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
130 changes: 130 additions & 0 deletions hooks/review-only-boundary-hook.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env node

/**
* Fail-closed PreToolUse boundary for Claude Code review/read-only runs.
*
* This hook is installed into the temporary read-only settings file generated by
* the cc companion, not into global Claude Code settings.
*/

import process from "node:process";

const BLOCKED_TOOLS = new Set(["write", "edit", "multiedit", "notebookedit", "task"]);

const READ_ONLY_GIT_COMMANDS = [
/^git\s+status(?:\s|$)/i,
/^git\s+diff(?:\s|$)/i,
/^git\s+log(?:\s|$)/i,
/^git\s+show(?:\s|$)/i,
/^git\s+blame(?:\s|$)/i,
/^git\s+rev-parse(?:\s|$)/i,
/^git\s+branch(?:\s|$)/i,
/^git\s+ls-files(?:\s|$)/i,
/^git\s+merge-base(?:\s|$)/i,
/^git\s+describe(?:\s|$)/i,
/^git\s+shortlog(?:\s|$)/i,
/^git\s+cat-file(?:\s|$)/i,
/^git\s+tag\s+--list(?:\s|$)/i,
/^git\s+stash\s+list(?:\s|$)/i,
/^git\s+config\s+--get(?:\s|$)/i,
];

const READ_ONLY_COMMANDS = [
...READ_ONLY_GIT_COMMANDS,
/^(?:pwd|cd\s*$|ls|dir)(?:\s|$)/i,
/^(?:get-childitem|gci)(?:\s|$)/i,
/^(?:rg|grep|findstr|select-string)(?:\s|$)/i,
/^(?:cat|type|get-content)(?:\s|$)/i,
/^node\s+(?:--version|-v|--check)(?:\s|$)/i,
/^npm\s+(?:view|list|ls|explain)(?:\s|$)/i,
/^claude\s+--version(?:\s|$)/i,
/^python(?:3)?\s+(?:.*?[\\/\s])?patcher\.py\b.*(?:--dry-run|--status|--verify)\b/i,
];

const MUTATING_COMMANDS = [
/\bnpm(?:\.cmd|\.ps1|\.exe)?\s+(?:install|i|update|upgrade|add|remove|uninstall|publish|pack|link|unlink|dedupe|rebuild|audit\s+fix)\b/i,
/\b(?:bun|pnpm|yarn)(?:\.cmd|\.ps1|\.exe)?\s+(?:install|add|remove|update|upgrade|publish|link|unlink)\b/i,
/\bpython(?:3)?\s+(?:.*?[\\/\s])?patcher\.py\b(?!.*(?:--dry-run|--status|--verify)(?:\s|$))/i,
/\bpython(?:3)?\s+.*(?:^|[\\/\s])drift-detector\.py\b/i,
/\b(?:git)\s+(?:add|commit|push|merge|rebase|reset|checkout|switch|restore|stash\s+(?:push|pop|apply|drop|clear)|clean|rm|mv|tag\s+(?!--list))\b/i,
/\b(?:rm|del|erase|rmdir|mv|move|cp|copy|mkdir|touch|chmod|chown|attrib|set-content|add-content|out-file|new-item|remove-item|move-item|copy-item|rename-item)\b/i,
/(?:>|>>|\|\s*(?:set-content|add-content|out-file)\b)/i,
];

function block(reason) {
console.error(`[cc-review-only-boundary] BLOCKED: ${reason}`);
process.exit(2);
}

function allow() {
process.exit(0);
}

function parseHookInput(raw) {
if (!raw.trim()) {
return {};
}
try {
return JSON.parse(raw);
} catch (error) {
block(`failed to parse hook input JSON: ${error.message}`);
}
}

function extractToolName(input) {
return String(
input.tool_name ??
input.toolName ??
input.tool?.name ??
input.name ??
""
).trim();
}

function extractToolInput(input) {
return input.tool_input ?? input.toolInput ?? input.input ?? {};
}

function extractCommand(input, toolInput) {
return String(
toolInput.command ??
toolInput.cmd ??
input.command ??
""
).trim();
}

let raw = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
raw += chunk;
});
process.stdin.on("end", () => {
const hookInput = parseHookInput(raw);
const toolName = extractToolName(hookInput);
const normalizedTool = toolName.toLowerCase();

if (BLOCKED_TOOLS.has(normalizedTool)) {
block(`${toolName || "unknown tool"} is not allowed in Claude Code review-only mode`);
}

if (normalizedTool !== "bash" && normalizedTool !== "powershell") {
allow();
}

const toolInput = extractToolInput(hookInput);
const command = extractCommand(hookInput, toolInput);
if (!command) {
block("shell command is missing from hook input");
}

if (MUTATING_COMMANDS.some((pattern) => pattern.test(command))) {
block(`mutating shell command is not allowed in review-only mode: ${command}`);
}

if (READ_ONLY_COMMANDS.some((pattern) => pattern.test(command))) {
allow();
}

block(`shell command is outside the review-only allowlist: ${command}`);
});
29 changes: 29 additions & 0 deletions scripts/lib/claude-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ import { randomBytes } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { normalizePathSlashes, resolvePluginRuntimeRoot } from "./codex-paths.mjs";
import { getProcessIdentity, validateProcessIdentity } from "./process.mjs";

const CLAUDE_BIN = "claude";
const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const REVIEW_ONLY_BOUNDARY_HOOK = normalizePathSlashes(
path.join(PLUGIN_ROOT, "hooks", "review-only-boundary-hook.mjs")
);
export const MAX_STREAM_PARSER_UNKNOWN_EVENTS = 50;
export const MAX_STREAM_PARSER_PARSE_ERRORS = 50;
export const MAX_STREAM_PARSER_TOOL_USES = 256;
Expand Down Expand Up @@ -388,6 +393,30 @@ export const SANDBOX_SETTINGS = {
allowedDomains: [],
},
},
hooks: {
PreToolUse: [
{
matcher: "Write|Edit|MultiEdit|NotebookEdit|Task",
hooks: [
{
type: "command",
command: `node "${REVIEW_ONLY_BOUNDARY_HOOK}"`,
timeout: 5,
},
],
},
{
matcher: "Bash|PowerShell",
hooks: [
{
type: "command",
command: `node "${REVIEW_ONLY_BOUNDARY_HOOK}"`,
timeout: 5,
},
],
},
],
},
},
"workspace-write": {
sandbox: {
Expand Down
64 changes: 64 additions & 0 deletions tests/review-only-boundary-hook.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Copyright 2026 Sendbird, Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const HOOK_PATH = path.join(REPO_ROOT, "hooks", "review-only-boundary-hook.mjs");

function runHook(input) {
return spawnSync(process.execPath, [HOOK_PATH], {
input: JSON.stringify(input),
encoding: "utf8",
timeout: 5_000,
});
}

function shellInput(command) {
return { tool_name: "Bash", tool_input: { command } };
}

describe("review-only boundary hook", () => {
it("blocks write-class tools", () => {
const result = runHook({ tool_name: "Write", tool_input: { file_path: "x" } });
assert.equal(result.status, 2);
assert.match(result.stderr, /BLOCKED: Write is not allowed/);
});

it("passes the patcher read-only command matrix", () => {
const cases = [
["python patcher.py --dry-run", 0],
["python patcher.py --status", 0],
["python patcher.py --verify", 0],
["python C:/tools/cc-patcher/patcher.py --dry-run", 0],
["python patcher.py --apply", 2],
["python patcher.py", 2],
["python ./notpatcher.py --dry-run", 2],
];

for (const [command, expectedStatus] of cases) {
const result = runHook(shellInput(command));
assert.equal(result.status, expectedStatus, command);
}
});

it("blocks mutating package manager and git commands", () => {
for (const command of ["npm install", "pnpm add left-pad", "git commit -m x"]) {
const result = runHook(shellInput(command));
assert.equal(result.status, 2, command);
assert.match(result.stderr, /mutating shell command is not allowed/);
}
});

it("allows read-only shell commands", () => {
for (const command of ["git diff --stat", "rg review-only", "npm view cc-plugin-codex version"]) {
const result = runHook(shellInput(command));
assert.equal(result.status, 0, command);
}
});
});
20 changes: 20 additions & 0 deletions tests/sandbox-modes.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,26 @@ describe("sandbox settings content", () => {
SANDBOX_SETTINGS["workspace-write"].sandbox.network
);
});

it("read-only: installs scoped review-only PreToolUse hooks", () => {
const hooks = SANDBOX_SETTINGS["read-only"].hooks?.PreToolUse ?? [];
assert.equal(hooks.length, 2);
assert.deepEqual(
hooks.map((entry) => entry.matcher),
["Write|Edit|MultiEdit|NotebookEdit|Task", "Bash|PowerShell"]
);

for (const entry of hooks) {
assert.equal(entry.hooks.length, 1);
assert.equal(entry.hooks[0].type, "command");
assert.match(entry.hooks[0].command, /node ".*review-only-boundary-hook\.mjs"$/);
assert.equal(entry.hooks[0].timeout, 5);
}
});

it("workspace-write: does not install review-only hooks", () => {
assert.equal(SANDBOX_SETTINGS["workspace-write"].hooks, undefined);
});
});

// ---------------------------------------------------------------------------
Expand Down