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
7 changes: 7 additions & 0 deletions hooks/stop-review-gate-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ import {
import {
getClaudeAvailability,
getClaudeAuthStatus,
cleanupReviewMcpConfig,
cleanupSandboxSettings,
createReviewMcpConfig,
createSandboxSettings,
runClaudeReview,
SANDBOX_STOP_REVIEW_TOOLS,
Expand Down Expand Up @@ -167,14 +169,18 @@ function parseStopReviewOutput(rawOutput) {
async function runStopReview(cwd, input = {}) {
const prompt = buildStopReviewPrompt(input);
const sandboxSettingsFile = createSandboxSettings("read-only");
let mcpConfigFile = null;
const promptBytes = Buffer.byteLength(prompt, "utf8");

try {
mcpConfigFile = createReviewMcpConfig(resolveWorkspaceRoot(cwd));
const result = await runClaudeReview(cwd, prompt, {
allowedTools: SANDBOX_STOP_REVIEW_TOOLS,
maxTurns: 5,
permissionMode: "dontAsk",
settingsFile: sandboxSettingsFile,
mcpConfigFile,
strictMcpConfig: true,
});

if (result.status !== "completed") {
Expand Down Expand Up @@ -221,6 +227,7 @@ async function runStopReview(cwd, input = {}) {
reason: `The stop-time Claude Code review failed: ${detail}`
};
} finally {
cleanupReviewMcpConfig(mcpConfigFile);
cleanupSandboxSettings(sandboxSettingsFile);
}
}
Expand Down
16 changes: 7 additions & 9 deletions scripts/lib/claude-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -345,15 +345,6 @@ export const SANDBOX_READ_ONLY_BASH_TOOLS = [
"Bash(git config --get:*)",
];

export const SANDBOX_STOP_REVIEW_TOOLS = [
"Read",
"Glob",
"Grep",
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git show:*)",
];

/** read-only: file reading + read-only git + web + read-only agents. No writes, MCP, or skills. */
export const SANDBOX_READ_ONLY_TOOLS = [
"Read",
Expand Down Expand Up @@ -385,6 +376,13 @@ export const REVIEW_MCP_ALLOWED_TOOLS = REVIEW_MCP_TOOL_NAMES.map(
(name) => `mcp__${REVIEW_MCP_SERVER_NAME}__${name}`
);

export const SANDBOX_STOP_REVIEW_TOOLS = [
"Read",
"Glob",
"Grep",
...REVIEW_MCP_ALLOWED_TOOLS,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep staged diffs reviewable in the stop gate

When the last turn leaves changes staged, this new allowlist gives the stop review only the bundled MCP git surface for git inspection. The MCP diff handler currently builds only git diff --no-color and has no --cached/index mode (scripts/lib/mcp-git.mjs:287-290), so replacing Bash(git diff:*) removes the previous way to inspect staged patches such as git diff --cached. In that scenario the gate can see that files are staged but cannot review the actual staged diff before returning ALLOW; add cached/index support to the MCP diff tool or provide the staged diff context before dropping Bash here.

Useful? React with 👍 / 👎.

];

/**
* Tools exposed to review/adversarial-review runs. Bash is intentionally absent —
* the Claude CLI does not strictly enforce `Bash(<pattern>:*)` sub-patterns, so any
Expand Down
38 changes: 36 additions & 2 deletions tests/hooks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { SANDBOX_STOP_REVIEW_TOOLS } from "../scripts/lib/claude-cli.mjs";
import {
REVIEW_MCP_ALLOWED_TOOLS,
REVIEW_MCP_SERVER_NAME,
SANDBOX_STOP_REVIEW_TOOLS,
} from "../scripts/lib/claude-cli.mjs";
import { SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs";

const PROJECT_ROOT = path.resolve(
Expand Down Expand Up @@ -43,6 +47,12 @@ const args = process.argv.slice(2);
if (process.env.CLAUDE_ARGS_FILE) {
fs.writeFileSync(process.env.CLAUDE_ARGS_FILE, JSON.stringify(args, null, 2) + "\\n", "utf8");
}
if (process.env.CLAUDE_MCP_CONFIG_FILE) {
const mcpConfigIndex = args.indexOf("--mcp-config");
if (mcpConfigIndex >= 0 && args[mcpConfigIndex + 1]) {
fs.copyFileSync(args[mcpConfigIndex + 1], process.env.CLAUDE_MCP_CONFIG_FILE);
}
}

if (args[0] === "-p") {
if (process.env.CLAUDE_SILENT_FAIL === "1") {
Expand Down Expand Up @@ -258,7 +268,7 @@ function writeTurnBaselineSnapshot(testEnv, sessionId, fingerprint) {
}

describe("hooks", () => {
it("stop-review hook uses read-only sandbox settings when review gate is enabled", () => {
it("stop-review hook uses read-only sandbox and git MCP when review gate is enabled", () => {
const testEnv = createHookEnvironment();

try {
Expand All @@ -271,6 +281,7 @@ describe("hooks", () => {
);

const argsFile = path.join(testEnv.rootDir, "claude-args.json");
const mcpConfigCaptureFile = path.join(testEnv.rootDir, "claude-mcp-config.json");
const result = runHook(
STOP_HOOK,
[],
Expand All @@ -281,6 +292,7 @@ describe("hooks", () => {
{
...testEnv.env,
CLAUDE_ARGS_FILE: argsFile,
CLAUDE_MCP_CONFIG_FILE: mcpConfigCaptureFile,
}
);

Expand All @@ -296,6 +308,8 @@ describe("hooks", () => {
assert.ok(permissionModeIndex >= 0);
assert.equal(claudeArgs[permissionModeIndex + 1], "dontAsk");
assert.ok(claudeArgs.includes("--settings"));
assert.ok(claudeArgs.includes("--mcp-config"));
assert.ok(claudeArgs.includes("--strict-mcp-config"));

const allowedTools = [];
for (let i = 0; i < claudeArgs.length; i++) {
Expand All @@ -304,6 +318,26 @@ describe("hooks", () => {
}
}
assert.deepEqual(allowedTools, SANDBOX_STOP_REVIEW_TOOLS);
for (const allowedTool of allowedTools) {
assert.ok(
!/^Bash(\(|$)/.test(allowedTool),
`stop review allowlist must not contain Bash: ${allowedTool}`
);
}
for (const expectedTool of REVIEW_MCP_ALLOWED_TOOLS) {
assert.ok(allowedTools.includes(expectedTool), `missing ${expectedTool}`);
}

const mcpConfigIndex = claudeArgs.indexOf("--mcp-config");
const mcpConfigPath = claudeArgs[mcpConfigIndex + 1];
assert.equal(fs.existsSync(mcpConfigPath), false);
const capturedMcpConfig = JSON.parse(fs.readFileSync(mcpConfigCaptureFile, "utf8"));
const server = capturedMcpConfig.mcpServers[REVIEW_MCP_SERVER_NAME];
assert.ok(server);
assert.equal(
fs.realpathSync.native(server.env.CC_GIT_ROOT),
fs.realpathSync.native(testEnv.workspaceDir)
);
} finally {
cleanupHookEnvironment(testEnv);
}
Expand Down
21 changes: 21 additions & 0 deletions tests/sandbox-modes.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
SANDBOX_READ_ONLY_BASH_TOOLS,
SANDBOX_READ_ONLY_TOOLS,
SANDBOX_REVIEW_TOOLS,
SANDBOX_STOP_REVIEW_TOOLS,
SANDBOX_TEMP_DIR,
SANDBOX_SETTINGS,
REVIEW_MCP_SERVER_NAME,
Expand Down Expand Up @@ -312,6 +313,26 @@ describe("SANDBOX_REVIEW_TOOLS", () => {
});
});

describe("SANDBOX_STOP_REVIEW_TOOLS", () => {
it("uses read tools plus bundled git MCP tools without Bash", () => {
for (const t of ["Read", "Glob", "Grep"]) {
assert.ok(SANDBOX_STOP_REVIEW_TOOLS.includes(t), `missing ${t}`);
}
for (const t of SANDBOX_STOP_REVIEW_TOOLS) {
assert.ok(
!/^Bash(\(|$)/.test(t),
`stop review allowlist must not contain Bash: ${t}`
);
}
for (const expected of REVIEW_MCP_ALLOWED_TOOLS) {
assert.ok(
SANDBOX_STOP_REVIEW_TOOLS.includes(expected),
`missing MCP tool entry: ${expected}`
);
}
});
});

// ---------------------------------------------------------------------------
// 7. createReviewMcpConfig
// ---------------------------------------------------------------------------
Expand Down