Skip to content

v1.18.2

Choose a tag to compare

@KentQi KentQi released this 01 Nov 05:02
· 13 commits to main since this release
4fbb1d6

1.18.2 — 2026-10-29

A second atomic audit (ten P0, ten P1) was run against 1.18.1 with every finding reproduced against real git / real hook processes before counting. Every P0 and every P1 is fixed below; the doc claims the audit falsified are now true in code, not just in prose.

  • [P0] Repository-controlled core.sshCommand could execute during a review. The config scanner used \bcommand\s*=, which never matches sshCommand (camelCase: h is a word char), so repoRunsGitPrograms() returned no refusals while the allowlisted — and prompt-suggested — git remote show origin ran the repo's command twice before any network I/O. The scanner now reads config through git config --file <cfg> --no-includes --list -z (normalised keys, GIT_HARDENING_ARGS + isolated gitSpawnOpts) and refuses core.sshCommand, core.fsmonitor, core.pager, core.gitProxy, credential.helper, core.attributesFile, diff.external, and every live diff.*/filter.* driver; the regex path remains as a fallback when git is unavailable. The reviewer child process also gets GIT_CONFIG_* command-scope neutralisation (7 keys) so its own git calls cannot be steered by repo config.
  • [P0] core.attributesFile was never scanned. Git has three attribute sources; only the worktree .gitattributes and .git/info/attributes were read, so a repo could point core.attributesFile at its own file and have git log -p execute a textconv (measured: 6 executions, scanner saw nothing). That file is now a third attribute source: in-repo regular files are scanned, out-of-repo/irregular/oversized targets are refused.
  • [P0] MAX_ATTR_FILES hit inside the directory loop did not set truncated — the scan stopped silently instead of failing closed as its own comment and the 1.18.0 changelog promised. Now it refuses. (The audit's 4100-file repro was invalid — files named aN.gitattributes are not attribute files and were never counted; the honest fixture is one .gitattributes per directory, and the regression uses that.)
  • [P0] echo could exfiltrate host credentials. echo was exempt from path checking and a bare $VAR was not modelled as substitution, so echo $ANTHROPIC_API_KEY, echo ~/*, echo ../* were all allowed by the real hook (the child process inherits the host environment, and SECURITY.md promises no credential exfiltration). $VAR/$1/$? are now substitution and unquoted globs/~ in echo operands are refused — quote-protection is tracked by the tokenizer, so the legitimate literal forms (echo 'price: $5', echo '*') still run, while echo $ANTHROPIC_API_KEY and echo ~/* are denied. The tool description and SKILL.md say literal text only.
  • [P0] tr file operands were unchecked (defense in depth: neither BSD nor GNU tr takes FILE operands, so the audit's "arbitrary read" overstated it — but the pipe sink is now path-checked like every other sink).
  • [P0] The hook's Read branch failed open. When realContainment() threw (repo root deleted/renamed mid-run, unreadable parent, symlink loop) rel became null and the check was skipped, so Read /etc/passwd passed — while the Write branch denied. Read now denies on the same conditions, including rel === null.
  • [P0] A hook that could not read its stdin exited 0, which the CLI reads as "no opinion" — the call ran unguarded. It now emits a deny decision and exits non-zero (onFailure: "block" is the second line of defence).
  • [P0] baseRef was validated by two different validators. HEAD~1/feature@2 passed index.js and were then silently dropped by prompt.js, so a fleet review reviewed a different diff with no sign of it. One exported validator is now the single source of truth; a ref the preflight accepts is the ref the prompt uses (range operators ../... are refused instead of half-honoured), and the error text no longer recommends a value that gets discarded.
  • [P0] scope=pr without pr rendered "the GitHub pull request undefined" into the prompt and, on the native path, silently reviewed a different target. It is now refused before any paid work.
  • [P0] The followup path passed an empty denial set to shellChainingNote(), so every command the guard had correctly blocked was reported as "RAN WITHOUT BEING BLOCKED — treat this repository as hostile". Followup now classifies permission_denials exactly like review, and reports its own allowlist gaps too.
  • [P1] The shipped SKILL.md confidence scale (0/25/50/75/100) contradicted its own >= 80 filter — only 100-point findings survived. Filter and tool description are now >= 75, matching the "real, important, will be hit" anchor.
  • [P1] Active JSONL journals were not actually protected: activeSubagentFiles was written and never read, so the 20-file prune and the 2-hour sweep could delete a journal a live job still referenced. Both sweeps now skip active journals (and the run's own journal), and the set is cleared when the run settles.
  • [P1] cross-cases' C7 assertion was vacuous (the array was passed in the cond slot), so "no allow rule is prefix-shadowed by a deny rule" could never fail. It now asserts the filtered length.
  • [P1] The P0 regression suite was marked optional — deleting the only file that locks the P0 security fixes kept npm test green. It is required again; a missing suite is a failure.
  • [P1] The test suite leaked real credentials into $TMPDIR: cross-cases spawned init.mjs with the full host environment and never removed its temp tree. It now scrubs ANTHROPIC_*, injects a sentinel token, proves the written config carries only that sentinel, and removes the tree on exit/crash/signal.
  • [P1] The log/guard directory trusted whatever was at /tmp/cc-code-review. mode: 0o700 only applies on creation, and on Linux /tmp is shared — a pre-created directory (or a symlink to an attacker's) received run logs and guard-settings-*.json, which claude reads at spawn time. Both paths now refuse symlinks and foreign ownership and force 0700.
  • [P1] claude_review_env could throw on a malformed ANTHROPIC_BASE_URL (new URL() unguarded) instead of reporting it. Guarded and reported as unparseable.
  • [P1] overrides never reached consumers (npm only honours the root project's overrides), so the @hono/node-server pin was inert for anyone installing the package. It is a direct dependency now.
  • [P1] Model substitution was blind to claude→claude downgrades: sameModelFamily() returned true for any claude-* effective model, so a gateway silently running Sonnet as Haiku produced no ⚠ MODEL SUBSTITUTION. Families (opus/sonnet/haiku) are compared when a model was requested.
  • [P1] pipeline=native + shadowed skill returned through err() instead of preflightFail(), leaking the guard settings file and its in-flight mark on every such call.
  • Stale-process detection now has teeth. The hook exposes a hash of the policy tables (CC_REVIEW_GUARD_INFO=1) and the pre-run self-test compares it with the server's in-memory policy; an upgrade without a host restart is refused instead of running two different policies — the "the guard self-test refuses to run" promise README/SECURITY made is finally true.
  • Audit corrections recorded (so the next pass does not re-chase them): tr has no FILE operand in BSD/GNU coreutils (the guard change is defense in depth, not an arbitrary-read fix); the 4100-file MAX_ATTR_FILES repro was invalid; echo /etc/passwd prints a literal, not file contents — the real leak was wildcard/$VAR expansion.
  • Suites: shell-guard 238, audit-fixes 272, p0-regression 95, validation 16, cross-cases 139/139 (C1–C22), smoke PASS — npm test green.