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
40 changes: 40 additions & 0 deletions src/workspaces/adapters/ai-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <prompt>', () => {
expect(codexAdapter.composeHeadlessCommand!(['codex'], ctx(), 'do x')).toEqual([
'codex',
Expand Down
36 changes: 34 additions & 2 deletions src/workspaces/adapters/claude.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -93,6 +93,34 @@ function isRecord(value: unknown): value is Record<string, unknown> {
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.
Expand Down Expand Up @@ -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',
Expand Down