* fix(#2547): fail closed on a malformed Kimi edit list in normalizeKimiPayload
`normalizeKimiPayload` rebuilt old_string/new_string with
`String(e.old ?? '')`. `??` guards the value, not the dereference, so a
nullish entry in a Kimi `edit` list threw a TypeError at the top of the
handler, before any tool dispatch. Each guard's outer
`catch { process.exit(0) }` swallowed that crash and emitted the same exit
code as "nothing to report" — turning a should-BLOCK call into a silent
allow.
Two hard blocks were bypassable:
* gsd-worktree-path-guard's cross-git-root write block (#260) — a
StrReplaceFile write whose path resolves to a different git root is
correctly blocked with a well-formed edit list, and silently allowed
with `edit: [null]`.
* gsd-workflow-guard's force-add block on agent-* branches — a Shell
payload carrying a spurious `edit: [null]` field walks past it. The
Bash path never reads `edit`; the field only has to be present to
trigger the crash.
Fixed with `e?.old` / `e?.new`, landed identically in all five copies so
tests/kimi-guard-normalization-parity.test.cjs's byte-identity assertion
still holds.
The crash boundary is nullish specifically, not "non-object": `('x').old`
and `(7).old` are legal reads yielding undefined, so string/number entries
never threw. Both are kept as controls proving the fix did not change
their behaviour.
Regression coverage is folded into the owning suites per CONTRIBUTING.md
(no new bug-* files). Negative-controlled: the nullish cases exit 0
against pre-fix guards and exit 2 after, with positive controls (the
equivalent well-formed payload blocks) and negative controls (in-worktree
writes and benign commands still pass) alongside.
Refs #2547
* test(#2547): exercise the production Kimi payload shape in read-guard tests
The `#2304: Kimi tool vocabulary engages the read guard` cases send
payloads with no `session_id`, and runHook injects none. A live Kimi turn
always carries one — kimi-cli's hooks/events.py `_base()` sets it
unconditionally, and soul/kimisoul.py calls `set_session_id()` at the top
of every turn before tool dispatch, so the ContextVar's `default=""` never
reaches a tool call.
gsd-read-guard treats any non-empty `data.session_id` as "Claude Code
already enforces read-before-edit, skip" (#2520). So the advisory those
tests assert fires only for a shape production never sends: the tests were
green, and the guard was dormant on Kimi. A sibling #2520 case in the same
file asserts the skip when `session_id` IS present — both passed, and the
production shape hits the skip.
Two changes, test-validity only:
* Retitle the #2304 block to say what it proves — the tool VOCABULARY is
normalized through to the Write/Edit branch — with a comment warning
not to read it as production evidence.
* Add a #2547 block asserting behaviour against the production shape
(session_id populated), including a case that pins the delta directly:
the same payload fires without session_id and is silent with it.
The #2547 block characterizes a known gap; it does not endorse it.
Redesigning how the guard discriminates runtimes is explicitly out of
scope for #2547. If a later change makes the advisory fire on Kimi these
tests are supposed to fail — update them then rather than dropping the
coverage.
Refs #2547
* docs(#2547): scope the Kimi guard-engagement claim to what Kimi enforces
#2518 engaged the guards' Kimi matchers and the release notes describe the
result as "All seven guard hooks now engage on Kimi", singling out the
prompt-injection read scanner as "the security-relevant guard" taken "from
silently dormant to engaged". That is not achievable for the scanner at
the emit layer.
gsd-read-injection-scanner.js is a PostToolUse hook, and kimi-cli's
dispatch never inspects PostToolUse hook results: src/kimi_cli/soul/
toolset.py awaits PreToolUse and honours `result.action == "block"`, but
fires PostToolUse via asyncio.create_task() and returns the ToolResult
without awaiting it — the done_callback only retrieves the task's own
exception. So no output shape the scanner emits can block or flag a Kimi
tool call, and `security.injection_blocking` cannot take effect there.
Reshaping the scanner's output would not change this; the enforcement gap
is in kimi-cli's PostToolUse handling, which is out of scope here.
This corrects the claim rather than the code — there is no gsd-core emit
fix that would make it true:
* .changeset/2304-kimi-guard-tool-name.md — the fragment is unreleased,
so it would otherwise ship this as a CHANGELOG security claim.
Headline narrowed to "normalize Kimi's payload shape" and a scope
paragraph added naming what actually blocks on Kimi (the two
PreToolUse blocks) versus what cannot.
* docs/migration/kimi-to-kimi-code.md — the scanner was listed under
"Every GSD `PreToolUse` guard"; it is PostToolUse. Corrected, and the
"What about the dormant guards?" section now splits enforceable from
not-enforceable instead of saying Phase 0 "fixed all seven".
* hooks/gsd-read-injection-scanner.js — the same scope note in the
file's own Kimi rationale comment, where the next contributor to touch
the normalization will actually read it. Comment only; the shared
normalization block is untouched and byte-identity still holds.
Refs #2547
* chore(#2547): regenerate golden install-parity fixtures for the guard fix
The golden install-parity fixtures record a content hash per installed
file, so changing the five guard hooks changes their hashes across every
runtime's fixture. Regenerated with the full sweep (build, gen:golden,
size:baseline) rather than a single generator — running gen:golden alone
leaves tests/workflow-size-baseline.json stale and loses CI jobs to a
regeneration that looked complete.
The size baselines came out unchanged (no workflow or agent bodies
touched) and the hash delta is confined to exactly the five guards:
gsd-prompt-guard, gsd-read-guard, gsd-read-injection-scanner,
gsd-workflow-guard, gsd-worktree-path-guard.
Refs #2547
* fix(#2547): guard the String() coercion in normalizeKimiPayload too
Found by adversarial review of the first commit, then reproduced against
pristine next: `e?.old` closes the nullish dereference but leaves a second
route to the same crash-to-allow.
`{"toString": null}` is valid JSON, and coercing it throws
`TypeError: Cannot convert object to primitive value` — so an edit entry
that IS a well-formed object still crashes normalization, still lands in
the outer `catch { process.exit(0) }`, and still downgrades a should-BLOCK
call to a silent allow. Confirmed on both hard blocks:
{"tool_name":"Shell","tool_input":{
"command":"git add -f secret.env",
"edit":[{"old":{"toString":null},"new":"x"}]}} -> exit 0 (was)
{"tool_name":"StrReplaceFile","tool_input":{
"path":"<main-repo>/src/index.ts",
"edit":[{"old":{"toString":null},"new":"x"}]}} -> exit 0 (was)
Both exit 2 now.
The coercion is wrapped rather than replaced with a `typeof === 'string'`
test on purpose. Degrading only the non-coercible entry keeps
stringification identical for every value that CAN coerce — numbers,
arrays, plain objects — which matters because gsd-prompt-guard scans
new_string for injection patterns, and a `typeof` test would silently stop
scanning content that reaches that scan today (e.g. `new: ["ignore all
previous instructions"]` currently stringifies and is scanned). Verified:
zero behaviour change across string, number, bool, null, array-of-strings,
nested array, plain object and `__proto__`-keyed input; only the throwing
case changes, from crash to ''.
Regression cases are negative-controlled against the previous commit: the
four new coercion-trap tests fail with only the `e?.old` fix in place and
pass with this one.
Refs #2547
* chore(#2547): cover the String() coercion vector in the changeset
The release note described only the nullish-dereference route. Both routes
reach the same fail-open, so both belong in the changelog entry, along with
why the coercion is wrapped rather than type-tested.
Refs #2547
* chore(#2547): point the changeset fragment at the real PR number
The fragment has to exist before `gh pr create` runs, so it carried the
issue number as a placeholder. Corrected to 2595 now that the PR is open.
Refs #2547
* fix(#2547): make Kimi's `path` authoritative over a model-supplied `file_path`
normalizeKimiPayload copied Kimi's `path` into `file_path` only when
`file_path === undefined`, so any `file_path` the model chose to include won
outright. Every guard reads `file_path`; kimi-cli executes on `path`. The guard
therefore inspected one file while the write landed on another.
This bypass needs no crash. A payload pairing a cross-root `path` with a
spurious `file_path: ""` left gsd-worktree-path-guard reading an empty string
and exiting 0, while the identical write without the extra key blocked — the
same cross-root write the #260 block exists to catch. The shadowing also
preserved a non-string `file_path` (`[]`), which threw inside that guard's
path.isAbsolute() and reached its outer `catch { process.exit(0) }`: the same
crash-to-allow the rest of #2547 closes, reached through the guard's own read
rather than through normalization.
Reachability is not speculative. kimi-cli's soul/toolset.py json-parses the
model's raw tool arguments and passes that dict verbatim as tool_input to
PreToolUse, performing typed validation only later inside tool.call() — after
the hook has already decided. So the model controls extra keys in tool_input at
the moment the guard runs. kimi-cli's file tools carry no `file_path` field at
all (src/kimi_cli/tools/file/write.py, replace.py), so a `file_path` in a Kimi
payload is always model-supplied.
`path` now wins outright. Overwriting can only ever narrow what a guard inspects
to the path that will actually be written, so it cannot under-block.
Normalization returns early for non-Kimi tool names, so the native Claude Code
contract (file_path governs) is untouched.
Landed identically across all five inlined copies; the byte-identity assertion
in tests/kimi-guard-normalization-parity.test.cjs enforces that.
* test(#2547): cover the file_path-shadowing bypass in the #260 guard suite
Four cases, each exiting 0 (bypass) against the pre-fix guards: a spurious
empty-string file_path, an in-worktree decoy file_path, and non-string
file_path values (array and object) that additionally crashed
path.isAbsolute() into the outer catch.
Two controls that are not bypass cases and matter as much:
- an in-worktree write carrying a cross-root DECOY file_path must still exit
0. Pre-fix this blocked, because the decoy won; the guard now follows the
path kimi-cli executes on in both directions, so the fix narrows what is
inspected without over-blocking.
- a native Claude Edit (no `path` field) must still block on file_path alone.
normalizeKimiPayload returns early for non-Kimi tool names, and this pins
that the non-Kimi contract did not move. It passes both pre- and post-fix
by design.
Negative-controlled: run against the pre-fix hooks, the four bypass cases and
the decoy control fail, and the native-Claude control passes.
* test(#2547): back the totality claim with property tests over fc.anything()
This PR claims the fix "makes normalization total over the inputs JSON can
express" — a for-all guarantee — while the tests backing it are example-based,
each shape added reactively after a crash was found by hand (the String()
coercion trap was itself found by adversarial review after the first commit
shipped). Example-based tests cannot substantiate a for-all claim; they record
the counterexamples someone happened to think of.
Four properties over fc.anything(), which is exactly the JSON-expressible
domain the claim names:
(a) totality over any tool_input
(b) totality over any edit list — the crash surface both #2547 fixes targeted
(c) `path` always wins over any model-supplied `file_path` (the review blocker
invariant: a guard reading file_path can never be aimed at a file other
than the one kimi-cli writes)
(d) a non-Kimi tool_name passes through untouched — the native Claude contract
normalizeKimiPayload is inlined per hook with no runtime binding, so there is
nothing to require. The block is extracted from hook source and evaluated via
the SAME extraction contract kimi-guard-normalization-parity.test.cjs uses, so
a source edit that breaks one breaks both instead of silently testing a stale
block. An extraction floor test fails loudly if the extraction yields a no-op.
Non-vacuous, and checked rather than assumed: against pristine pre-#2547 `next`,
(a), (b) and (c) all FAIL and (d) passes. (a) needed the fix that makes it
meaningful — a bare fc.anything() for tool_input passed even against the live
defect, because arbitrary generation essentially never invents the `edit` key
the crash lives behind, so the generator is biased onto the keys normalization
actually reads and unioned back with unbiased input.
* chore(#2547): cover the shadowing vector in the changeset and regen goldens
Golden install-parity churn is hash-only, on exactly the five hook files this
round changed. gsd-phase-boundary.sh is deliberately unchanged.
* test(#2547): make the property test able to kill the coercion mutant
Review Major 1: the generative test added to stop the NEXT counterexample
could not kill the one it was written for. Reproduced the reviewer's matrix
independently — against the shipped generator, a mutant reverting `editText`
to the unguarded `String(v ?? '')` passed all four properties.
Cause, confirmed by measurement: the edit-array ENTRIES were bare
`fc.anything()`, which essentially never invents an `old`/`new` key, so
`e?.old` was always undefined and `String(undefined ?? '')` never coerced
anything. That is the same vacuity the file's own comment describes one level
up, reproduced one level down.
The review's prescribed fix — bias the entry onto `{old, new}` — is necessary
but NOT sufficient, and this is the part worth recording: measured over 20,000
draws, bare `fc.anything()` yields a non-coercible value 3 times (0.015%). At
`numRuns: 200` an `old` key holding a hostile value essentially never
co-occurs, and the mutant survives the entry bias too. Both levels need
biasing — the entry onto the keys normalization reads, and the VALUE onto the
shape that actually throws.
`{"toString": <non-function>}` is that shape and stays inside the
"JSON-expressible" domain the claim names (JSON.parse produces it verbatim);
`fc.anything({withNullPrototype: true})` would also kill the mutant but widens
the domain past what the PR asserts, so it is not used.
Verified: M1 now dies at every seed tried (1/7/42/99/4242/31337, failing
within 3-31 cases) while HEAD stays green at all of them.
Also closes three coverage gaps the review listed as nits — properties (e)
totality over any JSON value as the WHOLE payload, (f) the tool_output →
tool_response mapping (including that an existing tool_response is not
clobbered), and (g) an empty edit list reconstructing nothing.
Property (e) required a one-line fix in the normalizer itself: `JSON.parse
('null')` is null, and null/primitive payloads threw on the `data.tool_name`
read — falsifying the "total over the inputs JSON can express" claim. Harmless
in practice (the throw landed in the same fail-open catch as the exit 0 it now
takes deliberately), but the claim should be true as stated. Landed
byte-identically across all five copies.
Refs #2547
* fix(#2547): make old_string/new_string authoritative, not merely defaulted
Review Major 2: the shadowing class the prior round's BLOCKER closed for
`file_path` survived one field over. `old_string`/`new_string` were still
reconstructed only `if (input.<field> === undefined)`, so a model-supplied
value won.
The argument for making `path` authoritative applies verbatim here.
kimi-cli's StrReplaceFile schema is `path` + `edit` only
(src/kimi_cli/tools/file/replace.py @ 4a550ef) and carries no
`old_string`/`new_string` at all, so either key appearing in a Kimi payload is
always model-supplied — exactly like `file_path`.
Verified end-to-end against the reviewer's payload: a cross-root write
carrying `new_string: ""` alongside an injected `edit[].new` left
gsd-prompt-guard reading '' and returning at its `if (!content)` guard, so the
injection advisory never fired and the reconstructed content was never
scanned. `new_string: null` behaved identically. Negative-controlled: both
produce empty output against pre-fix source and fire the advisory after.
Chose unconditional reconstruction over the offered `typeof` alternative
deliberately. A type test closes `""`/`null` but leaves the interesting case
open — a benign NON-EMPTY decoy (`new_string: "chore: tidy"`) shadows just as
effectively and passes any type test. The new suite includes that case
specifically; it is what discriminates between the two candidate fixes.
Also pins the kimi-cli SHA in the authoritative-path comment, as requested —
it cited file names with no version while the issue pins 4a550ef.
Landed byte-identically across all five inlined copies; the parity test's
byte-identity assertion holds.
Refs #2547
* fix(#2547): close the non-string file_path crash-to-allow at every read site
Review Major 3: the crash-to-allow was closed only as a side effect of `path`
masking the bad value, while the changeset read as though it were closed
outright. Confirmed both of the review's reachability claims: `[]`/`{}`/`42`
are truthy, survive the `!rawFilePath` early-out, and throw inside
path.isAbsolute() into the outer `catch { process.exit(0) }`; and normalization
returns early for native Claude Code payloads (KIMI_TOOL_NAMES has no 'Edit'
entry), so `{"tool_name":"Edit","tool_input":{"file_path":[]}}` reached it
untouched — this guard's original #260 surface.
Reproduced on a real fixture: string cross-root path exits 2, the identical
payload with `[]` or `{}` exits 0.
Swept the class rather than the instance. Five more untyped read sites across
four other hooks, each one line from a type-strict or method-dependent call.
Census of what each can actually do:
gsd-worktree-path-guard.js:173 BLOCKS -> live bypass (the review's finding)
gsd-prompt-guard.js:128 scanner -> silenced the injection scan, the
same outcome as Major 2 by another
route; verified empirically
gsd-workflow-guard.js:206 advisory only (its exit-2 is the Bash
force-add path, which reads
`command`, not `file_path`)
gsd-read-guard.js:141 advisory only
gsd-read-injection-scanner.js:213 advisory only
gsd-windsurf-pre-write.js:75 ALREADY TYPED — the shape adopted here
All six now read typed. The workflow-guard site keeps its truthiness fallback
(`(typeof x === 'string' && x) || ...`) because a bare type test would let an
empty `file_path` shortcut the `path` fallback.
Also declares one swept hit NOT fixed: `gsd-workflow-guard.js:175` reads
`command` untyped on a genuinely blocking path. Same shape, but not
exploitable — unlike file_path/path there is no second field carrying the
executable value, so a non-string command cannot smuggle a real `git add -f`
past the block. Left alone rather than widen this PR into the Bash path.
The regression gate is a SOURCE-level invariant, not a behavioural one, and
that is deliberate: the fixed read and the crashing read are black-box
identical — both end at exit 0, one via the catch and one via the early-out.
A test asserting exit 0 on a non-string payload passes against the unfixed
code, which is the same false-green the review flagged in the existing
`['non-string file_path (array)', []]` cases. Repeating it one level up would
be no better. tests/kimi-guard-typed-payload-reads.test.cjs fails if any hook
regresses to an untyped read (negative-controlled: it reports all five
pre-fix sites with correct file:line).
The behavioural cases requested — non-string file_path with NO `path` key —
are added to worktree-safety.test.cjs and labelled honestly as documenting the
explicit fail-open rather than detecting a revert.
Also states the relative-path premise (review Minor 5) at the early-out that
depends on it: "always safe" holds only while every runtime reaching there
resolves relative paths against the tool CWD. Claude Code satisfies it by
requiring absolute paths; kimi-cli's resolution behaviour is NOT verified here
and is recorded as an unverified premise rather than an asserted bypass.
Refs #2547
* docs(#2547): correct the changeset's closed-claim and fold the misattributed note
Review Major 3 also flagged the fragment: it said the non-string vector "threw
inside that guard's path.isAbsolute() ... `path` now wins outright", which
reads as closed when it was closed only conditionally. Rewritten to state what
is now true — closed unconditionally at all six read sites — and extended with
the Major 2 finding.
Review Minor 6 (the #2547 scope note living in a `pr: 2518` fragment) turns out
to understate the problem. Rendering the changelog and re-parsing it shows the
note is not merely misattributed — it is DROPPED. serializeChangelog emits each
fragment as a single `- ` bullet, and parseChangelog terminates a bullet at the
first non-continuation line, so everything after a blank line is lost on
re-parse. Audited all 44 fragments: exactly one was lossy —
2304-kimi-guard-tool-name.md, losing 656 of 1730 characters, i.e. precisely
that second paragraph. Folding it into this PR's fragment fixes the
attribution and the silent loss together; all 44 now round-trip losslessly.
That same mechanism is why the remaining nit — reformat this fragment's
~2,000-character paragraph for readability — is NOT applied. A paragraph break
or a bullet list would silently truncate the entry at the first blank line
(verified for both). The single-paragraph form is load-bearing under the
current serializer, not an authoring preference. Worth its own issue; noted in
the PR thread rather than worked around here.
Refs #2547
* chore(#2547): regenerate golden install parity after rebase onto next
Rebased onto `next` @ 9138271b (the PR had gone BEHIND by 20 commits; the
review's closing nit asked for it). The replay was CLEAN — no conflicts — and
that is exactly why this commit exists.
These fixtures are one key per installed file, so when the PR pins five hook
entries and the base rewrites others', the two edits land on different lines of
the same JSON. Git merges them silently and correctly AS TEXT while attesting
nothing about whether the merged hashes are still valid. Verified rather than
assumed: per-key equivalence against the old base showed the base had moved 22
of the 27 keys this PR pins in every runtime fixture, and
tests/golden-install-parity.test.cjs failed on 10 runtimes immediately after the
clean rebase. A push without this regen would have gone out red.
Regenerated with `npm run build && npm run gen:golden` under a throwaway
HOME/CLAUDE_CONFIG_DIR (the generators invoke the installer); live-profile
canary clean before and after.
Contamination check: every key differing from the base's committed fixture
resolves to a file this PR actually touches — the five guard hooks, under both
the `hooks/` and `.kimi/hooks/` install layouts, and nothing else. Derived from
the PR's changed-file set rather than a feature-name filter, which is what
would have mislabelled the registration surfaces.
Size baselines re-checked and NOT regenerated: this PR moves no workflow or
agent, and the base's own baselines are current (agent-size-budget,
workflow-size-budget, workflow-size, update-size-baseline all green).
Refs #2547
* chore(#2547): allowlist the field-shadowing security test in the injection scan
The new regression suite tripped the repo's own prompt-injection scan — a test
for the injection scanner setting off the injection scanner.
The fixture has to be a real injection phrase for the test to assert anything:
it is precisely the content gsd-prompt-guard must still scan once a
model-supplied `new_string` can no longer shadow the reconstructed
`edit[].new`. Weakening it to a benign string would make the suite vacuous.
Allowlisted rather than obfuscated, because that is this repo's established
convention for the class — tests/read-injection-scanner.security.test.cjs,
tests/security-prompt-injection.security.test.cjs,
tests/prompt-injection-scan.security.test.cjs and four others carry real
payloads as test DATA and are listed for exactly this reason. Splitting the
literal to dodge the grep would work but would make this one file inconsistent
with its five peers and leave the next reader wondering why.
Verified with the CI invocation itself (`scripts/prompt-injection-scan.sh
--diff upstream/next`): 26 files scanned, 0 findings. The .cjs codebase scan
does not cover tests/ and is unaffected (73 tests green).
Refs #2547
---------
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
310 lines
16 KiB
JavaScript
310 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
// gsd-hook-version: {{GSD_VERSION}}
|
|
// GSD Worktree Path Guard — PreToolUse hook
|
|
// Blocks Edit/Write/MultiEdit tool calls that target absolute paths outside the worktree root.
|
|
//
|
|
// Problem: gsd-executor agents spawned with isolation="worktree" sometimes issue
|
|
// Edit/Write calls with absolute paths rooted at the MAIN repository instead of
|
|
// the worktree (issue #260). The prose guard in agents/gsd-executor.md step 0b
|
|
// is never enforced because the model under load skips it.
|
|
//
|
|
// This hook enforces the constraint at the tooling layer, making it HARD-BLOCKING.
|
|
//
|
|
// Triggers on: Edit, Write, and MultiEdit tool calls
|
|
// Action: BLOCK (exit 2) if file_path is absolute and outside the worktree root
|
|
// No-op: relative paths, non-worktree CWDs, hook errors (silent fail)
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, windowsHide: true };
|
|
|
|
function git(args, cwd) {
|
|
return spawnSync('git', args, { ...SPAWNOPT, cwd });
|
|
}
|
|
|
|
// Walk up from `start` to find the nearest existing directory.
|
|
// Returns null if we reach the filesystem root without finding one.
|
|
function nearestExistingDir(start) {
|
|
let dir = start;
|
|
let prev;
|
|
do {
|
|
prev = dir;
|
|
try { fs.accessSync(dir, fs.constants.F_OK); return dir; } catch { /* keep walking */ }
|
|
dir = path.dirname(dir);
|
|
} while (dir !== prev);
|
|
return null;
|
|
}
|
|
|
|
// #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the payload
|
|
// (Write → WriteFile, Edit/MultiEdit → StrReplaceFile) while the [[hooks]]
|
|
// matcher is registered pre-translated (runtime-hooks-surface.cts
|
|
// buildKimiHooksTomlBlock) — so without normalizing the payload too, the
|
|
// matcher fires but the tool_name check below exits 0 and the guard is dormant
|
|
// on Kimi. The tool_input field names differ as well (kimi-cli
|
|
// src/kimi_cli/tools/file/{write,replace}.py): WriteFile takes `path`/`content`,
|
|
// StrReplaceFile takes `path` + `edit: Edit | list[Edit]` with `old`/`new` —
|
|
// kimi-cli's hooks/events.py forwards tool_input verbatim, so both layers need
|
|
// mapping. Accepts bare and module-qualified ('kimi_cli.tools.file:WriteFile')
|
|
// names; unknown names fall through untouched. Inlined per guard (not
|
|
// hooks/lib/): hook scripts are staged as standalone files, and a sibling
|
|
// require is a staging dependency that can fail silently.
|
|
// A Map, not an object literal: bare bracket lookup resolves prototype keys
|
|
// ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
|
|
// !mapped fall-through never fires for them; Map.get returns undefined (same
|
|
// shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
|
|
const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
|
|
function normalizeKimiPayload(data) {
|
|
// #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
|
|
// payloads reached the `data.tool_name` read below and threw — falsifying
|
|
// this function's own "total over the inputs JSON can express" claim, which
|
|
// property (e) now tests directly. Harmless in practice (a null payload has
|
|
// nothing to guard, and the throw landed in the same fail-open catch as the
|
|
// exit-0 it now takes deliberately) but the claim should be true as stated.
|
|
if (data === null || typeof data !== 'object') return data;
|
|
const raw = data.tool_name;
|
|
if (typeof raw !== 'string') return data;
|
|
const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
|
|
if (!mapped) return data;
|
|
data.tool_name = mapped;
|
|
if (data.tool_response === undefined && data.tool_output !== undefined) {
|
|
data.tool_response = data.tool_output;
|
|
}
|
|
const input = data.tool_input;
|
|
if (input && typeof input === 'object') {
|
|
// #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
|
|
// not merely fill in when `file_path` happens to be absent. kimi-cli's file
|
|
// tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
|
|
// replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
|
|
// model's raw json-parsed
|
|
// arguments to PreToolUse verbatim, doing typed validation only later inside
|
|
// tool.call() — after the hook has already decided. So a `file_path` in a
|
|
// Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
|
|
// condition it SHADOWED the field kimi-cli actually executes on. A payload
|
|
// pairing a cross-root `path` with a spurious `file_path: ""` left every
|
|
// guard reading an empty string and exiting 0, while the identical write
|
|
// without the extra key blocked — a bypass needing no crash at all. The same
|
|
// shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
|
|
// inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
|
|
// `catch { process.exit(0) }`: the same crash-to-allow this fix closes
|
|
// elsewhere, reached through the guard's own read rather than through
|
|
// normalization. Overwriting can only ever narrow what a guard inspects to
|
|
// the path that will actually be written, so it cannot under-block.
|
|
if (typeof input.path === 'string') {
|
|
input.file_path = input.path;
|
|
}
|
|
const edits = Array.isArray(input.edit) ? input.edit
|
|
: (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
|
|
if (edits.length) {
|
|
// #2547: `e?.old`, not `e.old` — `??` guards the value, not the
|
|
// dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
|
|
// here. normalizeKimiPayload runs before any tool dispatch, so that throw
|
|
// reached each guard's outer `catch { process.exit(0) }` and silently
|
|
// downgraded a should-BLOCK call into an allow. (A string/number entry
|
|
// never threw — `('x').old` is a legal read yielding undefined.)
|
|
//
|
|
// The String() coercion is guarded for the same reason: `{"toString":
|
|
// null}` is valid JSON that throws "Cannot convert object to primitive
|
|
// value", which is the identical crash-to-allow with a different
|
|
// trigger. Degrading only the non-coercible entry to '' keeps
|
|
// stringification intact for every value that CAN coerce (numbers,
|
|
// arrays, plain objects), so nothing downstream — including
|
|
// gsd-prompt-guard's scan of new_string — loses content it saw before.
|
|
const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
|
|
// #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
|
|
// `path` decision above rather than merely filling in when the field
|
|
// happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
|
|
// `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
|
|
// no `old_string`/`new_string` at all, so either field appearing in a
|
|
// Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
|
|
// the old `=== undefined` condition a model-supplied `new_string: ""`
|
|
// SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
|
|
// reading '' and exiting at its `if (!content)` before it ever saw the
|
|
// real `edit[].new` — a one-key bypass of the very scan this fix's
|
|
// guarded coercion exists to keep fed. A `typeof` test would NOT close
|
|
// it: a benign non-empty string shadows just as effectively as ''.
|
|
input.old_string = edits.map((e) => editText(e?.old)).join('\n');
|
|
input.new_string = edits.map((e) => editText(e?.new)).join('\n');
|
|
}
|
|
}
|
|
return data;
|
|
}
|
|
|
|
let input = '';
|
|
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', chunk => input += chunk);
|
|
process.stdin.on('end', () => {
|
|
clearTimeout(stdinTimeout);
|
|
try {
|
|
const data = normalizeKimiPayload(JSON.parse(input));
|
|
const toolName = data.tool_name;
|
|
|
|
// Only guard Edit, Write, and MultiEdit tool calls
|
|
if (toolName !== 'Edit' && toolName !== 'Write' && toolName !== 'MultiEdit') {
|
|
process.exit(0);
|
|
}
|
|
|
|
const cwd = data.cwd || process.cwd();
|
|
|
|
// Detect whether CWD is inside a linked git worktree by inspecting
|
|
// the git-dir path. In a linked worktree, git rev-parse --git-dir
|
|
// returns a path containing .git/worktrees/ as a component.
|
|
// In the main repo or a submodule it returns .git (or a path without /worktrees/).
|
|
// This approach works even when cwd is a subdirectory of the worktree.
|
|
const gitDirResult = git(['rev-parse', '--git-dir'], cwd);
|
|
if (gitDirResult.status !== 0 || !gitDirResult.stdout) {
|
|
process.exit(0); // not a git repo — pass through
|
|
}
|
|
|
|
const gitDir = gitDirResult.stdout.trim();
|
|
// A linked worktree's --git-dir contains .git/worktrees/ as a path component
|
|
const isLinkedWorktree = /[/\\]\.git[/\\]worktrees[/\\]/.test(gitDir);
|
|
if (!isLinkedWorktree) {
|
|
process.exit(0); // main repo, submodule, or separate-git-dir — no-op
|
|
}
|
|
|
|
// #1342: Only enforce inside a GSD-managed isolated executor worktree. Those
|
|
// are always on an `agent-*` or legacy `worktree-agent-*` branch (the positive
|
|
// allow-list enforced by worktree-branch-check.md, #2924, #1995). A manually-
|
|
// created linked worktree (plain non-GSD work, e.g. Claude Code plan-mode) is
|
|
// on the user's own branch, so the guard must be a no-op there. Detached HEAD
|
|
// / error → not GSD-managed → no-op.
|
|
const branchResult = git(['symbolic-ref', '--short', 'HEAD'], cwd);
|
|
const branch = branchResult.status === 0 && branchResult.stdout ? branchResult.stdout.trim() : '';
|
|
if (!/^(worktree-)?agent-[A-Za-z0-9._/-]+$/.test(branch)) {
|
|
process.exit(0); // not a GSD-managed executor worktree — no-op
|
|
}
|
|
|
|
// Get the raw --show-toplevel output for the worktree (cwd).
|
|
// We keep it raw (not path.resolve'd) to compare directly with the
|
|
// file's toplevel — same git binary, same format, no normalization needed.
|
|
const wtTopResult = git(['rev-parse', '--show-toplevel'], cwd);
|
|
if (wtTopResult.status !== 0 || !wtTopResult.stdout) {
|
|
process.exit(0); // can't determine root — fail open
|
|
}
|
|
const wtTopRaw = wtTopResult.stdout.trim();
|
|
|
|
// #2595 (review Major 3): read the field TYPED. `?.file_path || ''` let a
|
|
// non-string through — `[]` and `{}` are truthy, so they survived the
|
|
// `!rawFilePath` check and threw inside path.isAbsolute() below, landing in
|
|
// this script's outer `catch { process.exit(0) }`. That is the same
|
|
// crash-to-allow #2547 closes elsewhere, reached through the guard's own
|
|
// read rather than through normalization, and it is NOT closed by making
|
|
// `path` authoritative: normalization returns early for native Claude Code
|
|
// payloads (KIMI_TOOL_NAMES has no 'Edit' entry), so `{"tool_name":"Edit",
|
|
// "tool_input":{"file_path":[]}}` reached it untouched — this guard's
|
|
// original #260 surface. Same shape as hooks/gsd-windsurf-pre-write.js:75.
|
|
const rawFilePath = typeof data.tool_input?.file_path === 'string'
|
|
? data.tool_input.file_path
|
|
: '';
|
|
if (!rawFilePath) {
|
|
process.exit(0);
|
|
}
|
|
|
|
// Relative paths resolve against the tool's CWD, which is inside the worktree
|
|
// — so under the runtime this guard was written for they cannot leave it.
|
|
//
|
|
// #2595 (review Minor 5) — state the premise rather than leave it implicit,
|
|
// because THIS PR is what widened the guard's reach to Kimi. "Always safe"
|
|
// holds only while every runtime reaching here either rejects relative paths
|
|
// or resolves them against the worktree CWD. Claude Code's Edit/Write require
|
|
// an absolute file_path, so the original #260 surface satisfies it by
|
|
// construction. kimi-cli's StrReplaceFile takes `path` with no documented
|
|
// absoluteness guarantee, and its resolution behaviour is NOT verified here
|
|
// (no source available to this repo at 4a550ef beyond the schema). If it
|
|
// resolves relative paths against anything other than the tool CWD, a
|
|
// `../`-laden path exits 0 at this line and escapes the worktree. Stating a
|
|
// mechanism and an unverified premise — not asserting a live bypass.
|
|
if (!path.isAbsolute(rawFilePath)) {
|
|
process.exit(0);
|
|
}
|
|
|
|
// Normalise .. traversal so /worktree/src/../../../main/file
|
|
// resolves to its true location before we check containment.
|
|
const filePath = path.resolve(rawFilePath);
|
|
|
|
// Find the nearest existing ancestor of filePath so we can ask git
|
|
// for its toplevel. The file itself may not exist yet (Write creates
|
|
// new files), but at least one ancestor directory must exist.
|
|
// We check the file itself first in case it already exists.
|
|
const checkDir = nearestExistingDir(
|
|
(() => {
|
|
try {
|
|
return fs.statSync(filePath).isDirectory() ? filePath : path.dirname(filePath);
|
|
} catch {
|
|
return path.dirname(filePath);
|
|
}
|
|
})()
|
|
);
|
|
|
|
if (!checkDir) {
|
|
// Walked to root without finding any directory — path is synthetic.
|
|
// A path with no existing ancestor is not the #260 main-repo vector;
|
|
// #260 is caught by the different-git-root branch below. Fail open. (#1342)
|
|
process.exit(0);
|
|
}
|
|
|
|
// Ask git for the toplevel of the file's location.
|
|
// Comparing two raw git --show-toplevel outputs avoids every
|
|
// platform-specific path normalisation pitfall (Windows 8.3 short names,
|
|
// case differences between realpathSync and path.resolve, forward- vs
|
|
// back-slash inconsistencies) — both values come from the same git binary
|
|
// in the same format by definition.
|
|
const fileTopResult = git(['rev-parse', '--show-toplevel'], checkDir);
|
|
|
|
if (fileTopResult.status !== 0 || !fileTopResult.stdout) {
|
|
// The target's location is not a git work tree. Two sub-cases:
|
|
// - Inside a .git directory (e.g. /main-repo/.git/config or .git/hooks/*)
|
|
// → an absolute write into a repository's internals; still a #260-class
|
|
// escape (and dangerous) → BLOCK.
|
|
// - Truly outside all git repositories (e.g. ~/.claude/plans/) → not the
|
|
// main-repo vector → fail open. (#1342)
|
|
const insideGitDir = git(['rev-parse', '--is-inside-git-dir'], checkDir);
|
|
if (insideGitDir.status === 0 && insideGitDir.stdout && insideGitDir.stdout.trim() === 'true') {
|
|
const output = {
|
|
decision: 'block',
|
|
reason:
|
|
`Worktree path guard: '${filePath}' is inside a git internal (.git) directory, ` +
|
|
`not the active worktree at '${wtTopRaw}'. Writing to repository internals via an ` +
|
|
`absolute path is not permitted from an isolated executor worktree. Use a relative path.`,
|
|
};
|
|
process.stdout.write(JSON.stringify(output));
|
|
// Kimi feeds stderr (not stdout) back to the model on exit 2.
|
|
process.stderr.write(output.reason);
|
|
process.exit(2);
|
|
}
|
|
// Outside all git repositories — fail open (#1342).
|
|
process.exit(0);
|
|
}
|
|
|
|
const fileTopRaw = fileTopResult.stdout.trim();
|
|
|
|
// Same git toplevel → file is inside the worktree → allow
|
|
if (fileTopRaw === wtTopRaw) {
|
|
process.exit(0);
|
|
}
|
|
|
|
// BLOCK: file resolves to a different git root than the active worktree
|
|
const output = {
|
|
decision: 'block',
|
|
reason:
|
|
`Worktree path guard: '${filePath}' resolves to git root '${fileTopRaw}' which ` +
|
|
`differs from the active worktree root '${wtTopRaw}'. This likely means an ` +
|
|
`absolute path was derived from the orchestrator's main repository instead of ` +
|
|
`the active worktree. To fix: use a relative path, or re-derive the base ` +
|
|
`directory with \`git rev-parse --show-toplevel\` from within the worktree ` +
|
|
`(hook cwd: '${cwd}').`,
|
|
};
|
|
|
|
process.stdout.write(JSON.stringify(output));
|
|
// Kimi feeds stderr (not stdout) back to the model on exit 2.
|
|
process.stderr.write(output.reason);
|
|
process.exit(2);
|
|
} catch {
|
|
// Silent fail — never block valid tool calls due to hook errors
|
|
process.exit(0);
|
|
}
|
|
});
|