diff --git a/src/workspaces/adapters/ai-config.spec.ts b/src/workspaces/adapters/ai-config.spec.ts index 6f5f48433..7e1b78600 100644 --- a/src/workspaces/adapters/ai-config.spec.ts +++ b/src/workspaces/adapters/ai-config.spec.ts @@ -778,6 +778,46 @@ describe('composeHeadlessCommand (one-shot headless argv, prompt placed per-CLI) ]); }); + it('claude: a workspace .claude/settings.json permissions.allow widens --allowedTools', async () => { + // Claude Code `-p` gates non-safe Bash on the --allowedTools FLAG only, not + // on project settings files — so a workspace opts extra command prefixes + // into its own headless runs by committing them, and the adapter projects + // them onto the flag. Non-Bash/non-mcp entries are dropped. + await mkdir(join(dir, '.claude'), { recursive: true }); + await writeFile( + join(dir, '.claude/settings.json'), + JSON.stringify({ + permissions: { + allow: [ + 'Bash(bash orb-bot/run_live.sh:*)', + 'Bash(git push:*)', + 'mcp__foo__bar', + 'Read(./secrets)', + ], + }, + sandbox: false, + }), + ); + const argv = claudeAdapter.composeHeadlessCommand!(['claude'], { cwd: dir, env: {} }, 'do x'); + const allowed = argv[argv.indexOf('--allowedTools') + 1]; + expect(allowed).toBe( + 'Bash(alice:*),Bash(alice-workspace:*),Bash(alice-uta:*),Bash(traderhub:*),' + + 'Bash(bash orb-bot/run_live.sh:*),Bash(git push:*),mcp__foo__bar', + ); + }); + + it('claude: missing or malformed .claude/settings.json leaves the locked shim list untouched', async () => { + const locked = 'Bash(alice:*),Bash(alice-workspace:*),Bash(alice-uta:*),Bash(traderhub:*)'; + // missing file + let argv = claudeAdapter.composeHeadlessCommand!(['claude'], { cwd: dir, env: {} }, 'do x'); + expect(argv[argv.indexOf('--allowedTools') + 1]).toBe(locked); + // malformed file + await mkdir(join(dir, '.claude'), { recursive: true }); + await writeFile(join(dir, '.claude/settings.json'), '{ not json'); + argv = claudeAdapter.composeHeadlessCommand!(['claude'], { cwd: dir, env: {} }, 'do x'); + expect(argv[argv.indexOf('--allowedTools') + 1]).toBe(locked); + }); + it('codex: CLI-mode headless (no MCP) — approval/sandbox/network -c + exec --json -- ', () => { expect(codexAdapter.composeHeadlessCommand!(['codex'], ctx(), 'do x')).toEqual([ 'codex', diff --git a/src/workspaces/adapters/claude.ts b/src/workspaces/adapters/claude.ts index 53897f64b..c28c22280 100644 --- a/src/workspaces/adapters/claude.ts +++ b/src/workspaces/adapters/claude.ts @@ -1,4 +1,4 @@ -import { createReadStream } from 'node:fs'; +import { createReadStream, readFileSync } from 'node:fs'; import { readFile, realpath } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -93,6 +93,34 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +// A workspace may widen its OWN headless Bash surface by committing extra +// command-prefix rules to `.claude/settings.json` `permissions.allow`. Claude +// Code `-p` decides non-safe Bash from the `--allowedTools` FLAG only — it does +// not consult project settings files for that gate (verified 2.1.258) — so a +// workspace's committed intent has to be projected onto the flag here. The +// locked shim list above still applies to every workspace that ships no such +// file. Only `Bash(...)` / `mcp__*` entries are forwarded; a missing or +// malformed file leaves the default untouched. +function workspaceHeadlessAllowExtras(cwd: string): string[] { + try { + const parsed: unknown = JSON.parse( + readFileSync(join(cwd, '.claude/settings.json'), 'utf8'), + ); + if (!isRecord(parsed)) return []; + const perms = parsed['permissions']; + if (!isRecord(perms)) return []; + const allow = perms['allow']; + if (!Array.isArray(allow)) return []; + return allow.filter( + (entry): entry is string => + typeof entry === 'string' && + (entry.startsWith('Bash(') || entry.startsWith('mcp__')), + ); + } catch { + return []; + } +} + /** * Claude Code has two native gates that can consume the first interactive * screen before a seeded prompt: global onboarding and per-project trust. @@ -273,10 +301,14 @@ export const claudeAdapter: CliAdapter = { if (ctx.resume === 'last') { throw new Error('claude headless: resume requires a concrete resumeId mapping') } + const allowedTools = [ + HEADLESS_ALLOWED_TOOLS, + ...workspaceHeadlessAllowExtras(ctx.cwd), + ].join(','); return [ ...base, '--settings', AUTOTRUST_SETTINGS, - '--allowedTools', HEADLESS_ALLOWED_TOOLS, + '--allowedTools', allowedTools, ...(ctx.sessionRuntime?.headlessArgs ?? []), ...(ctx.resume ? ['--resume', ctx.resume.sessionId] : []), '-p', '--output-format', 'stream-json', '--verbose',