@mutmutco/kilo-plugin 3.83.0 → 3.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/kilo-plugin",
3
- "version": "3.83.0",
3
+ "version": "3.85.0",
4
4
  "description": "MMI workflow skills and org gates delivery.",
5
5
  "author": {
6
6
  "name": "MMI Future",
@@ -6,10 +6,15 @@ import { createInterface } from 'node:readline';
6
6
 
7
7
  /**
8
8
  * Read all stdin lines and JSON-parse them.
9
+ * #4118: the shared runner now runs a gate IN-PROCESS and has already drained stdin, so it passes the
10
+ * buffered payload in. Parsing it here keeps ONE contract — same throw on unreadable input, so every
11
+ * gate's fail-open/fail-closed branch is reached identically whether it was spawned or imported.
12
+ * @param {Buffer|string} [buffered] Payload already read by the caller; stdin is read when absent.
9
13
  * @returns {Promise<unknown>} Parsed JSON payload.
10
14
  * @throws {Error} On readline or JSON parse failure.
11
15
  */
12
- export async function readHookInput() {
16
+ export async function readHookInput(buffered) {
17
+ if (buffered !== undefined) return JSON.parse(Buffer.isBuffer(buffered) ? buffered.toString('utf8') : String(buffered));
13
18
  const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
14
19
  const lines = [];
15
20
  for await (const line of rl) lines.push(line);
@@ -6,11 +6,56 @@
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { existsSync, readFileSync } from 'node:fs';
8
8
  import { dirname, join, resolve } from 'node:path';
9
- import { fileURLToPath } from 'node:url';
9
+ import { fileURLToPath, pathToFileURL } from 'node:url';
10
10
  import { hookGate, hookSurface } from './hook-policy.mjs';
11
11
 
12
12
  const HERE = dirname(fileURLToPath(import.meta.url));
13
13
 
14
+ // #4118: every fire used to be THREE process boots — launcher, this runner, then a SECOND node for the
15
+ // gate script. The gate is ordinary ESM in the same tree, so it is imported here instead; measured
16
+ // against the live 4,002-fire log that second boot was ~70ms of the ~226ms median, on every fire of
17
+ // all three gates. The gate's own logic was 10-25ms of it.
18
+ //
19
+ // Losing the child process also loses the isolation it provided, so the boundary is rebuilt explicitly
20
+ // and the five things the spawn used to guarantee are each restored by hand:
21
+ // - stdout/stderr are captured (the child's pipes) rather than reaching the host raw, and the
22
+ // captured stdout is then REDUCED to the one decision envelope before it leaves this runner (see
23
+ // decisionEnvelope below — capture alone never bought that, and did not on base either: an injected
24
+ // `process.stdout.write` inside a gate reaches the host ahead of the deny, byte-identical spawned or
25
+ // imported, and the host's JSON.parse fails on the stream). What is guaranteed is narrower than "a
26
+ // stray write can never be read as a decision": stdout that cannot be reduced to exactly ONE decision
27
+ // envelope is not forwarded at all, and on a fail-closed gate that is a gate FAULT (#4016) — the
28
+ // fallback deny runs. So on a gate's DENY path a stray write can suppress the gate's own answer (the
29
+ // fallback's reason replaces it) but cannot weaken the outcome. On a gate's ALLOW path it is not
30
+ // contained: with no carrier of the gate's own to outrank it, a stray write carrying a well-formed
31
+ // allow envelope is elected and forwarded, turning "no decision, defer to the host's prompt" into an
32
+ // explicit auto-approve — measured, and unchanged from base and from 8730324a, so this is the shape
33
+ // #4016 did NOT close. Measured on vault-edit and command-ladder, all ten fault
34
+ // shapes in the #4016 matrix deny, and four consecutive fires of the unterminated-stray shape all deny
35
+ // — the gate still records its own success, so that shape never reaches the #2598 breaker threshold.
36
+ // The breaker is otherwise unchanged: three consecutive GENUINE crashes still degrade fail-open behind
37
+ // its warning banner;
38
+ // - process.exit is trapped (the child's exit code) so a gate that exits mid-run yields a STATUS
39
+ // instead of taking the whole hook process — and with it the fail-closed fallback — down with it;
40
+ // - unhandled rejections and uncaught exceptions are trapped for the same reason: those are the two
41
+ // faults an `await` cannot see, and untrapped they kill the RUNNER (see trapAsyncFaults below);
42
+ // - process.env is set to the gate's effective env before the import, because the gates read
43
+ // surface-dependent modes at MODULE LOAD, and restored after;
44
+ // - the import is cache-busted per fire so a second run in one process re-reads those module-level
45
+ // constants exactly as a fresh child would.
46
+ // Anything that escapes all of that lands in the catch as status 1, which is the same signal a crashed
47
+ // child gave: fail-CLOSED for command-ladder and vault-edit (deny-gate-crash.mjs), fail-open for
48
+ // secret-output. See scripts/hook-policy.mjs for the per-gate posture.
49
+ let fireCount = 0;
50
+
51
+ /** Thrown in place of a gate's process.exit so the runner keeps ownership of the exit code. */
52
+ class GateExit extends Error {
53
+ constructor(code) {
54
+ super(`gate exited ${code}`);
55
+ this.code = code;
56
+ }
57
+ }
58
+
14
59
  export function parseHookArgv(argv) {
15
60
  const parsed = { surface: '', gate: '' };
16
61
  for (let index = 0; index < argv.length; index += 1) {
@@ -41,14 +86,28 @@ export function payloadMeta(input) {
41
86
  }
42
87
  }
43
88
 
89
+ /** Codex's spelling of the one shell tool. The redactor's UPDATABLE_TOOLS is the Claude vocabulary, so
90
+ * the secret-output matcher can only equal it once the host name is translated — untranslated, a Codex
91
+ * `shell` detection took the "cannot be masked for this TOOL" arm and blamed the tool for what is a
92
+ * HOST limit (#4118). Translated for secret-output ONLY: the PreToolUse shell gates match these names
93
+ * directly and pick a dialect from them (scripts/pretooluse-shell-gates.mjs). */
94
+ export const CODEX_SHELL_TOOLS = Object.freeze(['shell', 'local_shell']);
95
+
44
96
  function normalizeInput(surface, gate, input) {
45
- if (surface !== 'cursor') return input;
97
+ const codexShell = surface === 'codex' && gate === 'secret-output';
98
+ if (surface !== 'cursor' && !codexShell) return input;
46
99
  try {
47
100
  const payload = JSON.parse(Buffer.from(input).toString('utf8'));
48
101
  if (!payload || typeof payload !== 'object') return input;
49
- if (payload.tool_name === 'Shell') {
102
+ if (payload.tool_name === 'Shell' || (codexShell && CODEX_SHELL_TOOLS.includes(payload.tool_name))) {
103
+ // Keep the name the HOST fired for the trace to read back. Translated-only, activity.jsonl logged
104
+ // BOTH Codex spellings as `tool: "PowerShell"` — a tool Codex does not have, and the two names
105
+ // became indistinguishable in the one log doctor, the Stop summary and this gate's own
106
+ // justification are counted from. secret-redact.mjs prefers this field when stamping `tool`.
107
+ payload.mmi_host_tool_name = payload.tool_name;
50
108
  payload.tool_name = process.platform === 'win32' ? 'PowerShell' : 'Bash';
51
109
  }
110
+ if (surface !== 'cursor') return Buffer.from(JSON.stringify(payload));
52
111
  if (!payload.session_id && typeof payload.conversation_id === 'string') {
53
112
  payload.session_id = payload.conversation_id;
54
113
  }
@@ -61,6 +120,43 @@ function normalizeInput(surface, gate, input) {
61
120
  }
62
121
  }
63
122
 
123
+ /** Split a gate's stdout into the ONE decision envelope and everything else. Every gate writes at most
124
+ * one envelope and always as a single line of JSON (pretooluse-shell-gates.mjs, vault-edit-gate.mjs,
125
+ * secret-redact.mjs) — the same shape .kilo-plugin/server.mjs already reads back off this launcher.
126
+ * Stray text is moved to stderr rather than dropped: still a symptom worth seeing, just not somewhere a
127
+ * decision can be read from.
128
+ *
129
+ * #4016: "the first line beginning with `{`" was the wrong test and measurably fails OPEN on a
130
+ * fail-CLOSED gate in two shapes. With a `.env` Write driven through vault-edit and the write injected at
131
+ * the gate entry: a stray line that is ITSELF JSON (`{"mmi_debug":"noise"}`) was elected the envelope and
132
+ * the real deny went to stderr — the host then parses a well-formed NON-decision and allows, which base
133
+ * could not do because base's stream did not parse at all; and a stray write with no trailing newline
134
+ * merges with the envelope into one line that starts with `h`, so no line matched, stdout came back EMPTY
135
+ * and the deny was gone. So the line is elected by PARSING it, `hookSpecificOutput` carriers first, and
136
+ * the result reports `malformed` when stdout exists but cannot be reduced to exactly one — which
137
+ * runPolicyGate treats as a gate fault, i.e. the fail-closed fallback for vault-edit/command-ladder. */
138
+ function decisionEnvelope(stdout) {
139
+ if (!stdout.trim()) return { envelope: '', stray: '', malformed: false };
140
+ const lines = stdout.split(/\r?\n/);
141
+ const objects = [];
142
+ for (let index = 0; index < lines.length; index += 1) {
143
+ if (!lines[index].trim()) continue;
144
+ let value;
145
+ try {
146
+ value = JSON.parse(lines[index]);
147
+ } catch {
148
+ continue;
149
+ }
150
+ if (value && typeof value === 'object' && !Array.isArray(value)) objects.push({ index, value });
151
+ }
152
+ const carriers = objects.filter(({ value }) => value.hookSpecificOutput !== undefined);
153
+ const candidates = carriers.length ? carriers : objects;
154
+ if (candidates.length !== 1) return { envelope: '', stray: stdout, malformed: true };
155
+ const at = candidates[0].index;
156
+ const stray = lines.filter((_, index) => index !== at).join('\n').trim();
157
+ return { envelope: `${lines[at]}\n`, stray: stray ? `${stray}\n` : '', malformed: false };
158
+ }
159
+
64
160
  function adaptOutput(surface, stdout) {
65
161
  if (surface !== 'cursor' || !stdout.trim()) return stdout;
66
162
  try {
@@ -84,12 +180,12 @@ function childEnv(surface, input, env) {
84
180
  };
85
181
  }
86
182
 
87
- function inlineDeny(surface, gate, root) {
183
+ function inlineDeny(surface, gate, root, fault = `its fail-closed fallback is missing from the plugin install at ${root}`) {
88
184
  return `${JSON.stringify({
89
185
  hookSpecificOutput: {
90
186
  hookEventName: 'PreToolUse',
91
187
  permissionDecision: 'deny',
92
- permissionDecisionReason: `MMI ${gate} gate could not run on ${surface} and its fail-closed fallback is missing from the plugin install at ${root}. Reinstall the MMI plugin, or set MMI_GATES_FAIL_OPEN=1 to proceed unguarded.`,
188
+ permissionDecisionReason: `MMI ${gate} gate could not run on ${surface} and ${fault}. Reinstall the MMI plugin, or set MMI_GATES_FAIL_OPEN=1 to proceed unguarded.`,
93
189
  },
94
190
  })}\n`;
95
191
  }
@@ -110,7 +206,126 @@ function invoke(target, args, input, env, timeoutMs) {
110
206
  };
111
207
  }
112
208
 
113
- export function runPolicyGate({ surface, gate: gateId, input = Buffer.alloc(0), env = process.env, here = HERE }) {
209
+ /** Point process.env at the gate's effective env for one in-process run, then put it back. */
210
+ function applyProcessEnv(env) {
211
+ if (env === process.env) return () => {};
212
+ const prior = { ...process.env };
213
+ const sync = (target) => {
214
+ for (const key of Object.keys(process.env)) if (!(key in target)) delete process.env[key];
215
+ for (const [key, value] of Object.entries(target)) {
216
+ if (value === undefined) delete process.env[key];
217
+ else if (process.env[key] !== value) process.env[key] = value;
218
+ }
219
+ };
220
+ sync(env);
221
+ return () => sync(prior);
222
+ }
223
+
224
+ /** Buffer everything the gate writes, the way the child's pipes used to. */
225
+ function captureStdio() {
226
+ const chunks = { stdout: [], stderr: [] };
227
+ const prior = { stdout: process.stdout.write, stderr: process.stderr.write };
228
+ const sink = (name) => function write(chunk, encoding, callback) {
229
+ chunks[name].push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
230
+ const done = typeof encoding === 'function' ? encoding : callback;
231
+ if (typeof done === 'function') done();
232
+ return true;
233
+ };
234
+ process.stdout.write = sink('stdout');
235
+ process.stderr.write = sink('stderr');
236
+ return () => {
237
+ process.stdout.write = prior.stdout;
238
+ process.stderr.write = prior.stderr;
239
+ return { stdout: chunks.stdout.join(''), stderr: chunks.stderr.join('') };
240
+ };
241
+ }
242
+
243
+ /** The child's `timeout` option: a gate that never settles must not hold the turn open. The timer is
244
+ * deliberately NOT unref'd — an unref'd timer lets the event loop drain when the gate settles nothing
245
+ * and holds no handle, and the runner then exits 0 with no output, which is fail-OPEN. clearTimeout in
246
+ * the `finally` is what keeps it from holding the loop open on the ordinary path. */
247
+ function withTimeout(promise, timeoutMs) {
248
+ if (!timeoutMs) return promise;
249
+ return new Promise((settle, fail) => {
250
+ const timer = setTimeout(() => fail(new Error(`gate exceeded ${timeoutMs}ms`)), timeoutMs);
251
+ promise.then(settle, fail).finally(() => clearTimeout(timer));
252
+ });
253
+ }
254
+
255
+ /**
256
+ * The child process absorbed a gate's ASYNC faults — a detached rejection or a throw from a timer
257
+ * crashed the child, the runner read status 1, and the fail-closed fallback ran. In-process those two
258
+ * faults reach the runner's own default handler and kill it: measured against 1326c2db, a detached
259
+ * rejection inside vault-edit-gate left the runner at exit 1 with NO stdout, and a hook that exits 1
260
+ * is a non-blocking error on Claude Code, so the tool call proceeds unguarded. `await` cannot see
261
+ * either fault, so the only place to catch them is the process handlers, for the length of the run.
262
+ * Prior handlers are saved and restored: a host that embeds runPolicyGate keeps its own.
263
+ */
264
+ function trapAsyncFaults() {
265
+ let onFault = () => {};
266
+ const fault = new Promise((_, fail) => {
267
+ onFault = (err) => fail(err instanceof Error ? err : new Error(String(err)));
268
+ });
269
+ // A fault that lands after the gate already settled has nothing racing it; swallow it here rather
270
+ // than let the rejection this function created become the next unhandled rejection.
271
+ fault.catch(() => {});
272
+ const prior = {
273
+ unhandledRejection: process.rawListeners('unhandledRejection'),
274
+ uncaughtException: process.rawListeners('uncaughtException'),
275
+ };
276
+ for (const event of Object.keys(prior)) {
277
+ process.removeAllListeners(event);
278
+ process.on(event, onFault);
279
+ }
280
+ return {
281
+ fault,
282
+ release() {
283
+ for (const [event, listeners] of Object.entries(prior)) {
284
+ process.removeAllListeners(event);
285
+ for (const listener of listeners) process.on(event, listener);
286
+ }
287
+ },
288
+ };
289
+ }
290
+
291
+ async function invokeInProcess(target, input, env, timeoutMs) {
292
+ const restoreEnv = applyProcessEnv(env);
293
+ const drain = captureStdio();
294
+ const faults = trapAsyncFaults();
295
+ const priorExit = process.exit;
296
+ process.exit = (code) => {
297
+ throw new GateExit(typeof code === 'number' ? code : 0);
298
+ };
299
+ let status = 0;
300
+ let error;
301
+ let legacy = false;
302
+ try {
303
+ const gateModule = await Promise.race([
304
+ faults.fault,
305
+ import(`${pathToFileURL(target).href}?mmi-hook-fire=${(fireCount += 1)}`),
306
+ ]);
307
+ // A gate from before #4118 is still a runnable script with its own self-run guard. Treating the
308
+ // missing export as a crash made every Edit/Write/Bash deny under the version skew the plugin's
309
+ // own `upgrade.mechanism: reinstall` creates (installed cache older than the tree); the spawn is
310
+ // the compatibility path, not a fallback for failure.
311
+ if (typeof gateModule.runHookGate !== 'function') legacy = true;
312
+ else await withTimeout(Promise.race([faults.fault, gateModule.runHookGate({ input })]), timeoutMs);
313
+ } catch (err) {
314
+ if (err instanceof GateExit) status = err.code;
315
+ else {
316
+ status = 1;
317
+ error = err;
318
+ }
319
+ } finally {
320
+ process.exit = priorExit;
321
+ faults.release();
322
+ }
323
+ const { stdout, stderr } = drain();
324
+ restoreEnv();
325
+ return { status, stdout, stderr, error, legacy };
326
+ }
327
+
328
+ export async function runPolicyGate({ surface, gate: gateId, input = Buffer.alloc(0), env = process.env, here = HERE }) {
114
329
  const gate = hookGate(gateId);
115
330
  hookSurface(surface);
116
331
  const root = pluginRoot(surface, env, here);
@@ -122,24 +337,48 @@ export function runPolicyGate({ surface, gate: gateId, input = Buffer.alloc(0),
122
337
  if (!existsSync(target)) {
123
338
  result = { status: 1, stdout: '', stderr: `[mmi-${surface}-hook] ${gate.script} not found under ${root}\n` };
124
339
  } else {
125
- result = invoke(target, [], normalizedInput, effectiveEnv, gate.timeoutMs);
340
+ result = await invokeInProcess(target, normalizedInput, effectiveEnv, gate.timeoutMs);
341
+ if (result.legacy) result = invoke(target, [], normalizedInput, effectiveEnv, gate.timeoutMs);
126
342
  if (result.status === 0 || result.status === 2) {
127
- return { ...result, stdout: adaptOutput(surface, result.stdout) };
343
+ const { envelope, stray, malformed } = decisionEnvelope(result.stdout);
344
+ if (!malformed) {
345
+ return { ...result, stdout: adaptOutput(surface, envelope), stderr: `${result.stderr}${stray}` };
346
+ }
347
+ // #4016: the gate exited cleanly but its stdout does not reduce to one decision. The runner cannot
348
+ // tell "allowed, then printed junk" from "denied, and the deny got mangled" — the second is exactly
349
+ // what an unterminated stray write produces — so this is a gate fault, not an allow.
350
+ result = { ...result, status: 1, stdout: '', stderr: `${result.stderr}${stray}` };
351
+ result.stderr += `[mmi-${surface}-hook] ${gate.script} wrote stdout with no single decision envelope\n`;
352
+ } else {
353
+ result.stderr += `[mmi-${surface}-hook] ${gate.script} exited ${result.status}`;
354
+ result.stderr += result.error ? `: ${result.error.message}\n` : '\n';
128
355
  }
129
- result.stderr += `[mmi-${surface}-hook] ${gate.script} exited ${result.status}\n`;
130
356
  }
131
357
 
132
358
  if (gate.failure === 'open') return { ...result, status: 0 };
133
359
 
360
+ // The fallback deliberately keeps its own process. Whatever just failed did so INSIDE this one, so a
361
+ // fail-closed deny must not be computed by the same module registry and globals that produced it.
134
362
  const crashGate = join(root, 'scripts', 'deny-gate-crash.mjs');
135
363
  if (!existsSync(crashGate)) {
136
364
  return { status: 0, stdout: inlineDeny(surface, gate.fallbackGate, root), stderr: result.stderr };
137
365
  }
138
366
  const fallback = invoke(crashGate, [gate.fallbackGate], normalizedInput, effectiveEnv, gate.timeoutMs);
367
+ const { envelope, stray, malformed } = decisionEnvelope(fallback.stdout);
368
+ // An EMPTY fallback stdout is intentional (breaker open, or MMI_GATES_FAIL_OPEN) and stays empty;
369
+ // `malformed` is the other case — the fallback itself emitted something unreadable — and that must not
370
+ // become an allow either (#4016).
371
+ if (malformed) {
372
+ return {
373
+ status: 0,
374
+ stdout: inlineDeny(surface, gate.fallbackGate, root, 'its fail-closed fallback wrote no readable decision'),
375
+ stderr: `${result.stderr}${fallback.stderr}${stray}`,
376
+ };
377
+ }
139
378
  return {
140
379
  ...fallback,
141
- stdout: adaptOutput(surface, fallback.stdout),
142
- stderr: `${result.stderr}${fallback.stderr}`,
380
+ stdout: adaptOutput(surface, envelope),
381
+ stderr: `${result.stderr}${fallback.stderr}${stray}`,
143
382
  };
144
383
  }
145
384
 
@@ -151,11 +390,11 @@ function readStdin() {
151
390
  }
152
391
  }
153
392
 
154
- export function main(argv = process.argv.slice(2)) {
393
+ export async function main(argv = process.argv.slice(2)) {
155
394
  const { surface, gate } = parseHookArgv(argv);
156
395
  let result;
157
396
  try {
158
- result = runPolicyGate({ surface, gate, input: readStdin() });
397
+ result = await runPolicyGate({ surface, gate, input: readStdin() });
159
398
  } catch (error) {
160
399
  process.stderr.write(`[mmi-hook] ${error.message}\n`);
161
400
  process.exit(1);
@@ -167,4 +406,9 @@ export function main(argv = process.argv.slice(2)) {
167
406
 
168
407
  // Codex may import this file from a replacement cache while process.argv[1] still names the deleted
169
408
  // cache path captured when the session loaded. Match the stable relative suffix, not URL identity.
170
- if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('scripts/hook-run.mjs')) main();
409
+ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('scripts/hook-run.mjs')) {
410
+ main().catch((error) => {
411
+ process.stderr.write(`[mmi-hook] ${error && error.message}\n`);
412
+ process.exit(1);
413
+ });
414
+ }
@@ -12,9 +12,8 @@
12
12
  // back to a per-cwd tmpdir path outside a repo so the trace still works anywhere. Fail-soft always:
13
13
  // the trace must never crash a turn or block a tool call.
14
14
  import { createHash } from 'node:crypto';
15
- import { execFileSync } from 'node:child_process';
16
- import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
17
- import { dirname, join, resolve } from 'node:path';
15
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
16
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
18
17
  import { tmpdir } from 'node:os';
19
18
 
20
19
  const DEFAULT_SURFACE = 'claude';
@@ -54,10 +53,60 @@ function fallbackPath(cwd) {
54
53
  return join(tmpdir(), 'mmi-cli', hash, 'hooks', 'activity.jsonl');
55
54
  }
56
55
 
56
+ // #4118: resolving the repo-local path used to shell out to `git rev-parse --git-path` — a fresh
57
+ // process on EVERY hook fire, 4,002 of them in the sampled window, so memoising inside the process
58
+ // would have saved nothing. Re-measured with the exact call it replaced, 25 samples in each of three
59
+ // checkouts: median 24.4/25.2/27.3ms, max 48.3/49.0/51.3ms. (An earlier note here quoted the ~49ms tail
60
+ // as the per-fire rate; it is roughly twice the median.) The answer is a pure function of the .git
61
+ // layout, so it is read off the filesystem instead. This is the resolution cli/src/repo-runtime-state.ts
62
+ // already performs for the CLI half of the same log; the two mirrors now agree by construction rather
63
+ // than by two different mechanisms happening to land on the same path.
64
+ //
65
+ // Two layouts, both of which `--git-path` answers identically: a `.git` DIRECTORY (ordinary checkout),
66
+ // and a `.git` FILE naming a private gitdir (a linked worktree — this org's normal working state —
67
+ // or a submodule, where the named path may be relative). Unlike the CLI mirror this walks UP, because a
68
+ // hook's cwd is the session's cwd and may sit below the repo root. A bare repo, where the cwd IS the
69
+ // gitdir, is not resolved: hooks do not run there, and the tmpdir fallback below still gives a log.
70
+ const GIT_DIR_MEMO = new Map();
71
+
72
+ function gitDir(cwd, env) {
73
+ if (env.GIT_DIR) return resolve(cwd, env.GIT_DIR);
74
+ if (GIT_DIR_MEMO.has(cwd)) return GIT_DIR_MEMO.get(cwd);
75
+ let dir = resolve(cwd);
76
+ let found = '';
77
+ for (;;) {
78
+ const dotGit = join(dir, '.git');
79
+ let stats;
80
+ try {
81
+ stats = statSync(dotGit);
82
+ } catch {
83
+ stats = null;
84
+ }
85
+ if (stats?.isDirectory()) {
86
+ found = dotGit;
87
+ break;
88
+ }
89
+ if (stats?.isFile()) {
90
+ try {
91
+ const named = /^gitdir:\s*(.+)$/im.exec(readFileSync(dotGit, 'utf8').trim())?.[1]?.trim();
92
+ if (named) found = isAbsolute(named) ? named : resolve(dir, named);
93
+ } catch {
94
+ /* unreadable .git file — fall through to the tmpdir fallback */
95
+ }
96
+ break;
97
+ }
98
+ const parent = dirname(dir);
99
+ if (parent === dir) break;
100
+ dir = parent;
101
+ }
102
+ GIT_DIR_MEMO.set(cwd, found);
103
+ return found;
104
+ }
105
+
57
106
  /**
58
107
  * Resolve the hook-activity log path for a working directory.
59
- * Prefers the repo-local gitignored state dir (`.git/mmi-runtime/hooks/activity.jsonl` via
60
- * `git rev-parse --git-path`); falls back to a per-cwd tmpdir path so the trace still works
108
+ * Prefers the repo-local gitignored state dir (`.git/mmi-runtime/hooks/activity.jsonl`, resolved from
109
+ * the .git layout by `gitDir` above); falls back to a per-cwd tmpdir path so the trace still works
61
110
  * outside a repo. Env overrides: `MMI_HOOK_ACTIVITY_LOG` (full path, wins outright) and
62
111
  * `MMI_HOOK_ACTIVITY_CWD` (resolve against this directory instead of the hook process's own cwd —
63
112
  * the Kimi launcher stamps it from the hook payload because Kimi runs plugin hooks with cwd = the
@@ -70,15 +119,9 @@ function fallbackPath(cwd) {
70
119
  export function activityLogPath(cwd = process.cwd(), env = process.env) {
71
120
  if (env.MMI_HOOK_ACTIVITY_LOG) return env.MMI_HOOK_ACTIVITY_LOG;
72
121
  const effectiveCwd = env.MMI_HOOK_ACTIVITY_CWD || cwd;
73
- try {
74
- return execFileSync(
75
- 'git',
76
- ['-C', effectiveCwd, 'rev-parse', '--path-format=absolute', '--git-path', 'mmi-runtime/hooks/activity.jsonl'],
77
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, windowsHide: true },
78
- ).trim();
79
- } catch {
80
- return fallbackPath(effectiveCwd);
81
- }
122
+ const dir = gitDir(effectiveCwd, env);
123
+ if (dir && existsSync(dir)) return join(dir, 'mmi-runtime', 'hooks', 'activity.jsonl');
124
+ return fallbackPath(effectiveCwd);
82
125
  }
83
126
 
84
127
  /**
@@ -361,10 +361,12 @@ function runCommandLadder(input, { stdout = process.stdout, stderr = process.std
361
361
  }
362
362
  }
363
363
 
364
- export async function runPreToolUseShellGates({ stdout = process.stdout, stderr = process.stderr } = {}) {
364
+ /** #4118: exported as `runHookGate` too the uniform in-process entry hook-run.mjs imports instead of
365
+ * booting a second node. `input` is the buffered payload when the runner already drained stdin. */
366
+ export async function runPreToolUseShellGates({ input: buffered, stdout = process.stdout, stderr = process.stderr } = {}) {
365
367
  let input;
366
368
  try {
367
- input = await readHookInput();
369
+ input = await readHookInput(buffered);
368
370
  } catch {
369
371
  // Unreadable/absent payload = out-of-contract host (Cursor's Claude-plugin import, #2992):
370
372
  // fail open without counting a crash. Post-parse crashes below stay fail-closed (#2598).
@@ -407,6 +409,8 @@ export async function runPreToolUseShellGates({ stdout = process.stdout, stderr
407
409
  }
408
410
  }
409
411
 
412
+ export { runPreToolUseShellGates as runHookGate };
413
+
410
414
  if (
411
415
  process.argv[1] &&
412
416
  (process.argv[1].endsWith('pretooluse-shell-gates.mjs') ||
@@ -444,11 +444,20 @@ export function postToolUseRedactDecision(input) {
444
444
  return { toolName, redactable: false, hookSpecificOutput: null };
445
445
  }
446
446
 
447
- async function main() {
447
+ /** The tool name the HOST actually fired, for the trace. hook-run.mjs rewrites a host's shell spelling
448
+ * into the Claude vocabulary UPDATABLE_TOOLS is written in (#4118) and stamps the original beside it;
449
+ * logging the rewritten name recorded Codex `shell` and `local_shell` as identical "PowerShell" rows. */
450
+ function tracedTool(input) {
451
+ return typeof input?.mmi_host_tool_name === 'string' ? input.mmi_host_tool_name : input?.tool_name;
452
+ }
453
+
454
+ /** Shared entry (#4118): awaitable and exit-free so hook-run.mjs can import it in-process instead of
455
+ * booting a second node. `input` is the buffered payload when the runner already drained stdin. */
456
+ export async function runHookGate({ input: buffered } = {}) {
448
457
  let input;
449
458
  try {
450
459
  const { readHookInput } = await import('./hook-io.mjs');
451
- input = await readHookInput();
460
+ input = await readHookInput(buffered);
452
461
  } catch {
453
462
  appendHookActivity({
454
463
  event: 'PostToolUse',
@@ -456,7 +465,7 @@ async function main() {
456
465
  outcome: 'failed',
457
466
  action: 'could not read hook input',
458
467
  });
459
- process.exit(0);
468
+ return;
460
469
  }
461
470
 
462
471
  const raw = JSON.stringify(input);
@@ -466,9 +475,9 @@ async function main() {
466
475
  script: 'secret-redact',
467
476
  outcome: 'ran',
468
477
  action: 'clean (pre-filter)',
469
- tool: input?.tool_name,
478
+ tool: tracedTool(input),
470
479
  });
471
- process.exit(0);
480
+ return;
472
481
  }
473
482
 
474
483
  try {
@@ -500,7 +509,7 @@ async function main() {
500
509
  if (decision && !decision.redactable) {
501
510
  process.stderr.write(`[mmi-hook] secret-redact: a secret-shaped value was detected in ${decision.toolName || 'tool'} output and cannot be masked for this tool (no updatedToolOutput channel) — treat the transcript as exposed.\n`);
502
511
  }
503
- appendHookActivity({ event: 'PostToolUse', script: 'secret-redact', outcome, action, tool: input?.tool_name });
512
+ appendHookActivity({ event: 'PostToolUse', script: 'secret-redact', outcome, action, tool: tracedTool(input) });
504
513
  } catch (err) {
505
514
  // #2610: the redactor stays fail-open (PostToolUse — the bytes already left the tool, blocking buys
506
515
  // nothing; see #2598 rescope), but its death must be LOUD. Record a greppable crash marker so a
@@ -511,10 +520,9 @@ async function main() {
511
520
  outcome: 'failed',
512
521
  action: 'redactor crashed during scan',
513
522
  error: err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err),
514
- tool: input?.tool_name,
523
+ tool: tracedTool(input),
515
524
  });
516
525
  }
517
- process.exit(0);
518
526
  }
519
527
 
520
528
  if (
@@ -522,9 +530,9 @@ if (
522
530
  (process.argv[1].endsWith('secret-redact.mjs') ||
523
531
  process.argv[1].replace(/\\/g, '/').endsWith('scripts/secret-redact.mjs'))
524
532
  ) {
525
- // #2610: a rejection from main() itself (e.g. the dynamic import blew up before the inner try) must not be
526
- // swallowed silently either — record a crash marker, then keep the fail-open exit-0 contract.
527
- main().catch((err) => {
533
+ // #2610: a rejection from runHookGate() itself (e.g. the dynamic import blew up before the inner try) must
534
+ // not be swallowed silently either — record a crash marker, then keep the fail-open exit-0 contract.
535
+ runHookGate().then(() => process.exit(0)).catch((err) => {
528
536
  appendHookActivity({
529
537
  event: 'PostToolUse',
530
538
  script: 'secret-redact',
@@ -36,22 +36,24 @@ export function analyze(input) {
36
36
  };
37
37
  }
38
38
 
39
- async function main() {
40
- let input;
39
+ /** Shared entry (#4118): awaitable and exit-free so hook-run.mjs can import it in-process instead of
40
+ * booting a second node. `input` is the buffered payload when the runner already drained stdin. */
41
+ export async function runHookGate({ input } = {}) {
42
+ let parsed;
41
43
  try {
42
- input = await readHookInput();
44
+ parsed = await readHookInput(input);
43
45
  } catch {
44
46
  // Unreadable/absent payload = out-of-contract host (#2992): fail open without counting a crash.
45
47
  const res = handleMissingHookInput(GATE_NAME);
46
48
  if (res.stdout) process.stdout.write(res.stdout);
47
49
  if (res.stderr) process.stderr.write(res.stderr);
48
- process.exit(0);
50
+ return;
49
51
  }
50
52
  recordGateSuccess(GATE_NAME);
51
53
 
52
54
  const { block, reason } = analyze({
53
- toolName: input?.tool_name,
54
- filePaths: editedPaths(input),
55
+ toolName: parsed?.tool_name,
56
+ filePaths: editedPaths(parsed),
55
57
  });
56
58
 
57
59
  appendHookActivity({
@@ -59,7 +61,7 @@ async function main() {
59
61
  script: GATE_NAME,
60
62
  outcome: block ? (MODE === 'observe' ? 'observe' : 'deny') : 'ran',
61
63
  action: block ? reason : 'clean',
62
- tool: input?.tool_name,
64
+ tool: parsed?.tool_name,
63
65
  });
64
66
 
65
67
  if (block) {
@@ -76,8 +78,6 @@ async function main() {
76
78
  process.stdout.write(decision + '\n');
77
79
  }
78
80
  }
79
-
80
- process.exit(0);
81
81
  }
82
82
 
83
83
  if (
@@ -85,7 +85,7 @@ if (
85
85
  (process.argv[1].endsWith('vault-edit-gate.mjs') ||
86
86
  process.argv[1].replace(/\\/g, '/').endsWith('scripts/vault-edit-gate.mjs'))
87
87
  ) {
88
- main().catch(() => {
88
+ runHookGate().then(() => process.exit(0)).catch(() => {
89
89
  const res = handleGateCrash(GATE_NAME);
90
90
  if (res.stdout) process.stdout.write(res.stdout);
91
91
  if (res.stderr) process.stderr.write(res.stderr);
package/server.mjs CHANGED
@@ -44,10 +44,14 @@ export function nodeBin() {
44
44
  // Kilo tool names → the Claude vocabulary the shared gates already understand.
45
45
  const SHELL_TOOLS = new Set(['bash', 'shell', 'powershell']);
46
46
  const EDIT_TOOLS = new Set(['write', 'edit', 'patch', 'apply_patch']);
47
+ // #4118: only the tools the redactor can actually MASK. secret-redact.mjs replaces output through
48
+ // `updatedToolOutput`, which the harness offers for the shell/search family and nothing else — for
49
+ // write/edit/patch/read it returned `redactable: false` and printed a warning, so those entries bought a
50
+ // gate spawn per tool call and never masked a byte. Kilo's own map is the matcher here (there are no
51
+ // manifest hook rules), so this is the same narrowing hooks/hooks.json makes for Claude.
47
52
  const REDACT_TOOL_NAME = {
48
53
  bash: 'Bash', shell: 'Bash', powershell: 'Bash',
49
- write: 'Write', edit: 'Write', patch: 'Write', apply_patch: 'apply_patch',
50
- read: 'Read', grep: 'Grep', glob: 'Glob',
54
+ grep: 'Grep', glob: 'Glob',
51
55
  };
52
56
 
53
57
  /** Idempotent first-run provisioning of the non-plugin payload (~/.kilo/{skills,command,agent}) behind
@@ -207,7 +211,8 @@ export default {
207
211
  }
208
212
  },
209
213
  // PostToolUse equivalent with a WORKING rewrite channel: secret-redact.mjs's updatedToolOutput is
210
- // applied to output.output. Write/Read/WebFetch are not rewriteable (same harness limit as Claude).
214
+ // applied to output.output. A tool absent from REDACT_TOOL_NAME is one the redactor cannot mask,
215
+ // and is skipped rather than gated for a warning nobody could act on (#4118).
211
216
  'tool.execute.after': async (input, output) => {
212
217
  const tool = typeof input.tool === 'string' ? input.tool : '';
213
218
  const translated = REDACT_TOOL_NAME[tool];
@@ -38,7 +38,11 @@ mmi-cli doctor --no-repo-writes
38
38
  ```
39
39
 
40
40
  Stop on a red authority or CLI-version result. The worktree must be clean; move scratch into `tmp/` or
41
- gitignore it rather than widening the hotfix diff.
41
+ gitignore it rather than widening the hotfix diff. A TRACKED path named in a `working tree must be clean
42
+ before …` refusal is not scratch: read both `git status --porcelain` columns, treat every state except
43
+ exactly ` M` as real work to commit or stash, and for ` M` discard only when
44
+ `git diff HEAD --numstat -- <paths>` is empty, and only with `git checkout -- <paths>`. Plain
45
+ `git diff --numstat` prints nothing for a merely staged change, so it cannot make that call (#4004).
42
46
 
43
47
  ## 1. Start from the merged development fix
44
48
 
@@ -43,8 +43,12 @@ primary checkout.
43
43
  ## Step 1 — development ahead of rc?
44
44
 
45
45
  Preconditions: on `development`, clean tree. The clean-tree check rejects UNTRACKED scratch too, not just
46
- modified tracked files — if `--apply` stops with `working tree must be clean before …`, run `git status` and
47
- gitignore the `??` scratch (or move it to a gitignored path like `tmp/`) before retrying (#1472).
46
+ modified tracked files — if `--apply` stops with `working tree must be clean before …`, run `git status
47
+ --porcelain` on the paths it named and read both columns. Gitignore the `??` scratch (or move it to a
48
+ gitignored path like `tmp/`). Treat every tracked state except exactly ` M` as real work to commit or stash;
49
+ for ` M`, discard only when `git diff HEAD --numstat -- <paths>` is empty, and only with
50
+ `git checkout -- <paths>`. Plain `git diff --numstat` prints nothing for a merely staged change, so it
51
+ cannot tell real work from line-ending churn (#1472, #4004).
48
52
  ```bash
49
53
  git fetch origin
50
54
  git rev-list --count origin/rc..origin/development
@@ -72,9 +72,22 @@ breaks here: the new worktree's branch is never literally named `development`/`r
72
72
  development` inside it fails outright when `development` is already checked out in the primary checkout (git
73
73
  worktrees cannot have the same branch checked out twice). If you are in such a worktree, exit it first and
74
74
  run the release from the primary checkout.
75
- The clean-tree check rejects UNTRACKED scratch too, not just modified tracked files if `--apply` stops
76
- with `working tree must be clean before …`, run `git status` and gitignore the `??` scratch (or move it to a
77
- gitignored path like `tmp/`) before retrying (#1472).
75
+ The clean-tree check rejects UNTRACKED scratch too, not just modified tracked files. When `--apply` or
76
+ `--resume` stops with `working tree must be clean before …`, run `git status --porcelain` on the paths it
77
+ named and read BOTH status columns before touching anything (#1472, #4004):
78
+
79
+ - `??` — untracked scratch. Gitignore it, or move it to a gitignored path like `tmp/`, then retry.
80
+ - exactly ` M` (blank staged column) — the only state that can be churn rather than work. Test it with
81
+ `git diff HEAD --numstat -- <paths>`: non-empty is a real edit to commit or stash; empty means the
82
+ worktree normalizes straight back to HEAD (an LF↔CRLF rewrite, say), and `git checkout -- <paths>`
83
+ then clears the refusal without discarding anything.
84
+ - anything else — `M `, `MM`, `A`, `D`, `R`, `T`, or any `U` conflict — is real work or an exceptional
85
+ index state. Commit, stash or resolve it; do not try to classify it.
86
+
87
+ Do not substitute plain `git diff --numstat` for the `HEAD` form. It compares the worktree against the
88
+ INDEX, so a change that is merely staged prints nothing at all, and reading that emptiness as
89
+ "line-ending churn" throws the change away. The remedy is the index form of `git checkout` for the same
90
+ reason: `git checkout HEAD -- <paths>` would overwrite a staged edit that was misread as churn.
78
91
 
79
92
  Full-track repos:
80
93
  ```bash
@@ -471,6 +484,19 @@ mmi-cli org project sync-info --apply # omit --apply for the read-only plan
471
484
  channel — #3630 took SessionStart off the hook surface) runs the
472
485
  same fast-forward first thing, so a stale local `development`/`main`/`rc` self-heals to origin before you
473
486
  work — no manual `git pull`. Nothing here ever blocks or fails the release.
487
+ - **Read the `checkout:` clause — it does not always say "returned" (#4006).** The report always prints
488
+ one, and restoration is deliberately skipped rather than forced when the repo is not in a fit state.
489
+ A release leaves the repo on `main` until the train moves it back, so a non-`returned` outcome means
490
+ you are still there and must return by hand once the cause is cleared:
491
+ - `checkout restoration skipped: working tree changed after release` — the tree went dirty DURING the
492
+ train, so the train declined to move you. Diagnose those paths with the Step 0 clean-tree rules,
493
+ then `git checkout <start branch>` yourself. The release itself already shipped; this is a
494
+ deliberate, harmless decline, not a failed release.
495
+ - `checkout restoration failed while returning to …` / `origin/<branch> fast-forward failed` — the
496
+ checkout or the pull errored. Read the appended git message; the release is unaffected.
497
+
498
+ `--resume` prints no `checkout:` clause at all, so after a resumed release check `git branch
499
+ --show-current` rather than assuming you were moved back.
474
500
 
475
501
  ## Step 6 — collect deploy verdict + report
476
502
 
@@ -478,6 +504,10 @@ Collect the backgrounded prod-deploy watch from Step 4 (it has typically finishe
478
504
  healthy (the central deploy workflow smoke step / a health check); **red** → report the failure prominently and flag
479
505
  that the release shipped on a failed deploy (re-run just the deploy — `main` is already correct).
480
506
 
507
+ **This distribution verifier is Hub-only.** It assumes the Hub release fold has already committed its
508
+ generated artifacts. Non-Hub `registry-publish` repositories must not run `release-distribution.mjs
509
+ verify`: use their own release-workflow evidence and the publish-visibility contract below.
510
+
481
511
  Hub releases always carry a distribution bump (the Step 1b fold), so the **publish workflow**
482
512
  (`publish.yml`) ships every registry-declared public npm artifact on the GitHub Release from Step 4 —
483
513
  don't publish by hand.