--- type: Security pr: 2595 --- **Malformed and shadowing Kimi payloads no longer disarm the guards that block** — `normalizeKimiPayload` (inlined in all five PreToolUse/PostToolUse guard hooks) rebuilt `old_string`/`new_string` with `String(e.old ?? '')`. Two inputs crashed it, and because normalization runs before any tool dispatch, both crashes landed in each guard's outer `catch { process.exit(0) }` — which emits the same exit code as "nothing to report", turning a should-**block** call into a silent **allow**. First, `??` guards the value and not the dereference, so a nullish entry (`edit: [null]`) threw on the property read. Second, coercion itself can throw: `{"toString": null}` is valid JSON that raises `Cannot convert object to primitive value`, so even a well-formed edit object could crash normalization. Two hard blocks were bypassable through either route: `gsd-worktree-path-guard`'s cross-git-root write block (the same write is correctly blocked with a well-formed edit list), and `gsd-workflow-guard`'s force-add block on `agent-*` branches (via a `Shell` payload carrying a spurious `edit` field the Bash path never even reads). Fixed with `e?.old` / `e?.new` plus a guarded coercion, landed identically across all five copies; the coercion is wrapped rather than type-tested so that stringification is unchanged for every value that can coerce. **Three model-supplied fields are now authoritative rather than merely defaulted.** Normalization used to fill `file_path`, `old_string` and `new_string` only when the key was `=== undefined`, so any value the model chose to include won — while kimi-cli executes on `path` and `edit`. Its `StrReplaceFile` schema is `path` + `edit` only (`src/kimi_cli/tools/file/replace.py` @ `4a550ef`) and carries none of those three keys, so each one appearing in a Kimi payload is always model-supplied. A cross-root `path` paired 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; likewise a `new_string: ""` — or any benign non-empty decoy, which a type test would not have caught — left `gsd-prompt-guard`'s injection scan reading empty content and returning at its `if (!content)` guard before it ever saw the real `edit[].new`. All three are now reconstructed unconditionally, which can only ever narrow what a guard inspects to what will actually be written. Reachability is not speculative: kimi-cli's `soul/toolset.py` json-parses the model's raw tool arguments and passes the dict verbatim as `tool_input` to `PreToolUse`, doing typed validation only later inside `tool.call()` — so the model controls extra keys at the moment the hook decides. **Separately, the guards now read payload path fields typed.** A non-string `file_path` (`[]`, `{}`) is truthy, so it survived each guard's `if (!filePath)` early-out and then threw inside `path.isAbsolute()` / `.includes()` / `.replace()`, reaching the same fail-open catch — crash-to-allow through the guard's own read rather than through normalization, and live on **native Claude Code payloads** too, since normalization returns early for non-Kimi tool names and so never masked the bad value there. Previously this was closed only as a side effect of a valid string `path` overwriting `file_path`; it is now closed unconditionally at all six read sites (the five normalized guards plus `gsd-windsurf-pre-write`, which already read typed), and a source-level invariant (`tests/kimi-guard-typed-payload-reads.test.cjs`) fails if any hook regresses to an untyped read. The native Claude Code contract (`file_path` governs) is unchanged. **Scope on Kimi:** normalization makes each guard's *checks* run; it does not make every guard *enforceable*. What can actually block on Kimi is what runs at PreToolUse — the worktree cross-root write block and the workflow force-add block. `gsd-read-injection-scanner` is a PostToolUse hook, and kimi-cli's dispatch never inspects PostToolUse hook results (`soul/toolset.py` fires them as a detached task and returns the tool result without awaiting it), so no output shape the scanner emits can block or flag a Kimi tool call; its prompt-injection block is not enforceable on Kimi under Kimi's current hook architecture. Regression coverage is negative-controlled against the pre-fix guards, and a property test (`tests/kimi-normalize-payload.property.test.cjs`) backs the totality claim generatively. `next`-only — released versions carry no Kimi normalization at all. (#2547)