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
27 changes: 20 additions & 7 deletions src/selfhost/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -882,13 +882,26 @@ export function createClaudeCodeAi(parentEnv: Record<string, string | undefined>
const prompt = toCliPrompt(options, systemAppend);
const spawn = spawnImpl ?? (await defaultSpawn());
const cwd = await isolatedCliCwd();
// bypassPermissions (not "plan"): --disallowedTools already forbids every mutating/networked tool, so
// nothing left needs an interactive approval prompt -- and this call has no TTY to answer one anyway.
// "plan" activates Claude Code's full interactive Plan-Mode WORKFLOW (explore, draft a plan, wait for
// ExitPlanMode approval), not just a permission restriction, which confused the model into treating a
// one-shot review request as an interactive planning session instead of returning a JSON verdict
// (#observability-plan-mode-injection-lookalike, live in ai_review_provider_unparseable_exhausted).
const args = ["--print", "--output-format", "json", "--model", claudeModel, "--permission-mode", "bypassPermissions", "--effort", effort, "--disallowedTools", "Bash,Edit,Write,WebFetch,WebSearch"];
// Keep bypassPermissions (not "plan") only to avoid a headless approval prompt; the actual boundary is
// tool removal. --tools "" removes every built-in tool, --strict-mcp-config prevents user/home MCP config
// from loading, and mcp__* is a defense-in-depth deny for CLIs that still have MCP tools available. This
// avoids a brittle mutating-tool denylist while preserving the one-shot JSON review flow.
const args = [
"--print",
"--output-format",
"json",
"--model",
claudeModel,
"--permission-mode",
"bypassPermissions",
"--effort",
effort,
"--tools",
"",
"--strict-mcp-config",
"--disallowedTools",
"mcp__*",
];
// A dedicated system-prompt-file channel (not textual stdin-prepending) marks repo instructions
// unambiguously SYSTEM rather than author-controlled content -- see writeClaudeSystemPromptFile's doc
// comment for why the textual-prepend approach this replaces was itself the bug.
Expand Down
6 changes: 5 additions & 1 deletion test/unit/selfhost-ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1316,7 +1316,7 @@ describe("subscription CLI helpers + fail-safe", () => {
expect(capturedInput).toBe("Review this diff.");
});

it("Claude Code runs with --permission-mode bypassPermissions, not plan (#observability-plan-mode-injection-lookalike): disallowedTools already forbids every mutating tool, and 'plan' activates the interactive Plan-Mode workflow instead of just restricting permissions", async () => {
it("Claude Code disables all built-in and MCP tools while keeping bypassPermissions headless (#observability-plan-mode-injection-lookalike)", async () => {
let seen: string[] = [];
const cap: StubSpawn = async (_c, a) => {
seen = a;
Expand All @@ -1325,6 +1325,10 @@ describe("subscription CLI helpers + fail-safe", () => {
await createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t" }, cap).run("", { prompt: "x" });
expect(seen[seen.indexOf("--permission-mode") + 1]).toBe("bypassPermissions");
expect(seen).not.toContain("plan");
expect(seen[seen.indexOf("--tools") + 1]).toBe("");
expect(seen).toContain("--strict-mcp-config");
expect(seen[seen.indexOf("--disallowedTools") + 1]).toBe("mcp__*");
expect(seen[seen.indexOf("--disallowedTools") + 1]).not.toContain("Bash");
});

it("chat-only CLIs reject embeds so the chain routes embeddings to an embed-capable provider (Claude review + ollama embed)", async () => {
Expand Down
Loading