Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/utils/__tests__/claude-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
getClaudeSettingsPath,
getExistingStatusLine,
getRefreshInterval,
getSandboxConfig,
getVoiceConfig,
installStatusLine,
isClaudeCodeVersionAtLeast,
Expand Down Expand Up @@ -812,3 +813,113 @@ describe('getVoiceConfig', () => {
});
});
});

describe('getSandboxConfig', () => {
let testSandboxProjectDir = '';

function writeRawUserLocalSettings(content: string): void {
const settingsPath = path.join(testClaudeConfigDir, 'settings.local.json');
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, content, 'utf-8');
}

function writeRawProjectSettings(content: string): void {
const settingsPath = path.join(testSandboxProjectDir, '.claude', 'settings.json');
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, content, 'utf-8');
}

function writeRawProjectLocalSettings(content: string): void {
const settingsPath = path.join(testSandboxProjectDir, '.claude', 'settings.local.json');
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, content, 'utf-8');
}

beforeEach(() => {
testSandboxProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-sandbox-project-'));
});

afterEach(() => {
if (testSandboxProjectDir) {
fs.rmSync(testSandboxProjectDir, { recursive: true, force: true });
}
});

it('returns null when no candidate file exists', () => {
expect(getSandboxConfig(testSandboxProjectDir)).toBeNull();
});

it('returns { enabled: false } when settings.json has no sandbox field', () => {
writeRawClaudeSettings(JSON.stringify({ effortLevel: 'high' }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
});

it('returns { enabled: true } when sandbox.enabled is true', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});

it('returns { enabled: false } when sandbox.enabled is false', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: false } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
});

it('returns { enabled: false } when sandbox exists but enabled is missing', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { network: { allowAll: true } } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
});

it('treats malformed JSON as "no override"', () => {
writeRawClaudeSettings('{ not json');
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
});

it('treats an unexpected sandbox shape as "no override"', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: 'on' }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
});

it('respects CLAUDE_CONFIG_DIR env var', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
expect(getClaudeSettingsPath().startsWith(testClaudeConfigDir)).toBe(true);
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});

it('project-local overrides project (where /sandbox writes)', () => {
writeRawProjectSettings(JSON.stringify({ sandbox: { enabled: false } }));
writeRawProjectLocalSettings(JSON.stringify({ sandbox: { enabled: true } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});

it('project overrides user-global', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: false } }));
writeRawProjectSettings(JSON.stringify({ sandbox: { enabled: true } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});

it('user-local overrides user-global', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
writeRawUserLocalSettings(JSON.stringify({ sandbox: { enabled: false } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
});

it('a layer without sandbox.enabled does not clobber a lower layer', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
writeRawProjectSettings(JSON.stringify({ sandbox: { network: {} } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});

it('a malformed higher-priority layer does not clobber a defined lower layer', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
writeRawProjectLocalSettings('{ corrupt');
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});

it('falls through layers without sandbox.enabled until it finds a defined value', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
writeRawUserLocalSettings(JSON.stringify({ effortLevel: 'high' }));
writeRawProjectSettings(JSON.stringify({ sandbox: { network: {} } }));
writeRawProjectLocalSettings(JSON.stringify({ effortLevel: 'low' }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});
});
59 changes: 57 additions & 2 deletions src/utils/claude-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ export async function setRefreshInterval(interval: number | null): Promise<void>

const VoiceConfigSchema = z.object({ enabled: z.boolean().optional() });

function getVoiceConfigCandidatePathsByPriority(cwd: string): string[] {
function getLayeredSettingsCandidatePathsByPriority(cwd: string): string[] {
const userDir = getClaudeConfigDir();
const projectDir = path.join(cwd, '.claude');
// Highest priority first — `getVoiceConfig` returns on the first defined override.
Expand Down Expand Up @@ -571,7 +571,7 @@ function tryReadVoiceLayer(filePath: string): VoiceLayerResult {
*/
export function getVoiceConfig(cwd: string = process.cwd()): { enabled: boolean } | null {
let anyFileExisted = false;
for (const filePath of getVoiceConfigCandidatePathsByPriority(cwd)) {
for (const filePath of getLayeredSettingsCandidatePathsByPriority(cwd)) {
const layer = tryReadVoiceLayer(filePath);
if (layer.fileExisted) {
anyFileExisted = true;
Expand All @@ -583,6 +583,61 @@ export function getVoiceConfig(cwd: string = process.cwd()): { enabled: boolean
return anyFileExisted ? { enabled: false } : null;
}

const SandboxConfigSchema = z.object({ enabled: z.boolean().optional() });

function tryReadSandboxLayer(filePath: string): { fileExisted: boolean; enabled: boolean | undefined } {
let content: string;
try {
content = fs.readFileSync(filePath, 'utf-8');
} catch (error) {
// ENOENT is the common case (file just doesn't exist on this layer);
// any other I/O error is treated the same — caller has no recovery path.
const isMissing = (error as NodeJS.ErrnoException).code === 'ENOENT';
return { fileExisted: !isMissing, enabled: undefined };
}

try {
const parsed = JSON.parse(content) as { sandbox?: unknown };
const sandbox = parsed.sandbox;
if (sandbox === undefined || sandbox === null) {
return { fileExisted: true, enabled: undefined };
}
const result = SandboxConfigSchema.safeParse(sandbox);
return { fileExisted: true, enabled: result.success ? result.data.enabled : undefined };
} catch {
// Malformed JSON — file exists but contributes no override.
return { fileExisted: true, enabled: undefined };
}
}

/**
* Reads the effective `sandbox.enabled` setting — Claude Code's bash sandbox mode —
* from the same layered configuration as `getVoiceConfig` (project-local → project →
* user-local → user, highest priority first; user dir respects `CLAUDE_CONFIG_DIR`).
*
* `/sandbox` persists its toggle to `<cwd>/.claude/settings.local.json` (the
* highest-priority layer), so re-reading on each status refresh reflects runtime
* toggles, not just the configured default.
*
* - Returns `null` if no candidate settings file exists (Claude Code never initialised).
* - Returns `{ enabled: false }` if files exist but none defines `sandbox.enabled`
* (Claude Code's default — sandbox disabled).
* - Returns `{ enabled: <bool> }` reflecting the highest-priority override otherwise.
*/
export function getSandboxConfig(cwd: string = process.cwd()): { enabled: boolean } | null {
let anyFileExisted = false;
for (const filePath of getLayeredSettingsCandidatePathsByPriority(cwd)) {
const layer = tryReadSandboxLayer(filePath);
if (layer.fileExisted) {
anyFileExisted = true;
}
if (layer.enabled !== undefined) {
return { enabled: layer.enabled };
}
}
return anyFileExisted ? { enabled: false } : null;
}

const RemoteSessionFileSchema = z.object({
sessionId: z.string().optional(),
bridgeSessionId: z.string().nullable().optional()
Expand Down
1 change: 1 addition & 0 deletions src/utils/widget-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
{ type: 'link', create: () => new widgets.LinkWidget() },
{ type: 'claude-session-id', create: () => new widgets.ClaudeSessionIdWidget() },
{ type: 'claude-account-email', create: () => new widgets.ClaudeAccountEmailWidget() },
{ type: 'sandbox-status', create: () => new widgets.SandboxStatusWidget() },
{ type: 'session-name', create: () => new widgets.SessionNameWidget() },
{ type: 'free-memory', create: () => new widgets.FreeMemoryWidget() },
{ type: 'session-usage', create: () => new widgets.SessionUsageWidget() },
Expand Down
166 changes: 166 additions & 0 deletions src/widgets/SandboxStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
CustomKeybind,
Widget,
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';
import { getSandboxConfig } from '../utils/claude-settings';

const DOT_ON = '●';
const DOT_OFF = '○';
const LOCK_NERD_FONT = '';
const UNLOCK_NERD_FONT = '';

const FORMATS = ['glyph', 'text', 'word', 'bare'] as const;
type SandboxFormat = typeof FORMATS[number];

const DEFAULT_FORMAT: SandboxFormat = 'glyph';
const CYCLE_FORMAT_ACTION = 'cycle-format';
const TOGGLE_NERD_FONT_ACTION = 'toggle-nerd-font';
const NERD_FONT_METADATA_KEY = 'nerdFont';

function getFormat(item: WidgetItem): SandboxFormat {
const f = item.metadata?.format;
return (FORMATS as readonly string[]).includes(f ?? '') ? (f as SandboxFormat) : DEFAULT_FORMAT;
}

function setFormat(item: WidgetItem, format: SandboxFormat): WidgetItem {
if (format === DEFAULT_FORMAT) {
const { format: removedFormat, ...restMetadata } = item.metadata ?? {};
void removedFormat;

return {
...item,
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
};
}

return {
...item,
metadata: {
...(item.metadata ?? {}),
format
}
};
}

function isNerdFontEnabled(item: WidgetItem): boolean {
return item.metadata?.[NERD_FONT_METADATA_KEY] === 'true';
}

function toggleNerdFont(item: WidgetItem): WidgetItem {
if (!isNerdFontEnabled(item)) {
return {
...item,
metadata: {
...(item.metadata ?? {}),
[NERD_FONT_METADATA_KEY]: 'true'
}
};
}

const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {};
void removedNerdFont;

return {
...item,
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
};
}

function formatStatus(enabled: boolean, format: SandboxFormat, nerdFont: boolean): string {
const stateText = enabled ? 'ON' : 'OFF';
const glyph = nerdFont
? (enabled ? LOCK_NERD_FONT : UNLOCK_NERD_FONT)
: (enabled ? DOT_ON : DOT_OFF);

switch (format) {
case 'glyph':
return `SB: ${glyph}`;
case 'text':
return `SB: ${stateText}`;
case 'word':
return `Sandbox: ${stateText}`;
case 'bare':
return glyph;
}
}

function resolveSandboxConfigCwd(context: RenderContext): string | undefined {
const candidates = [
context.data?.workspace?.project_dir,
context.data?.cwd,
context.data?.workspace?.current_dir
];

return candidates.find(candidate => typeof candidate === 'string' && candidate.trim().length > 0);
}

export class SandboxStatusWidget implements Widget {
getDefaultColor(): string { return 'green'; }
getDescription(): string { return 'Shows whether Claude Code bash sandbox mode is enabled'; }
getDisplayName(): string { return 'Sandbox Status'; }
getCategory(): string { return 'Core'; }

getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
const modifiers: string[] = [getFormat(item)];
if (isNerdFontEnabled(item)) {
modifiers.push('nerd font');
}

return {
displayText: this.getDisplayName(),
modifierText: `(${modifiers.join(', ')})`
};
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
if (action === CYCLE_FORMAT_ACTION) {
const currentFormat = getFormat(item);
const nextFormat = FORMATS[(FORMATS.indexOf(currentFormat) + 1) % FORMATS.length] ?? DEFAULT_FORMAT;

return setFormat(item, nextFormat);
}

if (action === TOGGLE_NERD_FONT_ACTION) {
return toggleNerdFont(item);
}

return null;
}

render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
const format = getFormat(item);
const nerdFont = isNerdFontEnabled(item);

if (context.isPreview) {
if (item.rawValue) {
return 'on';
}
return formatStatus(true, format, nerdFont);
}

const config = getSandboxConfig(resolveSandboxConfigCwd(context));
if (config === null) {
return null;
}

if (item.rawValue) {
return config.enabled ? 'on' : 'off';
}

return formatStatus(config.enabled, format, nerdFont);
}

getCustomKeybinds(): CustomKeybind[] {
return [
{ key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION },
{ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION }
];
}

supportsRawValue(): boolean { return true; }
supportsColors(_item: WidgetItem): boolean { return true; }
}
Loading