refactor: drop 12 runtimes, keep Claude, Codex, OpenCode, Cursor, ZCode, Antigravity

Removes kilo, kimi, kimi-code, copilot, windsurf, augment, trae, qwen, hermes,
cline, codebuddy and pi end to end: capability descriptors, installer branches
and converters (bin/install.js 14.9k -> 11.2k lines), TypeScript converters,
hook surfaces and runtime homes, review lanes qwen/kimi-code, the two pi
migrations, Kimi payload normalization in the hook guards, dead hostBehaviors
vocabulary, launcher home probes, fixtures, runtime-specific tests and the
prose that presented them as supported.

Installer output for the six kept runtimes is byte-identical to before the
prune. The Kimi tool-vocabulary tests in workflow-guard, read-guard and
read-injection-scanner are left in place pending a decision.
This commit is contained in:
Jakub Zych
2026-10-06 20:02:40 +02:00
parent 12ee75a509
commit 6cfa0c55d2
509 changed files with 1869 additions and 47737 deletions

View File

@@ -409,7 +409,7 @@ function runMain(main) {
* like `payload`. This exists because `hooks/msd-write-guard.js`'s
* emitBlock does NOT write the same bytes to both streams today — it
* writes the full JSON `output` to stdout but only the plain-text
* `output.reason` STRING to stderr, because Kimi's native hook bus reads
* `output.reason` STRING to stderr, because a native hook bus may read
* stderr verbatim back to the model on exit 2. Migrating that call site
* onto terminateNow requires a way to say "fd 2 gets this different,
* plain-text value" — `stderrPayload` is that seam. Ignored entirely for

View File

@@ -24,8 +24,7 @@
* §4, epic #3212 Phase 3, #3414) — the built `msd-core/bin/lib/token-scanner.cjs`
* artifact, not a sibling hooks/-tree file, because hook scripts are staged as
* standalone files at install time and a sibling require is a staging
* dependency that can fail silently (see msd-workflow-guard.js's own
* KIMI_TOOL_NAMES comment for the precedent this follows). Re-exported here
* dependency that can fail silently. Re-exported here
* unchanged — every existing caller's behavior is identical (parity-asserted
* in tests/token-scanner.test.cjs row 5).
*/

View File

@@ -3,8 +3,8 @@
// distinguishing a GENUINE negative answer (git ran and said "no") from
// "could not determine" (timeout / spawn failure / signal kill) — #3911.
//
// Proven defect: hooks/msd-worktree-path-guard.js, hooks/msd-workflow-guard.js,
// and hooks/msd-windsurf-pre-write.js each treat spawnSync(git, ..., {timeout})
// Proven defect: hooks/msd-worktree-path-guard.js and hooks/msd-workflow-guard.js
// each treat spawnSync(git, ..., {timeout})
// returning a non-zero/empty result as "not applicable" and allow silently.
// A macOS CI run showed three deny cases land at 2084ms/2112ms/2177ms — just
// past the 2000ms budget — each returning exit 0 with EMPTY stdout AND EMPTY

View File

@@ -12,7 +12,7 @@
*
* Why a shared lib require is safe here (the old inlining rationale, retired):
* the installer stages hooks/lib/ from the MSD_HOOK_LIB_FILES allowlist for the
* shared-bundle surfaces (Claude-family settings.json runtimes and Kimi), the
* shared-bundle surfaces (Claude-family settings.json runtimes), the
* Cursor stager auto-discovers require('./lib/...') in staged scripts and fails
* the install loudly when a helper is missing (#2587), and the plugin path
* ships this directory wholesale.

View File

@@ -41,8 +41,6 @@ const MANAGED_HOOKS = [
'msd-statusline.js',
'msd-update-banner.js',
'msd-validate-commit.sh',
'msd-windsurf-pre-command.js',
'msd-windsurf-pre-write.js',
'msd-workflow-guard.js',
'msd-worktree-path-guard.js',
'msd-write-guard.js',

View File

@@ -575,7 +575,7 @@ function main() {
reason_code: decision.reasonCode,
sentinel_discarded: decision.sentinelDiscarded ?? null,
};
// Kimi feeds stderr (not stdout) back to the model on exit 2.
// A native hook bus may feed stderr (not stdout) back to the model on exit 2.
deny(out, decision.reason);
}
allow(undefined);

View File

@@ -101,16 +101,16 @@ try {
// Check for stale hooks — compare hook version headers against installed VERSION
// Since #3023 the bundle directory name is resolved from __dirname (this
// worker is staged INSIDE the bundle), not assumed to be configDir/hooks —
// the directory name is runtime-descriptor-driven (e.g. `msd-hooks/` for pi).
// the directory name is runtime-descriptor-driven (hostBehaviors.sharedHooksDirName).
// Only check hooks that MSD currently ships — orphaned files from removed features
// (e.g., msd-intel-*.js) must be ignored to avoid permanent stale warnings (#1750)
// MANAGED_HOOKS is imported from ./managed-hooks-registry.cjs above.
const staleHooks = [];
if (configDir) {
// #3023: the bundle's directory name is runtime-descriptor-driven (pi stages
// it as `msd-hooks/`), so deriving it as `<configDir>/hooks` silently scanned
// nothing there. This worker is staged INSIDE the bundle, so __dirname is the
// #3023: the bundle's directory name is runtime-descriptor-driven
// (hostBehaviors.sharedHooksDirName), so deriving it as `<configDir>/hooks`
// could silently scan nothing there. This worker is staged INSIDE the bundle, so __dirname is the
// bundle directory by construction — name-agnostic and one fewer assumption.
const hooksDir = __dirname;
try {

View File

@@ -31,7 +31,7 @@ try {
const homeDir = os.homedir();
const cwd = process.cwd();
// Detect runtime config directory (supports Claude, OpenCode, Kilo, Gemini)
// Detect runtime config directory (supports Claude, OpenCode, Gemini)
// Respects CLAUDE_CONFIG_DIR for custom config directory setups
function detectConfigDir(baseDir) {
// Check env override first (supports multi-account setups)
@@ -39,7 +39,7 @@ function detectConfigDir(baseDir) {
if (envDir && fs.existsSync(path.join(envDir, 'msd-core', 'VERSION'))) {
return envDir;
}
for (const dir of ['.claude', '.gemini', '.config/kilo', '.kilo', '.config/opencode', '.opencode']) {
for (const dir of ['.claude', '.gemini', '.config/opencode', '.opencode']) {
if (fs.existsSync(path.join(baseDir, dir, 'msd-core', 'VERSION'))) {
return path.join(baseDir, dir);
}

View File

@@ -462,7 +462,7 @@ const handleStdinEnd = () => {
// Runtime-agnostic path: this hook lives at <runtime-config>/hooks/
// and msd-tools.cjs lives at <runtime-config>/msd-core/bin/.
// Using __dirname makes this work on Claude Code, OpenCode, Gemini,
// Kilo, etc. without hardcoding ~/.claude/.
// etc. without hardcoding ~/.claude/.
const msdTools = path.join(__dirname, '..', 'msd-core', 'bin', 'msd-tools.cjs');
// Coerce usedPct to a safe number in case bridge file is malformed
const safeUsedPct = Number(usedPct) || 0;

View File

@@ -63,15 +63,6 @@ TOOL_NAME=$(printf '%s\n' "$TOOL_INFO" | sed -n '1p')
# matches the substring anywhere in the multi-line string.
COMMAND=$(printf '%s\n' "$TOOL_INFO" | sed -n '2,$p')
# #2304: Kimi CLI registers this hook with matcher 'Shell' and forwards its
# own tool vocabulary (tool_name 'Shell', possibly module-qualified as
# kimi_cli.tools.shell:Shell). kimi-cli's Shell.Params names its field
# `command` (src/kimi_cli/tools/shell/__init__.py), same as Claude's Bash,
# so only the tool name needs normalization — the shell counterpart of the
# KIMI_TOOL_NAMES map inlined in the JS guards.
TOOL_NAME="${TOOL_NAME##*:}"
if [ "$TOOL_NAME" = "Shell" ]; then TOOL_NAME="Bash"; fi
[ "$TOOL_NAME" = "Bash" ] || exit 0
# Gate 2 — HEAD-advancing git op (shell-direct or exact `msd-tools query commit`)

View File

@@ -19,19 +19,7 @@ fi
INPUT=$(cat)
# Extract file_path from JSON using Node (handles escaping correctly).
# #2304: Kimi CLI registers this hook with matcher 'WriteFile|StrReplaceFile'
# and its file tools name the field `path`, not `file_path` (kimi-cli
# src/kimi_cli/tools/file/write.py + replace.py) — fall back to tool_input.path
# when file_path is absent, mirroring normalizeKimiPayload in the JS guards.
# #2752: `path` is AUTHORITATIVE (kimi-cli executes on it; it sends `path` only,
# never `file_path`). `file_path` is model-controlled on Kimi, so consulting it
# first let a model-supplied decoy suppress/fabricate the reminder. `path` wins,
# `file_path` is the fallback (Claude Code emits `file_path` and no `path`, so the
# fallback must remain). The JS guards reach the same "path authoritative" outcome
# via an upstream normalizeKimiPayload step (copies path→file_path before any guard
# reads); this shell hook parses tool_input once, raw, so it applies the precedence
# directly at the read site.
FILE=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const i=JSON.parse(d).tool_input||{};process.stdout.write((typeof i.path==='string'&&i.path)||(typeof i.file_path==='string'&&i.file_path)||'')}catch{}})" 2>/dev/null)
FILE=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const i=JSON.parse(d).tool_input||{};process.stdout.write((typeof i.file_path==='string'&&i.file_path)||'')}catch{}})" 2>/dev/null)
# Emit a structured JSON envelope (#2974). additionalContext carries the
# user-visible reminder text; the typed `planning_modified` boolean and

View File

@@ -27,100 +27,6 @@ const ON_CRASH = HOOK_ON_CRASH.ALLOW;
// MSD_HOOK_LIB_FILES (bin/install.js).
const { INJECTION_PATTERNS, describePattern } = require('./lib/injection-patterns.js');
// #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 msd-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
// msd-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 msd-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(() => allow(undefined), 3000);
process.stdin.setEncoding('utf8');
@@ -128,7 +34,7 @@ process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = normalizeKimiPayload(JSON.parse(input));
const data = JSON.parse(input);
const toolName = data.tool_name;
// Only scan Write and Edit operations
@@ -152,8 +58,8 @@ process.stdin.on('end', () => {
// bare `||` chain handed a NON-STRING truthy `content` straight to
// pattern.test(), where ToString can throw ("Cannot convert object to a
// primitive value" for `{"toString": null}`) into the outer catch — the
// exact crash-to-allow class #2547/#2595 hardened inside
// normalizeKimiPayload, unreached on this read. Guarded selection: take
// exact crash-to-allow class #2547/#2595 hardened elsewhere, unreached on
// this read. Guarded selection: take
// the first field that is a string, or String-coerces without throwing,
// so a poisoned `content` no longer shadows a real `new_string`.
let content = '';

View File

@@ -28,100 +28,6 @@ const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
// own read-before-edit rejection it was trying to help avoid (#3911).
const ON_CRASH = HOOK_ON_CRASH.ALLOW;
// #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 msd-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
// msd-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 msd-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(() => allow(undefined), 3000);
process.stdin.setEncoding('utf8');
@@ -129,7 +35,7 @@ process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = normalizeKimiPayload(JSON.parse(input));
const data = JSON.parse(input);
const toolName = data.tool_name;
// Only intercept Write and Edit tool calls

View File

@@ -116,109 +116,6 @@ function isExcludedPath(filePath) {
);
}
// Kimi CLI delivers the tool vocabulary the matcher was registered with —
// the scanner's Kimi matcher is 'ReadFile' (runtime-hooks-surface.cts), so
// tool_name arrives as 'ReadFile' (possibly module-qualified) and tool_input
// carries `path` (kimi-cli src/kimi_cli/tools/file/read.py Params), not
// `file_path`. Without normalization the SCANNED_TOOLS check below never
// matches on Kimi and the scanner is silently dormant (#2304).
//
// SCOPE ON KIMI (#2547): normalization makes this scanner's CHECKS run on
// Kimi. It does NOT make its block effective there. This is a PostToolUse
// hook, and kimi-cli's dispatch never inspects PostToolUse hook results —
// src/kimi_cli/soul/toolset.py fires them via asyncio.create_task() and
// returns the ToolResult without awaiting, whereas PreToolUse results are
// awaited and honoured. So `security.injection_blocking` cannot take effect
// on Kimi regardless of the shape emitted below; reshaping the output would
// not change that. Blocking prompt injection on Kimi needs a PreToolUse
// mechanism, or an upstream kimi-cli change. Do not describe this hook as
// "engaged" or "blocking" on Kimi. This block is
// kept byte-identical with the copies in msd-prompt-guard.js,
// msd-read-guard.js, and msd-worktree-path-guard.js — a parity test binds
// them (tests/kimi-guard-normalization-parity.test.cjs). 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 msd-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
// msd-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 msd-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 inputBuf = '';
const stdinTimeout = setTimeout(() => allow(undefined), 5000);
process.stdin.setEncoding('utf8');
@@ -226,7 +123,7 @@ process.stdin.on('data', chunk => { inputBuf += chunk; });
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = normalizeKimiPayload(JSON.parse(inputBuf));
const data = JSON.parse(inputBuf);
const toolName = data.tool_name;
const SCANNED_TOOLS = new Set(['Read', 'WebFetch', 'WebSearch']);

View File

@@ -93,7 +93,7 @@
// as the pre-existing volume-mount gap (`-v .env:/s`); the flag's value
// itself is a name, never contents.)
//
// Triggers on: Read, Grep, Bash tool calls (Kimi: ReadFile, Grep, Shell)
// Triggers on: Read, Grep, Bash tool calls
// Action: BLOCK (decision: 'block', exit 2) — codes secret-read |
// glob-too-complex | command-too-large
// No-op: other tools, non-secret targets, hook errors (fail open — a parser
@@ -122,7 +122,7 @@ const MAX_GLOB_ALTERNATIVES = 64;
const NON_SECRET_ENV_SUFFIXES = new Set(['example', 'sample', 'template', 'dist']);
// Command-prefix wrappers to look through when locating the command word at
// the head of a segment (same set as hooks/msd-windsurf-pre-command.js).
// the head of a segment.
const CMD_PREFIXES = new Set(['sudo', 'env', 'command', 'nice', 'nohup', 'time', 'doas']);
// Commands whose ordinary operands are file NAMES, never file CONTENTS. A
@@ -978,101 +978,12 @@ function reasonFor(code, tool, target) {
}
// stdout gets the typed JSON block; stderr gets the plain reason string
// (Kimi's hook bus reads stderr verbatim back to the model — #3911).
// (a native hook bus may read stderr verbatim back to the model — #3911).
function emitBlock(code, tool, target) {
const reason = reasonFor(code, tool, target);
deny({ decision: 'block', code, tool, path: target, reason }, reason);
}
// Strips a `module:` prefix so Kimi's `kimi_cli.tools.file:Grep` (not in the
// KIMI_TOOL_NAMES map — Grep has the same name on both buses) matches.
function bareToolName(raw) {
return typeof raw === 'string' ? raw.slice(raw.lastIndexOf(':') + 1) : '';
}
// #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the
// payload (ReadFile / Shell) and `path` instead of `file_path`; the map and
// normalizer below are the byte-identical copy every guard carries (bound by
// tests/kimi-guard-normalization-parity.test.cjs — do not edit locally).
// Grep keeps its name on Kimi and is not in the map; bareToolName() above
// strips the module prefix for it.
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 msd-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
// msd-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 msd-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(() => allow(undefined), 3000);
process.stdin.setEncoding('utf8');
@@ -1080,7 +991,7 @@ process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = normalizeKimiPayload(JSON.parse(input));
const data = JSON.parse(input);
// A null/primitive payload has nothing to guard — exit deliberately
// rather than throwing into the fail-open catch below (#2595 class).
@@ -1088,7 +999,7 @@ process.stdin.on('end', () => {
allow(undefined);
}
const tool = bareToolName(data.tool_name);
const tool = typeof data.tool_name === 'string' ? data.tool_name : '';
if (tool !== 'Read' && tool !== 'Grep' && tool !== 'Bash') {
allow(undefined);
}

View File

@@ -1,280 +0,0 @@
#!/usr/bin/env node
// msd-hook-version: {{MSD_VERSION}}
// msd-windsurf-pre-command.js — Windsurf/Cascade pre_run_command hook (ADR-1239 / #2100)
//
// Cascade (Windsurf's agent) invokes this script before each shell-command
// tool call executes, via the workspace/global hooks.json hook bus.
//
// Input schema (Cascade pre_run_command envelope, JSON on stdin):
// { agent_action_name: 'pre_run_command', trajectory_id, execution_id,
// timestamp, model_name,
// tool_info: { command_line } }
//
// Decision protocol — DISTINCT from Cursor's stdout-JSON form:
// - exit 0 -> allow the command to run (no stdout contract)
// - exit 2 -> BLOCK the command; the printed stderr text is the reason
// shown to the agent/user
//
// Behaviour: blocks a small, CONSERVATIVE, well-scoped, BEST-EFFORT deny-list
// of obviously destructive commands. This is intentionally not exhaustive —
// a broad deny-list would false-positive on legitimate agent/tooling work,
// and Cascade honors exit 2 unconditionally, so a false positive blocks the
// user's real work. When in doubt, this script allows:
// - a fork-bomb pattern
// - `rm -rf` (or equivalent combined/long flags), including through common
// prefixed forms (`sudo rm -rf /`, `/bin/rm -rf /`, `env FOO=1 rm -rf /`),
// targeting the filesystem root, the user's home directory, or a Windows
// drive root/profile root
// - `git push` with a force flag (`-f`/`--force`/`--force-with-lease`) or a
// `+`-prefixed refspec, explicitly targeting a protected branch
// (main / master / next) as the push destination — not merely mentioning
// that name elsewhere in a longer branch name or a trailing comment
// Everything else — including force-pushes to feature branches and `rm -rf`
// against ordinary project subdirectories — is intentionally left alone.
// Fails OPEN on any error, timeout, or unrecognized shape — a hook bug must
// never wedge Cascade.
//
// Classification is TOKENIZE-based (split into shell segments, then
// whitespace-split tokens), not a single mega-regex over the raw string —
// this keeps every check linear in input length. `command_line` longer than
// MAX_COMMAND_LENGTH is allowed outright before any pattern matching runs:
// no realistic destructive command is anywhere near that long, so the cap
// both fails open on pathological input and bounds the worst-case cost of
// every classifier below (defense-in-depth against regex-based DoS).
//
// Cascade hooks docs (reference): https://docs.windsurf.com/llms-full.txt ,
// https://docs.devin.ai/desktop/cascade/hooks
'use strict';
const { allow, deny } = require('./lib/hook-exit.js');
// #3911 (ADR-3889 Phase 7): the exit(2) call site (block(), below) is
// migrated to hook-exit.js's deny(undefined, reason) now that
// hooks/lib/cli-exit.js's terminateNow emits fd 1 and fd 2 (stderrPayload)
// in INDEPENDENT try/catch blocks — `payload=undefined` cleanly skips the
// fd 1 write (preserving "nothing written to stdout") instead of throwing
// into a shared catch that used to also swallow the fd 2 write, which is
// what silently dropped the deny reason before this fix.
// No realistic destructive command comes anywhere close to this length.
const MAX_COMMAND_LENGTH = 4096;
// Classic bash fork bomb: `:(){ :|:& };:`
const FORK_BOMB_RE = /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
// Command-prefix wrappers to look through when locating the "real" command at
// the head of a segment: `sudo rm -rf /`, `/bin/rm -rf /` (basename strip),
// `env FOO=1 rm -rf /` (env's leading VAR=val args are skipped too).
const CMD_PREFIXES = new Set(['sudo', 'env', 'command', 'nice', 'nohup', 'time', 'doas']);
// Bare filesystem-root-class tokens for `rm`'s target. Ordinary paths like
// `/tmp/foo` or `/home/user/project` never match this set.
const ROOT_SENTINELS = new Set(['/', '/*', '~', '~/', '$HOME', '${HOME}']);
const PROTECTED_BRANCHES = new Set(['main', 'master', 'next']);
// ---------------------------------------------------------------------------
// Tokenizing helpers
// ---------------------------------------------------------------------------
// Split a command line into shell segments on `;`, `&&`, `||`, `|`, newline —
// each segment is classified independently.
function splitSegments(cmd) {
return cmd.split(/\|\||&&|[;\n|]/);
}
// A `#` starts a bash comment when it's the first character of a "word"
// (preceded by whitespace, or at the very start of the segment). Strip it
// before classifying, so a comment mentioning a protected branch name never
// counts as a real command argument.
function stripBashComment(segment) {
const m = segment.match(/(^|\s)#/);
if (!m) return segment;
const idx = m.index + m[1].length;
return segment.slice(0, idx).replace(/\s+$/, '');
}
function tokenize(segment) {
return segment.split(/\s+/).filter(Boolean);
}
// Strip any directory path from a token: `/bin/rm` -> `rm`.
function basename(tok) {
const parts = tok.split(/[\\/]/);
return parts[parts.length - 1] || tok;
}
// Find the index of the "real" command token in a token list, skipping past
// known command-prefix wrappers (and, for `env`, its leading VAR=val args).
function indexOfCommandAfterPrefixes(tokens) {
let i = 0;
while (i < tokens.length) {
const base = basename(tokens[i]).toLowerCase();
if (!CMD_PREFIXES.has(base)) return i;
const wasEnv = base === 'env';
i++;
if (wasEnv) {
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
}
}
return i;
}
// True if `tokens` contains a flag matching either the exact long form, or a
// combined/short `-xyz` cluster containing `shortChar` (e.g. `-rf`, `-fr`,
// `-r`). A single `[a-zA-Z]+` quantifier with no nested ambiguity — linear,
// no catastrophic backtracking regardless of token length.
function hasFlag(tokens, shortChar, longFlag) {
return tokens.some((t) => {
if (t === longFlag) return true;
if (t.length > 1 && t[0] === '-' && t[1] !== '-' && /^[a-zA-Z]+$/.test(t.slice(1))) {
return t.slice(1).toLowerCase().includes(shortChar);
}
return false;
});
}
function isRootSentinel(tok) {
if (ROOT_SENTINELS.has(tok)) return true;
// Bare Windows drive root: `C:\` or `C:/`.
if (/^[A-Za-z]:[\\/]$/.test(tok)) return true;
return false;
}
// ---------------------------------------------------------------------------
// Classifiers (each operates on one already comment-stripped segment)
// ---------------------------------------------------------------------------
// `rm` (any flag order/spelling, optionally through `sudo`/`env FOO=1`/an
// absolute path/etc.) with BOTH a recursive flag and a force flag, targeting
// a bare filesystem-root-class token.
function isDestructiveRmRf(segment) {
const tokens = tokenize(segment);
const cmdIdx = indexOfCommandAfterPrefixes(tokens);
if (cmdIdx >= tokens.length) return null;
if (basename(tokens[cmdIdx]) !== 'rm') return null;
const args = tokens.slice(cmdIdx + 1);
const hasRecursive = hasFlag(args, 'r', '--recursive');
const hasForce = hasFlag(args, 'f', '--force');
if (!hasRecursive || !hasForce) return null;
const rootTok = args.find(isRootSentinel);
if (rootTok) return `rm -rf targeting the filesystem root or home directory ('${rootTok}')`;
return null;
}
function isWindowsRootSentinel(tok) {
if (/^[A-Za-z]:\\?$/.test(tok)) return true;
if (/^\$env:userprofile\\?$/i.test(tok)) return true;
if (/^~\\?$/.test(tok)) return true;
return false;
}
function isWindowsDriveRoot(tok) {
return /^[A-Za-z]:\\?$/.test(tok);
}
// Windows equivalents: `Remove-Item -Recurse -Force <drive-root|profile-root>`
// and `rd /s /q <drive-root>` / `rmdir /s /q <drive-root>`.
function isDestructiveWindowsRmRf(segment) {
const tokens = tokenize(segment);
if (tokens.length === 0) return null;
const first = basename(tokens[0]).toLowerCase();
const rest = tokens.slice(1);
if (first === 'remove-item') {
const hasRecurse = rest.some((t) => t.toLowerCase() === '-recurse');
const hasForce = rest.some((t) => t.toLowerCase() === '-force');
if (hasRecurse && hasForce && rest.some(isWindowsRootSentinel)) {
return 'Remove-Item -Recurse -Force targeting a drive root or user-profile root';
}
return null;
}
if (first === 'rd' || first === 'rmdir') {
const hasS = rest.some((t) => t.toLowerCase() === '/s');
const hasQ = rest.some((t) => t.toLowerCase() === '/q');
if (hasS && hasQ) {
const rootTok = rest.find(isWindowsDriveRoot);
if (rootTok) return `rd /s /q targeting drive root '${rootTok}'`;
}
return null;
}
return null;
}
function isForceToken(tok) {
if (tok === '--force' || tok === '-f') return true;
if (/^--force-with-lease(=.*)?$/i.test(tok)) return true;
if (tok.startsWith('+')) return true;
return false;
}
// Resolve the branch a push-argument token targets, honoring `+<dst>` and
// `<src>:<dst>` refspec forms and an optional `refs/heads/` prefix. Returns
// the lower-cased protected branch name, or null. Whole-token comparison
// only — `feature/main-fix` never matches `main`.
function protectedTargetFromToken(tok) {
let t = tok;
if (t.startsWith('+')) t = t.slice(1);
const colonIdx = t.lastIndexOf(':');
const candidate = colonIdx !== -1 ? t.slice(colonIdx + 1) : t;
const stripped = candidate.replace(/^refs\/heads\//i, '');
const lower = stripped.toLowerCase();
return PROTECTED_BRANCHES.has(lower) ? lower : null;
}
// `git push` with a force flag/refspec AND an explicit protected-branch push
// target (main / master / next — see scripts/setup-branch-protection.sh).
function isProtectedBranchForcePush(segment) {
const tokens = tokenize(segment);
for (let i = 0; i < tokens.length - 1; i++) {
if (tokens[i].toLowerCase() === 'git' && tokens[i + 1].toLowerCase() === 'push') {
const rest = tokens.slice(i + 2);
if (!rest.some(isForceToken)) return null;
for (const tok of rest) {
const target = protectedTargetFromToken(tok);
if (target) return `git push --force targeting protected branch '${target}'`;
}
return null;
}
}
return null;
}
function destructiveReason(cmd) {
if (FORK_BOMB_RE.test(cmd)) return 'fork-bomb pattern';
for (const rawSegment of splitSegments(cmd)) {
const segment = stripBashComment(rawSegment).trim();
if (!segment) continue;
const reason = isDestructiveRmRf(segment)
|| isDestructiveWindowsRmRf(segment)
|| isProtectedBranchForcePush(segment);
if (reason) return reason;
}
return null;
}
function block(reason) {
deny(undefined, `MSD windsurf pre_run_command guard: ${reason}\n`);
}
let input = '';
const stdinTimeout = setTimeout(() => allow(undefined), 10000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = JSON.parse(input || '{}');
const toolInfo = (data && typeof data.tool_info === 'object' && data.tool_info) || {};
const commandLine = typeof toolInfo.command_line === 'string' ? toolInfo.command_line : '';
if (!commandLine) { allow(undefined); return; }
if (commandLine.length > MAX_COMMAND_LENGTH) { allow(undefined); return; }
const reason = destructiveReason(commandLine);
if (reason) { block(reason); return; }
allow(undefined);
} catch {
// Silent fail-open — never block a valid tool call due to a hook bug.
allow(undefined);
}
});

View File

@@ -1,141 +0,0 @@
#!/usr/bin/env node
// msd-hook-version: {{MSD_VERSION}}
// msd-windsurf-pre-write.js — Windsurf/Cascade pre_write_code hook (ADR-1239 / #2100)
//
// Cascade (Windsurf's agent) invokes this script before each file-write tool
// call executes, via the workspace/global hooks.json hook bus.
//
// Input schema (Cascade pre_write_code envelope, JSON on stdin):
// { agent_action_name: 'pre_write_code', trajectory_id, execution_id,
// timestamp, model_name,
// tool_info: { file_path, edits: [{ old_string, new_string }] } }
//
// Decision protocol — DISTINCT from Cursor's stdout-JSON form:
// - exit 0 -> allow the write to proceed (no stdout contract)
// - exit 2 -> BLOCK the write; the printed stderr text is the reason shown
// to the agent/user
//
// Behaviour: reimplements the core containment check from
// hooks/msd-worktree-path-guard.js — block a write whose file_path resolves
// (via `git rev-parse --show-toplevel`) to a DIFFERENT git root than the
// current working directory, or lands inside a `.git/` internals directory.
// Fails OPEN on any error, timeout, non-git cwd, or missing git binary — a
// hook bug must never wedge Cascade.
//
// Cascade hooks docs (reference): https://docs.windsurf.com/llms-full.txt ,
// https://docs.devin.ai/desktop/cascade/hooks
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { allow, deny } = require('./lib/hook-exit.js');
const { reportIfUndetermined } = require('./lib/git-probe.js');
// #3911 (ADR-3889 Phase 7): the exit(2) call site (block(), below) is
// migrated to hook-exit.js's deny(undefined, reason) — see
// msd-windsurf-pre-command.js's identical note for the fixed defect
// (terminateNow's fd 1/fd 2 writes now run in independent try/catch blocks).
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 (not merely an
// existing filesystem entry) — a linked git worktree's `.git` is a plain FILE
// (a `gitdir:` pointer), not a directory, so a plain existence check would
// hand spawnSync an invalid `cwd` and silently fail the git calls below.
// Returns null if we reach the filesystem root without finding one.
function nearestExistingDir(start) {
let dir = start;
let prev;
do {
prev = dir;
try { if (fs.statSync(dir).isDirectory()) return dir; } catch { /* keep walking */ }
dir = path.dirname(dir);
} while (dir !== prev);
return null;
}
function block(reason) {
deny(undefined, `MSD windsurf pre_write_code guard: ${reason}\n`);
}
let input = '';
const stdinTimeout = setTimeout(() => allow(undefined), 10000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = JSON.parse(input || '{}');
const toolInfo = (data && typeof data.tool_info === 'object' && data.tool_info) || {};
const rawFilePath = typeof toolInfo.file_path === 'string' ? toolInfo.file_path : '';
if (!rawFilePath) { allow(undefined); return; }
const cwd = process.cwd();
// Determine the active project's git root. No git root at all -> nothing
// to enforce a boundary against -> fail open.
const cwdTopResult = git(['rev-parse', '--show-toplevel'], cwd);
// #3911: a timeout/spawn-failure result is indistinguishable from a clean
// "no git root here" answer by status/stdout alone — reportIfUndetermined
// is a no-op on a genuine negative and only fires when the probe itself
// could not run. The allow() below is UNCHANGED either way.
reportIfUndetermined('msd-windsurf-pre-write', 'git rev-parse --show-toplevel (cwd)', cwdTopResult);
if (cwdTopResult.status !== 0 || !cwdTopResult.stdout) { allow(undefined); return; }
const cwdTopRaw = cwdTopResult.stdout.trim();
const filePath = path.isAbsolute(rawFilePath) ? path.resolve(rawFilePath) : path.resolve(cwd, rawFilePath);
// Find the nearest existing ancestor of filePath so we can ask git for its
// toplevel. The file itself may not exist yet (a write can create it).
const checkDir = nearestExistingDir(
(() => {
try {
return fs.statSync(filePath).isDirectory() ? filePath : path.dirname(filePath);
} catch {
return path.dirname(filePath);
}
})(),
);
if (!checkDir) { allow(undefined); return; } // synthetic path with no existing ancestor — fail open
const fileTopResult = git(['rev-parse', '--show-toplevel'], checkDir);
reportIfUndetermined('msd-windsurf-pre-write', 'git rev-parse --show-toplevel (file location)', fileTopResult);
if (fileTopResult.status !== 0 || !fileTopResult.stdout) {
// Not inside any git worktree. Distinguish "inside a .git/ internals
// directory" (dangerous — BLOCK) from "outside all git repos entirely"
// (not the escape vector this guard targets — fail open).
const insideGitDir = git(['rev-parse', '--is-inside-git-dir'], checkDir);
reportIfUndetermined('msd-windsurf-pre-write', 'git rev-parse --is-inside-git-dir', insideGitDir);
if (insideGitDir.status === 0 && insideGitDir.stdout && insideGitDir.stdout.trim() === 'true') {
block(
`'${filePath}' is inside a git internal (.git) directory, not the active project at ` +
`'${cwdTopRaw}'. Writing to repository internals via an absolute path is not permitted. ` +
`Use a relative path. (cwd: '${cwd}')`,
);
return;
}
allow(undefined);
return;
}
const fileTopRaw = fileTopResult.stdout.trim();
if (fileTopRaw === cwdTopRaw) { allow(undefined); return; }
// BLOCK: file resolves to a different git root than the active project.
block(
`'${filePath}' resolves to git root '${fileTopRaw}' which differs from the active project root ` +
`'${cwdTopRaw}'. This likely means an absolute path was derived from a different repository. ` +
`Use a relative path within the active project, or re-derive the base directory with ` +
`\`git rev-parse --show-toplevel\` from the active project. (cwd: '${cwd}')`,
);
} catch {
// Silent fail-open — never block a valid tool call due to a hook bug.
allow(undefined);
}
});

View File

@@ -119,7 +119,7 @@ function payloadCwd(data) {
// The force-add block payload, emitted by the happy-path detector and the
// fail-closed catch — one source so the two exits cannot drift. `origin`
// distinguishes them structurally ('force-add-detected' vs 'fail-closed') so a
// consumer — or the model reading Kimi's stderr feedback — is never told a
// consumer — or the model reading stderr feedback — is never told a
// force-add was detected when the call was in fact blocked unanalyzed.
function emitForceAddBlock(origin) {
const failClosed = origin === 'fail-closed';
@@ -132,7 +132,7 @@ function emitForceAddBlock(origin) {
origin: failClosed ? 'fail-closed' : 'force-add-detected',
reason,
};
// Kimi CLI's exit-2 protocol feeds stderr back to the model (#2304) — deny's
// A native exit-2 hook bus may feed stderr back to the model (#2304) — deny's
// stderrPayload keeps fd 2 to the plain reason string, matching the
// pre-migration two-write byte-for-byte.
deny(output, output.reason);
@@ -151,7 +151,7 @@ function emitForceAddBlock(origin) {
function failClosedBlockContext(rawInput) {
let data;
try {
data = normalizeKimiPayload(JSON.parse(rawInput));
data = JSON.parse(rawInput);
} catch {
return false;
}
@@ -184,100 +184,6 @@ function workflowGuardEnabled(cwd) {
}
}
// Kimi CLI delivers the tool vocabulary the matcher was registered with —
// this guard's Kimi matcher is 'Shell|WriteFile|StrReplaceFile'
// (runtime-hooks-surface.cts), so tool_name arrives in Kimi vocabulary
// (possibly module-qualified) and neither the Bash branch nor the
// Write/Edit/MultiEdit allowlist below ever matched on Kimi (#2304).
// kimi-cli's Shell.Params names its field `command`
// (src/kimi_cli/tools/shell/__init__.py), same as Claude's Bash, so the
// Shell leg needs only the name mapping. This block is kept byte-identical
// with the copies in msd-prompt-guard.js, msd-read-guard.js,
// msd-worktree-path-guard.js, and msd-read-injection-scanner.js — a parity
// test binds them (tests/kimi-guard-normalization-parity.test.cjs). 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 msd-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
// msd-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 msd-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(() => allow(undefined), 3000);
process.stdin.setEncoding('utf8');
@@ -285,7 +191,7 @@ process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = normalizeKimiPayload(JSON.parse(input));
const data = JSON.parse(input);
// #3504 test-only fault seam: throws right after parse so the fail-closed
// posture of the outer catch is exercisable — no JSON-expressible input
// throws in this handler today (#2547/#2595 hardened every read). Gated on

View File

@@ -47,100 +47,6 @@ function nearestExistingDir(start) {
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 msd-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
// msd-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 msd-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(() => allow(undefined), 3000);
process.stdin.setEncoding('utf8');
@@ -148,7 +54,7 @@ process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = normalizeKimiPayload(JSON.parse(input));
const data = JSON.parse(input);
const toolName = data.tool_name;
// Only guard Edit, Write, and MultiEdit tool calls
@@ -220,11 +126,8 @@ process.stdin.on('end', () => {
// `!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/msd-windsurf-pre-write.js:75.
// read: `{"tool_name":"Edit", "tool_input":{"file_path":[]}}` reached it
// untouched — this guard's original #260 surface.
const rawFilePath = typeof data.tool_input?.file_path === 'string'
? data.tool_input.file_path
: '';
@@ -235,17 +138,13 @@ process.stdin.on('end', () => {
// 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.
// #2595 (review Minor 5) — state the premise rather than leave it implicit.
// "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. A runtime whose edit tool resolves relative
// paths against anything other than the tool CWD would let a `../`-laden
// path exit 0 at this line and escape the worktree.
if (!path.isAbsolute(rawFilePath)) {
allow(undefined);
}

View File

@@ -87,7 +87,7 @@ const ON_CRASH = HOOK_ON_CRASH.ALLOW;
// migrated to hooks/lib/hook-exit.js's deny(), using its `stderrPayload`
// param (added for exactly this site): fd 1 still gets the full JSON
// `output`, fd 2 gets ONLY the plain-text `output.reason` string — because
// Kimi's native hook bus reads stderr verbatim back to the model, and a raw
// a native hook bus may read stderr verbatim back to the model, and a raw
// JSON-stringified object on stderr is not the same reason text a model
// should read. Byte-identical to the pre-migration emitBlock.
@@ -227,54 +227,6 @@ function emitBlock(output) {
deny(output, output.reason);
}
// #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.py): WriteFile takes `path`/`content`, and
// kimi-cli's hooks/events.py forwards tool_input verbatim, so both layers need
// mapping. Only WriteFile is mapped: this guard exits 0 for any tool but
// Write, so an Edit-class mapping here would be dead code. 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']]);
function normalizeKimiPayload(data) {
// #2595: total over everything JSON can express — JSON.parse('null') is
// null, and reading .tool_name off a primitive would throw into the outer
// fail-open catch. A null payload has nothing to guard; pass it through
// deliberately rather than by crash.
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;
const input = data.tool_input;
if (input && typeof input === 'object') {
// #2595 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
// not merely fill in when `file_path` happens to be absent. kimi-cli's
// WriteFile schema carries no `file_path` at all (src/kimi_cli/tools/
// file/write.py), so a `file_path` in a Kimi payload is ALWAYS
// model-supplied; under the old `=== undefined` condition a payload
// pairing a curated `path` with a spurious `file_path: ""` left this
// guard reading '' and exiting 0 while kimi-cli wrote to `path` — a
// one-key bypass needing no crash. Overwriting can only narrow what the
// guard inspects to the path that will actually be written.
if (typeof input.path === 'string') {
input.file_path = input.path;
}
}
return data;
}
let input = '';
const stdinTimeout = setTimeout(() => allow(undefined), 3000);
process.stdin.setEncoding('utf8');
@@ -282,7 +234,7 @@ process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = normalizeKimiPayload(JSON.parse(input));
const data = JSON.parse(input);
// A null/primitive payload has nothing to guard — exit deliberately
// rather than throwing into the fail-open catch below (#2595 class).