@mutmutco/kilo-plugin 3.84.0 → 3.86.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 +1 -1
- package/scripts/hook-io.mjs +6 -1
- package/scripts/hook-run.mjs +258 -14
- package/scripts/hook-trace.mjs +57 -14
- package/scripts/pretooluse-shell-gates.mjs +6 -2
- package/scripts/secret-redact.mjs +30 -15
- package/scripts/vault-edit-gate.mjs +10 -10
- package/server.mjs +30 -9
package/package.json
CHANGED
package/scripts/hook-io.mjs
CHANGED
|
@@ -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);
|
package/scripts/hook-run.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
export 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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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'))
|
|
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
|
+
}
|
package/scripts/hook-trace.mjs
CHANGED
|
@@ -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 {
|
|
16
|
-
import {
|
|
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
|
|
60
|
-
*
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
478
|
+
tool: tracedTool(input),
|
|
470
479
|
});
|
|
471
|
-
|
|
480
|
+
return;
|
|
472
481
|
}
|
|
473
482
|
|
|
474
483
|
try {
|
|
@@ -484,23 +493,30 @@ async function main() {
|
|
|
484
493
|
}
|
|
485
494
|
const healed = Boolean(decision?.redactable) && canRewriteOutput;
|
|
486
495
|
const outcome = !decision ? 'ran' : healed ? 'heal' : 'observe';
|
|
496
|
+
// #4015/#4021: every operator- and log-facing string names the tool the HOST fired, never
|
|
497
|
+
// `decision.toolName` — that one is the name hook-run.mjs rewrote into the Claude vocabulary so it can
|
|
498
|
+
// be tested against UPDATABLE_TOOLS, and it collapsed Codex `shell` and `local_shell` into "PowerShell".
|
|
499
|
+
// #4012 fixed this call's `tool` FIELD and #4015 the stderr arms; the `action` TEXT below was converted
|
|
500
|
+
// by neither, so one row read `{"tool":"shell","action":"secret detected in PowerShell output …"}` and
|
|
501
|
+
// disagreed with itself — in the log doctor, the Stop summary and scrooge.md's detection counts read.
|
|
502
|
+
const alarmTool = tracedTool(input) || decision?.toolName || 'tool';
|
|
487
503
|
const action = !decision
|
|
488
504
|
? 'clean'
|
|
489
505
|
: healed
|
|
490
506
|
? 'redacted secrets from tool output'
|
|
491
507
|
: decision.redactable
|
|
492
|
-
? `secret detected in ${
|
|
493
|
-
: `secret detected in ${
|
|
508
|
+
? `secret detected in ${alarmTool} output and NOT masked — this host cannot rewrite tool output (no updatedToolOutput channel on this surface)`
|
|
509
|
+
: `secret detected in ${alarmTool} output but PostToolUse cannot redact this tool (harness limitation)`;
|
|
494
510
|
// Detection without masking must be audible, not just logged: on Codex the value is still on screen.
|
|
495
511
|
if (decision?.redactable && !canRewriteOutput) {
|
|
496
|
-
process.stderr.write(`[mmi-hook] secret-redact: a secret-shaped value was detected in ${
|
|
512
|
+
process.stderr.write(`[mmi-hook] secret-redact: a secret-shaped value was detected in ${alarmTool} output and could NOT be masked on this host — treat the transcript as exposed.\n`);
|
|
497
513
|
}
|
|
498
514
|
// #3630: same audibility for the non-redactable-TOOL arm. A detection in Read/WebFetch/Agent/mcp__*
|
|
499
515
|
// output landed only in the trace file — silent on the one surface where the value is still visible.
|
|
500
516
|
if (decision && !decision.redactable) {
|
|
501
|
-
process.stderr.write(`[mmi-hook] secret-redact: a secret-shaped value was detected in ${
|
|
517
|
+
process.stderr.write(`[mmi-hook] secret-redact: a secret-shaped value was detected in ${alarmTool} output and cannot be masked for this tool (no updatedToolOutput channel) — treat the transcript as exposed.\n`);
|
|
502
518
|
}
|
|
503
|
-
appendHookActivity({ event: 'PostToolUse', script: 'secret-redact', outcome, action, tool: input
|
|
519
|
+
appendHookActivity({ event: 'PostToolUse', script: 'secret-redact', outcome, action, tool: tracedTool(input) });
|
|
504
520
|
} catch (err) {
|
|
505
521
|
// #2610: the redactor stays fail-open (PostToolUse — the bytes already left the tool, blocking buys
|
|
506
522
|
// nothing; see #2598 rescope), but its death must be LOUD. Record a greppable crash marker so a
|
|
@@ -511,10 +527,9 @@ async function main() {
|
|
|
511
527
|
outcome: 'failed',
|
|
512
528
|
action: 'redactor crashed during scan',
|
|
513
529
|
error: err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err),
|
|
514
|
-
tool: input
|
|
530
|
+
tool: tracedTool(input),
|
|
515
531
|
});
|
|
516
532
|
}
|
|
517
|
-
process.exit(0);
|
|
518
533
|
}
|
|
519
534
|
|
|
520
535
|
if (
|
|
@@ -522,9 +537,9 @@ if (
|
|
|
522
537
|
(process.argv[1].endsWith('secret-redact.mjs') ||
|
|
523
538
|
process.argv[1].replace(/\\/g, '/').endsWith('scripts/secret-redact.mjs'))
|
|
524
539
|
) {
|
|
525
|
-
// #2610: a rejection from
|
|
526
|
-
// swallowed silently either — record a crash marker, then keep the fail-open exit-0 contract.
|
|
527
|
-
|
|
540
|
+
// #2610: a rejection from runHookGate() itself (e.g. the dynamic import blew up before the inner try) must
|
|
541
|
+
// not be swallowed silently either — record a crash marker, then keep the fail-open exit-0 contract.
|
|
542
|
+
runHookGate().then(() => process.exit(0)).catch((err) => {
|
|
528
543
|
appendHookActivity({
|
|
529
544
|
event: 'PostToolUse',
|
|
530
545
|
script: 'secret-redact',
|
|
@@ -36,22 +36,24 @@ export function analyze(input) {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
+
return;
|
|
49
51
|
}
|
|
50
52
|
recordGateSuccess(GATE_NAME);
|
|
51
53
|
|
|
52
54
|
const { block, reason } = analyze({
|
|
53
|
-
toolName:
|
|
54
|
-
filePaths: editedPaths(
|
|
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:
|
|
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
|
-
|
|
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
|
@@ -24,6 +24,8 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync
|
|
|
24
24
|
import { homedir } from 'node:os';
|
|
25
25
|
import { dirname, join } from 'node:path';
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { decisionEnvelope } from './scripts/hook-run.mjs';
|
|
28
|
+
import { hookGate } from './scripts/hook-policy.mjs';
|
|
27
29
|
|
|
28
30
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
29
31
|
const PACKAGE_JSON = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8'));
|
|
@@ -44,10 +46,14 @@ export function nodeBin() {
|
|
|
44
46
|
// Kilo tool names → the Claude vocabulary the shared gates already understand.
|
|
45
47
|
const SHELL_TOOLS = new Set(['bash', 'shell', 'powershell']);
|
|
46
48
|
const EDIT_TOOLS = new Set(['write', 'edit', 'patch', 'apply_patch']);
|
|
49
|
+
// #4118: only the tools the redactor can actually MASK. secret-redact.mjs replaces output through
|
|
50
|
+
// `updatedToolOutput`, which the harness offers for the shell/search family and nothing else — for
|
|
51
|
+
// write/edit/patch/read it returned `redactable: false` and printed a warning, so those entries bought a
|
|
52
|
+
// gate spawn per tool call and never masked a byte. Kilo's own map is the matcher here (there are no
|
|
53
|
+
// manifest hook rules), so this is the same narrowing hooks/hooks.json makes for Claude.
|
|
47
54
|
const REDACT_TOOL_NAME = {
|
|
48
55
|
bash: 'Bash', shell: 'Bash', powershell: 'Bash',
|
|
49
|
-
|
|
50
|
-
read: 'Read', grep: 'Grep', glob: 'Glob',
|
|
56
|
+
grep: 'Grep', glob: 'Glob',
|
|
51
57
|
};
|
|
52
58
|
|
|
53
59
|
/** Idempotent first-run provisioning of the non-plugin payload (~/.kilo/{skills,command,agent}) behind
|
|
@@ -120,10 +126,22 @@ export function runGate(gate, payload) {
|
|
|
120
126
|
return { denied: false, reason: '', stdout: '', error: String(err && err.message) };
|
|
121
127
|
}
|
|
122
128
|
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
123
|
-
|
|
124
|
-
|
|
129
|
+
// #4018: elect the envelope with the RUNNER's own helper, never "the first line beginning with `{`".
|
|
130
|
+
// That weaker rule is exactly what #4016 measured failing OPEN on vault-edit: a stray line that is
|
|
131
|
+
// itself JSON wins over the real deny, and a stray write with no trailing newline merges with the
|
|
132
|
+
// envelope so nothing matches at all. It was safe here only because hook-run.mjs happens to emit one
|
|
133
|
+
// envelope or nothing — an invariant held by a different file and unasserted at this boundary.
|
|
134
|
+
const { envelope, malformed } = decisionEnvelope(stdout);
|
|
135
|
+
if (malformed) {
|
|
136
|
+
// The runner treats an unreducible stdout as a gate fault, i.e. fail-closed on the gates declared
|
|
137
|
+
// that way. Kilo is the surface where `permissionDecision` is honoured, so it must not be the one
|
|
138
|
+
// host that answers "allow" to a stream it could not read.
|
|
139
|
+
if (hookGate(gate).failure === 'closed') {
|
|
140
|
+
return { denied: true, reason: `${gate} produced no readable decision — failing closed`, stdout };
|
|
141
|
+
}
|
|
142
|
+
} else if (envelope) {
|
|
125
143
|
try {
|
|
126
|
-
const decision = JSON.parse(
|
|
144
|
+
const decision = JSON.parse(envelope).hookSpecificOutput;
|
|
127
145
|
if (decision?.permissionDecision === 'deny') {
|
|
128
146
|
return { denied: true, reason: decision.permissionDecisionReason || `${gate} denied the tool call`, stdout };
|
|
129
147
|
}
|
|
@@ -162,10 +180,12 @@ function editToolInput(args) {
|
|
|
162
180
|
/** The redactor's stdout is `{"hookSpecificOutput":{"updatedToolOutput": <redacted>}}` when it changed
|
|
163
181
|
* something, and nothing when clean. Returns the rewrite, or undefined when there is none. */
|
|
164
182
|
function readRedactorRewrite(stdout) {
|
|
165
|
-
|
|
166
|
-
|
|
183
|
+
// #4018: same election rule as runGate. `secret-output` is fail-OPEN, so an unreducible stream means
|
|
184
|
+
// "no rewrite" — the original output stands and the redactor's own stderr alarm stays the signal.
|
|
185
|
+
const { envelope, malformed } = decisionEnvelope(String(stdout ?? ''));
|
|
186
|
+
if (malformed || !envelope) return undefined;
|
|
167
187
|
try {
|
|
168
|
-
const updated = JSON.parse(
|
|
188
|
+
const updated = JSON.parse(envelope).hookSpecificOutput?.updatedToolOutput;
|
|
169
189
|
return typeof updated === 'string' || updated !== undefined ? updated : undefined;
|
|
170
190
|
} catch {
|
|
171
191
|
return undefined;
|
|
@@ -207,7 +227,8 @@ export default {
|
|
|
207
227
|
}
|
|
208
228
|
},
|
|
209
229
|
// PostToolUse equivalent with a WORKING rewrite channel: secret-redact.mjs's updatedToolOutput is
|
|
210
|
-
// applied to output.output.
|
|
230
|
+
// applied to output.output. A tool absent from REDACT_TOOL_NAME is one the redactor cannot mask,
|
|
231
|
+
// and is skipped rather than gated for a warning nobody could act on (#4118).
|
|
211
232
|
'tool.execute.after': async (input, output) => {
|
|
212
233
|
const tool = typeof input.tool === 'string' ? input.tool : '';
|
|
213
234
|
const translated = REDACT_TOOL_NAME[tool];
|