@mutmutco/claude-plugin 3.131.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/.claude-plugin/plugin.json +11 -0
- package/.pi-plugin/package.json +23 -0
- package/bin/mmi-cli +6 -0
- package/bin/mmi-cli.cmd +3 -0
- package/bin/mmi-hook +2 -0
- package/bin/mmi-hook-console.cmd +10 -0
- package/bin/mmi-hook.exe +0 -0
- package/hooks/hooks.json +38 -0
- package/package.json +22 -0
- package/scripts/command-ladder-core.mjs +334 -0
- package/scripts/command-ladder-gate.mjs +126 -0
- package/scripts/deny-gate-crash.mjs +179 -0
- package/scripts/edit-tool-paths.mjs +113 -0
- package/scripts/env-write-lint.mjs +137 -0
- package/scripts/hook-io.mjs +22 -0
- package/scripts/hook-policy.mjs +73 -0
- package/scripts/hook-run.mjs +437 -0
- package/scripts/hook-trace.mjs +151 -0
- package/scripts/pretooluse-shell-gates.mjs +424 -0
- package/scripts/secret-echo-lint.mjs +177 -0
- package/scripts/secret-redact.mjs +552 -0
- package/scripts/throttle-core.mjs +324 -0
- package/scripts/validate-hook.mjs +156 -0
- package/scripts/vault-edit-gate.mjs +94 -0
- package/skills/bootstrap/SKILL.md +550 -0
- package/skills/bootstrap/seeds/Dockerfile.template +30 -0
- package/skills/bootstrap/seeds/README.template.md +37 -0
- package/skills/bootstrap/seeds/architecture.template.md +34 -0
- package/skills/bootstrap/seeds/decisions-readme.template.md +45 -0
- package/skills/bootstrap/seeds/docker-compose.template.yml +26 -0
- package/skills/bootstrap/seeds/gate.template.yml +85 -0
- package/skills/bootstrap/seeds/google-login.template.md +33 -0
- package/skills/bootstrap/seeds/manifest.json +26 -0
- package/skills/bootstrap/seeds/mmi-product-required-checks.template.json +23 -0
- package/skills/browser-automation/SKILL.md +95 -0
- package/skills/epic/SKILL.md +104 -0
- package/skills/hotfix/SKILL.md +165 -0
- package/skills/mmi/SKILL.md +404 -0
- package/skills/mmi-doctor/SKILL.md +63 -0
- package/skills/onboard/SKILL.md +85 -0
- package/skills/rcand/SKILL.md +208 -0
- package/skills/release/SKILL.md +599 -0
- package/skills/resume/SKILL.md +90 -0
- package/skills/secrets/SKILL.md +159 -0
- package/skills/stage/SKILL.md +153 -0
- package/skills/worktree/SKILL.md +151 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Shared hook process core. Every active host calls this runner with only its surface token and a
|
|
3
|
+
// policy gate id. Host-specific event/tool matching stays in the adapter; policy and failure behavior
|
|
4
|
+
// stay here.
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
|
+
import { hookGate, hookSurface } from './hook-policy.mjs';
|
|
11
|
+
|
|
12
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
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
|
+
|
|
59
|
+
export function parseHookArgv(argv) {
|
|
60
|
+
const parsed = { surface: '', gate: '' };
|
|
61
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
62
|
+
if (argv[index] === '--surface') parsed.surface = argv[++index] ?? '';
|
|
63
|
+
else if (argv[index] === '--gate') parsed.gate = argv[++index] ?? '';
|
|
64
|
+
}
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function pluginRoot(surfaceToken, env = process.env, here = HERE) {
|
|
69
|
+
const surface = hookSurface(surfaceToken);
|
|
70
|
+
for (const name of surface.rootEnv) {
|
|
71
|
+
const candidate = env[name];
|
|
72
|
+
if (candidate && existsSync(join(candidate, 'scripts', 'hook-policy.mjs'))) return resolve(candidate);
|
|
73
|
+
}
|
|
74
|
+
return resolve(here, '..');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function payloadMeta(input) {
|
|
78
|
+
try {
|
|
79
|
+
const payload = JSON.parse(Buffer.from(input).toString('utf8'));
|
|
80
|
+
return {
|
|
81
|
+
sessionId: typeof payload?.session_id === 'string' ? payload.session_id : '',
|
|
82
|
+
cwd: typeof payload?.cwd === 'string' ? payload.cwd : '',
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
return { sessionId: '', cwd: '' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
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
|
+
|
|
96
|
+
function normalizeInput(surface, gate, input) {
|
|
97
|
+
const codexShell = surface === 'codex' && gate === 'secret-output';
|
|
98
|
+
if (surface !== 'cursor' && !codexShell) return input;
|
|
99
|
+
try {
|
|
100
|
+
const payload = JSON.parse(Buffer.from(input).toString('utf8'));
|
|
101
|
+
if (!payload || typeof payload !== 'object') return input;
|
|
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;
|
|
108
|
+
payload.tool_name = process.platform === 'win32' ? 'PowerShell' : 'Bash';
|
|
109
|
+
}
|
|
110
|
+
if (surface !== 'cursor') return Buffer.from(JSON.stringify(payload));
|
|
111
|
+
if (!payload.session_id && typeof payload.conversation_id === 'string') {
|
|
112
|
+
payload.session_id = payload.conversation_id;
|
|
113
|
+
}
|
|
114
|
+
if (gate === 'secret-output' && payload.tool_response == null && payload.tool_output != null) {
|
|
115
|
+
payload.tool_response = payload.tool_output;
|
|
116
|
+
}
|
|
117
|
+
return Buffer.from(JSON.stringify(payload));
|
|
118
|
+
} catch {
|
|
119
|
+
return input;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
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
|
+
|
|
160
|
+
function adaptOutput(surface, stdout) {
|
|
161
|
+
if (surface !== 'cursor') return stdout;
|
|
162
|
+
// Jerv-PowerTools #4751: cursor's preToolUse entries carry failClosed:true, and cursor reads
|
|
163
|
+
// EMPTY stdout as a hook FAILURE — so the gates' clean-allow contract (exit 0, no envelope)
|
|
164
|
+
// denied every Shell and Write on the surface. `{}` is cursor's documented intentional no-op
|
|
165
|
+
// (staff guidance in the fail-closed report thread), and on cursor only `deny` overrides the
|
|
166
|
+
// host's own permission flow — so this defers exactly like empty stdout does on other surfaces.
|
|
167
|
+
if (!stdout.trim()) return '{}\n';
|
|
168
|
+
try {
|
|
169
|
+
const payload = JSON.parse(stdout);
|
|
170
|
+
const decision = payload?.hookSpecificOutput;
|
|
171
|
+
// #4331: a gate that DECIDES anything other than deny must still leave cursor a parseable envelope.
|
|
172
|
+
// #4300 fixed the no-envelope path and stopped here, so an explicit `allow`/`ask` envelope still
|
|
173
|
+
// adapted to EMPTY stdout — the exact byte stream cursor's failClosed reader grades as a hook
|
|
174
|
+
// failure, denying the call the gate had just passed. Same no-op as the empty case: `{}` is
|
|
175
|
+
// cursor's documented "no decision, defer to the host". It must NOT become `permission: 'allow'`
|
|
176
|
+
// — on cursor only an explicit decision overrides the host's own permission flow, so that would
|
|
177
|
+
// auto-approve calls the user would otherwise be prompted for. Deny stays byte-identical below.
|
|
178
|
+
if (decision?.permissionDecision !== 'deny') return '{}\n';
|
|
179
|
+
const reason = decision.permissionDecisionReason || 'MMI policy denied this tool call.';
|
|
180
|
+
return `${JSON.stringify({ permission: 'deny', user_message: reason, agent_message: reason })}\n`;
|
|
181
|
+
} catch {
|
|
182
|
+
return stdout;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function childEnv(surface, input, env) {
|
|
187
|
+
const meta = payloadMeta(input);
|
|
188
|
+
return {
|
|
189
|
+
...env,
|
|
190
|
+
MMI_HOOK_SURFACE: surface,
|
|
191
|
+
...(meta.sessionId && !env.MMI_GATE_SESSION_ID ? { MMI_GATE_SESSION_ID: meta.sessionId } : {}),
|
|
192
|
+
...(meta.cwd && !env.MMI_HOOK_ACTIVITY_CWD ? { MMI_HOOK_ACTIVITY_CWD: meta.cwd } : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function inlineDeny(surface, gate, root, fault = `its fail-closed fallback is missing from the plugin install at ${root}`) {
|
|
197
|
+
return `${JSON.stringify({
|
|
198
|
+
hookSpecificOutput: {
|
|
199
|
+
hookEventName: 'PreToolUse',
|
|
200
|
+
permissionDecision: 'deny',
|
|
201
|
+
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.`,
|
|
202
|
+
},
|
|
203
|
+
})}\n`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function invoke(target, args, input, env, timeoutMs) {
|
|
207
|
+
const result = spawnSync(process.execPath, [target, ...args], {
|
|
208
|
+
input,
|
|
209
|
+
encoding: 'utf8',
|
|
210
|
+
env,
|
|
211
|
+
timeout: timeoutMs,
|
|
212
|
+
windowsHide: true,
|
|
213
|
+
});
|
|
214
|
+
return {
|
|
215
|
+
status: result.status ?? 1,
|
|
216
|
+
stdout: typeof result.stdout === 'string' ? result.stdout : '',
|
|
217
|
+
stderr: typeof result.stderr === 'string' ? result.stderr : '',
|
|
218
|
+
error: result.error,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Point process.env at the gate's effective env for one in-process run, then put it back. */
|
|
223
|
+
function applyProcessEnv(env) {
|
|
224
|
+
if (env === process.env) return () => {};
|
|
225
|
+
const prior = { ...process.env };
|
|
226
|
+
const sync = (target) => {
|
|
227
|
+
for (const key of Object.keys(process.env)) if (!(key in target)) delete process.env[key];
|
|
228
|
+
for (const [key, value] of Object.entries(target)) {
|
|
229
|
+
if (value === undefined) delete process.env[key];
|
|
230
|
+
else if (process.env[key] !== value) process.env[key] = value;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
sync(env);
|
|
234
|
+
return () => sync(prior);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Buffer everything the gate writes, the way the child's pipes used to. */
|
|
238
|
+
function captureStdio() {
|
|
239
|
+
const chunks = { stdout: [], stderr: [] };
|
|
240
|
+
const prior = { stdout: process.stdout.write, stderr: process.stderr.write };
|
|
241
|
+
const sink = (name) => function write(chunk, encoding, callback) {
|
|
242
|
+
chunks[name].push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
|
|
243
|
+
const done = typeof encoding === 'function' ? encoding : callback;
|
|
244
|
+
if (typeof done === 'function') done();
|
|
245
|
+
return true;
|
|
246
|
+
};
|
|
247
|
+
process.stdout.write = sink('stdout');
|
|
248
|
+
process.stderr.write = sink('stderr');
|
|
249
|
+
return () => {
|
|
250
|
+
process.stdout.write = prior.stdout;
|
|
251
|
+
process.stderr.write = prior.stderr;
|
|
252
|
+
return { stdout: chunks.stdout.join(''), stderr: chunks.stderr.join('') };
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The child's `timeout` option: a gate that never settles must not hold the turn open. The timer is
|
|
257
|
+
* deliberately NOT unref'd — an unref'd timer lets the event loop drain when the gate settles nothing
|
|
258
|
+
* and holds no handle, and the runner then exits 0 with no output, which is fail-OPEN. clearTimeout in
|
|
259
|
+
* the `finally` is what keeps it from holding the loop open on the ordinary path. */
|
|
260
|
+
function withTimeout(promise, timeoutMs) {
|
|
261
|
+
if (!timeoutMs) return promise;
|
|
262
|
+
return new Promise((settle, fail) => {
|
|
263
|
+
const timer = setTimeout(() => fail(new Error(`gate exceeded ${timeoutMs}ms`)), timeoutMs);
|
|
264
|
+
promise.then(settle, fail).finally(() => clearTimeout(timer));
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The child process absorbed a gate's ASYNC faults — a detached rejection or a throw from a timer
|
|
270
|
+
* crashed the child, the runner read status 1, and the fail-closed fallback ran. In-process those two
|
|
271
|
+
* faults reach the runner's own default handler and kill it: measured against 1326c2db, a detached
|
|
272
|
+
* rejection inside vault-edit-gate left the runner at exit 1 with NO stdout, and a hook that exits 1
|
|
273
|
+
* is a non-blocking error on Claude Code, so the tool call proceeds unguarded. `await` cannot see
|
|
274
|
+
* either fault, so the only place to catch them is the process handlers, for the length of the run.
|
|
275
|
+
* Prior handlers are saved and restored: a host that embeds runPolicyGate keeps its own.
|
|
276
|
+
*/
|
|
277
|
+
function trapAsyncFaults() {
|
|
278
|
+
let onFault = () => {};
|
|
279
|
+
const fault = new Promise((_, fail) => {
|
|
280
|
+
onFault = (err) => fail(err instanceof Error ? err : new Error(String(err)));
|
|
281
|
+
});
|
|
282
|
+
// A fault that lands after the gate already settled has nothing racing it; swallow it here rather
|
|
283
|
+
// than let the rejection this function created become the next unhandled rejection.
|
|
284
|
+
fault.catch(() => {});
|
|
285
|
+
const prior = {
|
|
286
|
+
unhandledRejection: process.rawListeners('unhandledRejection'),
|
|
287
|
+
uncaughtException: process.rawListeners('uncaughtException'),
|
|
288
|
+
};
|
|
289
|
+
for (const event of Object.keys(prior)) {
|
|
290
|
+
process.removeAllListeners(event);
|
|
291
|
+
process.on(event, onFault);
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
fault,
|
|
295
|
+
release() {
|
|
296
|
+
for (const [event, listeners] of Object.entries(prior)) {
|
|
297
|
+
process.removeAllListeners(event);
|
|
298
|
+
for (const listener of listeners) process.on(event, listener);
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function invokeInProcess(target, input, env, timeoutMs) {
|
|
305
|
+
const restoreEnv = applyProcessEnv(env);
|
|
306
|
+
const drain = captureStdio();
|
|
307
|
+
const faults = trapAsyncFaults();
|
|
308
|
+
const priorExit = process.exit;
|
|
309
|
+
process.exit = (code) => {
|
|
310
|
+
throw new GateExit(typeof code === 'number' ? code : 0);
|
|
311
|
+
};
|
|
312
|
+
let status = 0;
|
|
313
|
+
let error;
|
|
314
|
+
let legacy = false;
|
|
315
|
+
try {
|
|
316
|
+
const gateModule = await Promise.race([
|
|
317
|
+
faults.fault,
|
|
318
|
+
import(`${pathToFileURL(target).href}?mmi-hook-fire=${(fireCount += 1)}`),
|
|
319
|
+
]);
|
|
320
|
+
// A gate from before #4118 is still a runnable script with its own self-run guard. Treating the
|
|
321
|
+
// missing export as a crash made every Edit/Write/Bash deny under the version skew the plugin's
|
|
322
|
+
// own `upgrade.mechanism: reinstall` creates (installed cache older than the tree); the spawn is
|
|
323
|
+
// the compatibility path, not a fallback for failure.
|
|
324
|
+
if (typeof gateModule.runHookGate !== 'function') legacy = true;
|
|
325
|
+
else await withTimeout(Promise.race([faults.fault, gateModule.runHookGate({ input })]), timeoutMs);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (err instanceof GateExit) status = err.code;
|
|
328
|
+
else {
|
|
329
|
+
status = 1;
|
|
330
|
+
error = err;
|
|
331
|
+
}
|
|
332
|
+
} finally {
|
|
333
|
+
process.exit = priorExit;
|
|
334
|
+
faults.release();
|
|
335
|
+
}
|
|
336
|
+
const { stdout, stderr } = drain();
|
|
337
|
+
restoreEnv();
|
|
338
|
+
return { status, stdout, stderr, error, legacy };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function runPolicyGate({ surface, gate: gateId, input = Buffer.alloc(0), env = process.env, here = HERE }) {
|
|
342
|
+
const gate = hookGate(gateId);
|
|
343
|
+
hookSurface(surface);
|
|
344
|
+
const root = pluginRoot(surface, env, here);
|
|
345
|
+
const target = join(root, 'scripts', gate.script);
|
|
346
|
+
const normalizedInput = normalizeInput(surface, gateId, stripUtf8Bom(input));
|
|
347
|
+
const effectiveEnv = childEnv(surface, normalizedInput, env);
|
|
348
|
+
let result;
|
|
349
|
+
|
|
350
|
+
if (!existsSync(target)) {
|
|
351
|
+
result = { status: 1, stdout: '', stderr: `[mmi-${surface}-hook] ${gate.script} not found under ${root}\n` };
|
|
352
|
+
} else {
|
|
353
|
+
result = await invokeInProcess(target, normalizedInput, effectiveEnv, gate.timeoutMs);
|
|
354
|
+
if (result.legacy) result = invoke(target, [], normalizedInput, effectiveEnv, gate.timeoutMs);
|
|
355
|
+
if (result.status === 0 || result.status === 2) {
|
|
356
|
+
const { envelope, stray, malformed } = decisionEnvelope(result.stdout);
|
|
357
|
+
if (!malformed) {
|
|
358
|
+
return { ...result, stdout: adaptOutput(surface, envelope), stderr: `${result.stderr}${stray}` };
|
|
359
|
+
}
|
|
360
|
+
// #4016: the gate exited cleanly but its stdout does not reduce to one decision. The runner cannot
|
|
361
|
+
// tell "allowed, then printed junk" from "denied, and the deny got mangled" — the second is exactly
|
|
362
|
+
// what an unterminated stray write produces — so this is a gate fault, not an allow.
|
|
363
|
+
result = { ...result, status: 1, stdout: '', stderr: `${result.stderr}${stray}` };
|
|
364
|
+
result.stderr += `[mmi-${surface}-hook] ${gate.script} wrote stdout with no single decision envelope\n`;
|
|
365
|
+
} else {
|
|
366
|
+
result.stderr += `[mmi-${surface}-hook] ${gate.script} exited ${result.status}`;
|
|
367
|
+
result.stderr += result.error ? `: ${result.error.message}\n` : '\n';
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (gate.failure === 'open') return { ...result, status: 0 };
|
|
372
|
+
|
|
373
|
+
// The fallback deliberately keeps its own process. Whatever just failed did so INSIDE this one, so a
|
|
374
|
+
// fail-closed deny must not be computed by the same module registry and globals that produced it.
|
|
375
|
+
const crashGate = join(root, 'scripts', 'deny-gate-crash.mjs');
|
|
376
|
+
if (!existsSync(crashGate)) {
|
|
377
|
+
return { status: 0, stdout: inlineDeny(surface, gate.fallbackGate, root), stderr: result.stderr };
|
|
378
|
+
}
|
|
379
|
+
const fallback = invoke(crashGate, [gate.fallbackGate], normalizedInput, effectiveEnv, gate.timeoutMs);
|
|
380
|
+
const { envelope, stray, malformed } = decisionEnvelope(fallback.stdout);
|
|
381
|
+
// An EMPTY fallback stdout is intentional (breaker open, or MMI_GATES_FAIL_OPEN) and stays empty;
|
|
382
|
+
// `malformed` is the other case — the fallback itself emitted something unreadable — and that must not
|
|
383
|
+
// become an allow either (#4016).
|
|
384
|
+
if (malformed) {
|
|
385
|
+
return {
|
|
386
|
+
status: 0,
|
|
387
|
+
stdout: inlineDeny(surface, gate.fallbackGate, root, 'its fail-closed fallback wrote no readable decision'),
|
|
388
|
+
stderr: `${result.stderr}${fallback.stderr}${stray}`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
...fallback,
|
|
393
|
+
stdout: adaptOutput(surface, envelope),
|
|
394
|
+
stderr: `${result.stderr}${fallback.stderr}${stray}`,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** #4878: Cursor's Windows payload transport pipes the hook payload through PowerShell with
|
|
399
|
+
* `$OutputEncoding = [System.Text.Encoding]::UTF8` — the BOM-EMITTING encoder — so the JSON arrives
|
|
400
|
+
* prefixed with EF BB BF. JSON.parse refuses a BOM, every parse in this chain (normalizeInput,
|
|
401
|
+
* payloadMeta, the gate scripts) came back empty, and the fail-open no-payload arm (#2992) silently
|
|
402
|
+
* waved the call through. Stripped once at the gate entry so every consumer parses the same bytes. */
|
|
403
|
+
function stripUtf8Bom(input) {
|
|
404
|
+
const buffer = Buffer.from(input);
|
|
405
|
+
return buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf ? buffer.subarray(3) : buffer;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function readStdin() {
|
|
409
|
+
try {
|
|
410
|
+
return readFileSync(0);
|
|
411
|
+
} catch {
|
|
412
|
+
return Buffer.alloc(0);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
417
|
+
const { surface, gate } = parseHookArgv(argv);
|
|
418
|
+
let result;
|
|
419
|
+
try {
|
|
420
|
+
result = await runPolicyGate({ surface, gate, input: readStdin() });
|
|
421
|
+
} catch (error) {
|
|
422
|
+
process.stderr.write(`[mmi-hook] ${error.message}\n`);
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
426
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
427
|
+
process.exit(result.status);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Codex may import this file from a replacement cache while process.argv[1] still names the deleted
|
|
431
|
+
// cache path captured when the session loaded. Match the stable relative suffix, not URL identity.
|
|
432
|
+
if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('scripts/hook-run.mjs')) {
|
|
433
|
+
main().catch((error) => {
|
|
434
|
+
process.stderr.write(`[mmi-hook] ${error && error.message}\n`);
|
|
435
|
+
process.exit(1);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Uniform greppable hook-activity log (#2599). Every hook invocation appends ONE line so a
|
|
2
|
+
// user/agent can see "what just ran" (ran / healed X / denied Y / failed Z) and a recurring silent
|
|
3
|
+
// failure is grep-discoverable. This generalizes the per-hook trace pattern that lived only in
|
|
4
|
+
// shell-redirect-lint.mjs into one shared mechanism every hook reuses (epic #2589 transparency lane).
|
|
5
|
+
//
|
|
6
|
+
// Format: one JSONL line per invocation with stable keys —
|
|
7
|
+
// { "ts","surface","event","script","outcome","action", ... }
|
|
8
|
+
// outcome vocabulary: "ran" (pass-through) | "deny" (blocked) | "heal" (transformed output) |
|
|
9
|
+
// "observe" (would-block, observe mode) | "bypass" (escape hatch used) | "failed" (hook errored).
|
|
10
|
+
//
|
|
11
|
+
// Path: repo-local gitignored state (.git/mmi-runtime/hooks/activity.jsonl — never tracked); falls
|
|
12
|
+
// back to a per-cwd tmpdir path outside a repo so the trace still works anywhere. Fail-soft always:
|
|
13
|
+
// the trace must never crash a turn or block a tool call.
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_SURFACE = 'claude';
|
|
20
|
+
|
|
21
|
+
// #3642: four hooks append on every tool call and nothing ever pruned, so the trace reached 17 MB on
|
|
22
|
+
// the Hub checkout — in `.git/`, so invisible to `git status` and to every worktree/scratch sweep.
|
|
23
|
+
// Trim on append: past MAX_BYTES, keep the last KEEP_BYTES. KEEP sits above the 512 KB tail the
|
|
24
|
+
// doctor's redactor-liveness probe reads (cli/src/hook-activity.ts), so trimming never shortens the
|
|
25
|
+
// window a reader sees. MAX is high enough that the read-rewrite happens once every few weeks.
|
|
26
|
+
// The CLI mirror in cli/src/hook-activity.ts deliberately has no copy of this: with the SessionStart
|
|
27
|
+
// hook retired (#3630) it writes at most a row a session, and the bound is a property of the FILE —
|
|
28
|
+
// whoever appends next enforces it, and that is effectively always this writer.
|
|
29
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
30
|
+
const KEEP_BYTES = 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Drop the oldest rows once the log passes MAX_BYTES, keeping the last KEEP_BYTES from the first
|
|
34
|
+
* line boundary (a byte-slice would leave a torn row at the head). Fail-soft: a trim that cannot
|
|
35
|
+
* run leaves the log as it is rather than losing the append behind it.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} path
|
|
38
|
+
* @returns {void}
|
|
39
|
+
*/
|
|
40
|
+
function trimIfOversized(path) {
|
|
41
|
+
try {
|
|
42
|
+
if (statSync(path).size <= MAX_BYTES) return;
|
|
43
|
+
const tail = readFileSync(path, 'utf8').slice(-KEEP_BYTES);
|
|
44
|
+
const firstBreak = tail.indexOf('\n');
|
|
45
|
+
writeFileSync(path, firstBreak === -1 ? '' : tail.slice(firstBreak + 1), 'utf8');
|
|
46
|
+
} catch {
|
|
47
|
+
/* no log yet, or an unreadable one — the append below is still worth attempting */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fallbackPath(cwd) {
|
|
52
|
+
const hash = createHash('sha256').update(resolve(cwd)).digest('hex').slice(0, 16);
|
|
53
|
+
return join(tmpdir(), 'mmi-cli', hash, 'hooks', 'activity.jsonl');
|
|
54
|
+
}
|
|
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
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the hook-activity log path for a working directory.
|
|
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
|
|
110
|
+
* outside a repo. Env overrides: `MMI_HOOK_ACTIVITY_LOG` (full path, wins outright) and
|
|
111
|
+
* `MMI_HOOK_ACTIVITY_CWD` (resolve against this directory instead of the hook process's own cwd —
|
|
112
|
+
* the Kimi launcher stamps it from the hook payload because Kimi runs plugin hooks with cwd = the
|
|
113
|
+
* plugin root, which is not a repo).
|
|
114
|
+
*
|
|
115
|
+
* @param {string} [cwd]
|
|
116
|
+
* @param {Record<string,string|undefined>} [env]
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function activityLogPath(cwd = process.cwd(), env = process.env) {
|
|
120
|
+
if (env.MMI_HOOK_ACTIVITY_LOG) return env.MMI_HOOK_ACTIVITY_LOG;
|
|
121
|
+
const effectiveCwd = env.MMI_HOOK_ACTIVITY_CWD || cwd;
|
|
122
|
+
const dir = gitDir(effectiveCwd, env);
|
|
123
|
+
if (dir && existsSync(dir)) return join(dir, 'mmi-runtime', 'hooks', 'activity.jsonl');
|
|
124
|
+
return fallbackPath(effectiveCwd);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Append one hook-activity line. Fail-soft: any IO error is swallowed (the trace must never crash a
|
|
129
|
+
* turn). `ts` is stamped and `surface` defaulted when absent.
|
|
130
|
+
*
|
|
131
|
+
* @param {{ surface?: string, event: string, script: string, outcome: string, action?: string, [k: string]: unknown }} entry
|
|
132
|
+
* @param {{ cwd?: string, env?: Record<string,string|undefined> }} [opts]
|
|
133
|
+
* @returns {void}
|
|
134
|
+
*/
|
|
135
|
+
export function appendHookActivity(entry, opts = {}) {
|
|
136
|
+
try {
|
|
137
|
+
const path = activityLogPath(opts.cwd, opts.env);
|
|
138
|
+
const line = {
|
|
139
|
+
ts: new Date().toISOString(),
|
|
140
|
+
// #3563: the same scripts now run under Codex too, and a Codex deny logged as `surface: "claude"`
|
|
141
|
+
// sends anyone reading the trace to the wrong manifest. The Codex launcher sets MMI_HOOK_SURFACE.
|
|
142
|
+
surface: (opts.env ?? process.env).MMI_HOOK_SURFACE || DEFAULT_SURFACE,
|
|
143
|
+
...entry,
|
|
144
|
+
};
|
|
145
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
146
|
+
trimIfOversized(path);
|
|
147
|
+
appendFileSync(path, `${JSON.stringify(line)}\n`, 'utf8');
|
|
148
|
+
} catch {
|
|
149
|
+
/* fail-soft — never crash a turn for the trace */
|
|
150
|
+
}
|
|
151
|
+
}
|