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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user