Compare commits

...
2 Commits
Author SHA1 Message Date
Brent Vatne 75fccfe3f4 fix: strip HTML tags from the inline-finding excerpt in the main comment
The short-form excerpt escaped every <, so a model-written <br> showed as
literal text. Remove HTML formatting tags first, then escape what is left.
2026-10-01 16:28:09 -07:00
Brent VatneandClaude Opus 5.5 621c354568 fix: load only the granted read tools in Claude Code review passes
`--permission-mode dontAsk` rejects only tools that ask for permission. Against
Claude Code 2.1.280, a review pass could still run EnterWorktree (it created a
git worktree), RemoteTrigger (it listed the logged-in account's cloud
routines), SendMessage, and Skill, because none of them is on the deny list and
none asks for permission.

buildClaudeArgs now passes `--tools` with exactly the granted read tools
(`Read,Grep,Glob` or fewer, and "" for the coordinator). The CLI loads only
those built-ins, so a tool a future CLI version adds is never loaded. MCP tools
and the StructuredOutput tool used by --json-schema stay loaded. The scoped
allow rules, dontAsk, and ALWAYS_DENIED_TOOLS stay as inner layers.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-24 10:03:50 -07:00
5 changed files with 73 additions and 22 deletions

No files matched your search

@@ -174,12 +174,22 @@ reads are denied by the unmatched-rule denial, and a withheld tool is denied by
([observed] `claude-code.ts:241-321`; behavior verified against `claude 2.1.212`).
Write/exec/network tools (`Bash`, `Edit`, `Write`, `NotebookEdit`, `WebFetch`,
`WebSearch`, `Task`, …) are always denied for every pass regardless of role, via the
`ALWAYS_DENIED_TOOLS` enumeration. A deny **enumeration** is used rather than an
allow-only model because permission rules cannot fail closed for tools outside the read
set and a `*` deny breaks tool calling outright — both verified against `claude 2.1.212`.
The residual (a future CLI version shipping a new read-capable tool this list does not
name) is bounded by pinning the CLI version, so **the list must be revisited on every
pinned-version bump** ([observed] `claude-code.ts:86-111`).
`ALWAYS_DENIED_TOOLS` enumeration. Permission rules alone cannot fail closed for tools
outside the read set, and a `*` deny breaks tool calling outright — both verified
against `claude 2.1.212`.
**`--tools` is the outer bound.** `--tools` is not a permission rule: it names the
built-in tools the CLI **loads**. ecr passes exactly the granted read tools
(`Read,Grep,Glob` or fewer), and `""` for the coordinator and the no-tools fallback.
A tool that is not loaded cannot be called, so a new tool in a future CLI version
stays out without anyone adding its name. The deny list was not enough on its own:
against `claude 2.1.280`, `dontAsk` rejected `Monitor` and `Workflow`, but
`EnterWorktree` created a git worktree, `RemoteTrigger` listed the logged-in
account's cloud routines, and `SendMessage` and `Skill` ran, because those tools ask
no permission. MCP tools (the research tools) and the CLI's `StructuredOutput` tool
(used by `--json-schema`) are outside the built-in set and stay loaded — also verified
against `2.1.280`. The scoped allow rules and `ALWAYS_DENIED_TOOLS` stay as inner
layers ([observed] `claude-code.ts` `buildClaudeArgs`, `ALWAYS_DENIED_TOOLS`).
**The env is an allowlist.** The child env is built from `CHILD_ENV_ALLOWLIST` (PATH,
HOME, locale, tmp, proxies, config dirs — what a CLI needs and nothing else), never a
+8
View File
@@ -245,6 +245,9 @@ test("buildClaudeArgs: read-only, trust-isolated, subscription argv; never --bar
);
expect(joined).not.toContain(" Grep ");
expect(joined).not.toContain(" Glob ");
// --tools loads only the granted read tools, so an ungranted built-in (a new one
// in a future CLI included) never reaches the model.
expect(args[args.indexOf("--tools") + 1]).toBe("Read,Grep,Glob");
// Write/exec/net tools are denied outright; out-of-tree reads are denied by
// dontAsk's unmatched-rule denial (NO explicit Read deny: `Read(~/**)` would
// deny the whole tree whenever the repo lives under the home directory).
@@ -582,6 +585,7 @@ test("buildClaudeArgs: cross-cutting/verifier get Read+Grep scoped, Glob denied
// withheld read tool must be denied BY NAME, not just left out of --allowedTools.
expect(joined).toContain("--allowedTools Read(//work/repo/**) Grep(//work/repo/**)");
expect(joined).not.toContain("Glob(/");
expect(args[args.indexOf("--tools") + 1]).toBe("Read,Grep");
expect(joined).toContain(
"--disallowedTools Glob Bash Edit Write NotebookEdit NotebookRead WebFetch WebSearch Task TodoWrite BashOutput KillShell ExitPlanMode",
);
@@ -616,6 +620,8 @@ test("buildClaudeArgs: empty tools (coordinator/no-tools fallback) deny every re
// No --allowedTools at all, and Read/Grep/Glob explicitly denied so dontAsk's
// default-allow can't grant a read.
expect(joined).not.toContain("--allowedTools");
// An empty --tools value loads no built-in tool at all.
expect(args[args.indexOf("--tools") + 1]).toBe("");
expect(joined).toContain(
"--disallowedTools Read Grep Glob Bash Edit Write NotebookEdit NotebookRead WebFetch WebSearch Task TodoWrite BashOutput KillShell ExitPlanMode",
);
@@ -643,6 +649,8 @@ test("buildClaudeArgs: explicit research MCP replaces safe mode without loading
expect(joined).toContain("mcp__platform_docs__search_platform_docs");
expect(joined).toContain("mcp__platform_docs__fetch_platform_doc");
expect(joined).toContain("--mcp-config /private/mcp.json");
// MCP tools sit outside the built-in set, so --tools does not remove them.
expect(args[args.indexOf("--tools") + 1]).toBe("Read");
expect(joined).toContain("--setting-sources --disable-slash-commands");
expect(joined).toContain("--strict-mcp-config");
expect(joined).toContain("--no-session-persistence");
+10 -2
View File
@@ -89,9 +89,17 @@ test("inlineStubBody: neutral — never claims resolved or dismissed", () => {
// ---- oneLineRationale ----
test("oneLineRationale: collapses whitespace, escapes <, truncates at a word boundary", () => {
test("oneLineRationale: strips HTML tags, collapses whitespace, escapes <, truncates at a word boundary", () => {
expect(oneLineRationale("a\n\nb c")).toBe("a b c");
expect(oneLineRationale("<details>x</details>")).toBe("&lt;details>x&lt;/details>");
expect(oneLineRationale("<details>x</details>")).toBe("x");
expect(oneLineRationale("High — reason.<br>**Impact:** Medium<BR/>end")).toBe(
"High — reason. **Impact:** Medium end",
);
expect(oneLineRationale("<details><summary>Why</summary>\n\nbody</details>")).toBe("Why body");
// Non-formatting angle brackets stay, escaped.
expect(oneLineRationale("returns `Promise<void>` when a < b")).toBe(
"returns `Promise&lt;void>` when a &lt; b",
);
const long = Array.from({ length: 60 }, (_, i) => `word${i}`).join(" ");
const short = oneLineRationale(long);
expect(short.length).toBeLessThanOrEqual(161);
+20 -8
View File
@@ -92,17 +92,16 @@ const READ_TOOL_MAP: Record<string, "Read" | "Grep" | "Glob"> = {
glob: "Glob",
};
const ALL_READ_TOOLS = ["Read", "Grep", "Glob"] as const;
// @ref LLP 0003#claude-code-cli-containment [implements] — deny enumeration (not allow-only) because an empty/absent --allowedTools list default-ALLOWS reads; verified against claude 2.1.212, tool list re-checked against 2.1.280 (ListAgents added), revisit on every CLI version bump
// @ref LLP 0003#claude-code-cli-containment [implements] — inner deny layer behind --tools, because an empty/absent --allowedTools list default-ALLOWS reads; verified against claude 2.1.212, tool list re-checked against 2.1.280 (ListAgents added)
/**
* Tools never available to a review pass, whatever the role. A DENY enumeration is
* the only workable containment: permission rules cannot fail closed here — reads
* Tools never available to a review pass, whatever the role. The primary bound is
* `--tools` (see buildClaudeArgs), which loads only the granted read tools; this
* list is the inner layer behind it. Permission rules alone cannot fail closed — reads
* inside the workspace are default-ALLOWED even when an allow list is present but
* unmatched, and a `*` deny breaks tool calling outright (both verified against
* claude 2.1.212). The residual risk — a FUTURE CLI version shipping a new
* read-capable tool this list doesn't name — is bounded by pinning the CLI version
* (the scaffolded workflow installs an exact @anthropic-ai/claude-code version;
* bump it deliberately and revisit this list). Unknown names are ignored by the
* CLI, so denying tools that don't exist in a given version is harmless.
* claude 2.1.212). A FUTURE CLI version shipping a new tool this list doesn't name
* is no longer the gap it was: `--tools` does not load it. Unknown names are ignored
* by the CLI, so denying tools that don't exist in a given version is harmless.
*/
const ALWAYS_DENIED_TOOLS = [
"Bash",
@@ -274,6 +273,16 @@ export function claudeModelMatches(requested: string, actualKey: string): boolea
* tree itself (paths resolve to absolute), and `Read(~/**)` denies the whole tree
* whenever the repo lives under the home directory — the common case.
*
* `--tools` is the outer bound: it names the built-in tools the CLI LOADS, so
* everything else (including tools a future CLI version adds) never reaches the
* model. It is an availability allowlist, not a permission rule, so it fails closed
* where `--allowedTools` does not. Verified against claude 2.1.280: `dontAsk` does
* NOT stop tools that ask no permission — EnterWorktree created a git worktree,
* RemoteTrigger listed the account's cloud routines, SendMessage and Skill ran — and
* `--tools` removes them all. MCP tools (the research tools) and the CLI's own
* StructuredOutput tool (`--json-schema`) are not in the built-in set and stay
* loaded. The scoped allow rules and ALWAYS_DENIED_TOOLS stay as the inner layers.
*
* This is NOT, by itself, a boundary against a symlink committed inside the PR-head
* tree (e.g. `docs/notes.md -> ~/.claude/.credentials.json`): the permission rule
* matches the literal path ARGUMENT, which is in-tree, but Read/Grep then follow the
@@ -325,6 +334,9 @@ export function buildClaudeArgs(opts: {
"--append-system-prompt",
opts.system,
...(opts.jsonSchema ? ["--json-schema", JSON.stringify(opts.jsonSchema)] : []),
// @ref LLP 0003#claude-code-cli-containment [implements] — load only the granted read tools; "" loads none
"--tools",
enabled.join(","),
...(allowedTools.length > 0 ? ["--allowedTools", ...allowedTools] : []),
"--disallowedTools",
...deniedReadTools,
+19 -6
View File
@@ -385,14 +385,27 @@ function sourceLabel(value: string): string {
const SHORT_RATIONALE_CHARS = 160;
/**
* A rationale reduced to one plain line for the short (inlined) form: whitespace
* collapsed, every `<` escaped so a truncated HTML block (`<details>`) can never
* leave an unclosed tag in the comment, cut at a word boundary with an ellipsis.
* This excerpt is the audit trail that survives the PR author collapsing or
* resolving the inline thread, so it must always be non-empty when the rationale is.
* HTML formatting tags a model writes into a rationale (`<br>`, `<details>`, ...).
* A fixed list, not "anything in angle brackets", so a type like `Promise<void>`
* in the prose survives.
*/
const FORMATTING_TAG_RE =
/<\/?(?:a|b|blockquote|br|code|del|details|div|em|h[1-6]|hr|i|kbd|li|ol|p|pre|s|span|strong|sub|summary|sup|table|tbody|td|th|thead|tr|u|ul)\b[^<>]*>/gi;
/**
* A rationale reduced to one plain line for the short (inlined) form: HTML
* formatting tags removed (an escaped `<br>` would show as literal text),
* whitespace collapsed, every remaining `<` escaped so a truncated HTML block can
* never leave an unclosed tag in the comment, cut at a word boundary with an
* ellipsis. This excerpt is the audit trail that survives the PR author collapsing
* or resolving the inline thread, so it must always be non-empty when the rationale is.
*/
export function oneLineRationale(text: string): string {
const flat = text.replace(/</g, "&lt;").replace(/\s+/g, " ").trim();
const flat = text
.replace(FORMATTING_TAG_RE, " ")
.replace(/</g, "&lt;")
.replace(/\s+/g, " ")
.trim();
if (flat.length <= SHORT_RATIONALE_CHARS) {
return flat;
}