@mutmutco/cursor-plugin 4.2.7 → 4.3.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.
Files changed (35) hide show
  1. package/.cursor-plugin/plugin.json +2 -3
  2. package/package.json +1 -1
  3. package/scripts/edit-tool-paths.mjs +4 -4
  4. package/skills/bootstrap/SKILL.md +2 -2
  5. package/skills/bootstrap/seeds/README.template.md +2 -2
  6. package/skills/bootstrap/seeds/gate.template.yml +5 -5
  7. package/skills/bootstrap/seeds/manifest.json +1 -0
  8. package/skills/bootstrap/seeds/test-policy.template.json +4 -0
  9. package/skills/hotfix/SKILL.md +1 -1
  10. package/skills/rcand/SKILL.md +1 -1
  11. package/skills/release/SKILL.md +34 -9
  12. package/skills/secrets/SKILL.md +1 -1
  13. package/skills/stage/SKILL.md +1 -1
  14. package/bin/mmi-hook +0 -2
  15. package/bin/mmi-hook-console.cmd +0 -16
  16. package/bin/mmi-hook.exe +0 -0
  17. package/hooks/cursor-hooks.json +0 -26
  18. package/scripts/command-ladder-core.mjs +0 -339
  19. package/scripts/command-ladder-gate.mjs +0 -126
  20. package/scripts/deny-gate-crash.mjs +0 -179
  21. package/scripts/env-write-lint.mjs +0 -146
  22. package/scripts/hook-io.mjs +0 -22
  23. package/scripts/hook-policy.mjs +0 -78
  24. package/scripts/hook-run.mjs +0 -434
  25. package/scripts/hook-trace.mjs +0 -151
  26. package/scripts/pretooluse-shell-gates.mjs +0 -720
  27. package/scripts/secret-echo-lint.mjs +0 -177
  28. package/scripts/test-command-policy-core.mjs +0 -294
  29. package/scripts/throttle-core.mjs +0 -332
  30. package/scripts/vault-edit-gate.mjs +0 -94
  31. package/skills/browser-automation/SKILL.md +0 -122
  32. package/skills/mmi/SKILL.md +0 -544
  33. package/skills/mmi-doctor/SKILL.md +0 -66
  34. package/skills/mmi-resume/SKILL.md +0 -123
  35. package/skills/onboard/SKILL.md +0 -72
@@ -1,720 +0,0 @@
1
- // Consolidated PreToolUse shell gates (#2600): one Node boot for the Bash/PowerShell hook pair.
2
- // #3630 trimmed bundle: env-write-lint first, secret-echo second (Codex-only by default — Claude's
3
- // PostToolUse redaction masks an echoed value, Codex cannot), the narrow Windows operator-input guard,
4
- // then command-ladder. The broad shell-dialect advisory remains retired.
5
- import { execFileSync } from 'node:child_process';
6
- import { existsSync, readFileSync } from 'node:fs';
7
- import { isAbsolute, resolve } from 'node:path';
8
- import { analyze as analyzeSecretEcho } from './secret-echo-lint.mjs';
9
- import { analyze as analyzeEnvWrite } from './env-write-lint.mjs';
10
- import { decide as decideCommandLadder, matchedVerb } from './command-ladder-gate.mjs';
11
- import { handleGateCrash, handleMissingHookInput, recordGateSuccess } from './deny-gate-crash.mjs';
12
- import { readHookInput } from './hook-io.mjs';
13
- import { appendHookActivity } from './hook-trace.mjs';
14
- import { evaluateTestCommandPolicy, isShallowRepository, isTestPath, readOverride } from './test-command-policy-core.mjs';
15
-
16
- // Secret echoes are blocked before execution on every active host.
17
- const SECRET_ECHO_MODE = process.env.MMI_SECRET_ECHO_LINT || 'block';
18
- const ENV_WRITE_MODE = process.env.MMI_ENV_WRITE_MODE ?? 'block';
19
- const WINDOWS_OPERATOR_GUARD_ON = !/^(?:0|false|no|off)$/i.test(process.env.MMI_WINDOWS_OPERATOR_GUARD ?? 'on');
20
- const GATE_NAME = 'command-ladder';
21
- const OPERATOR_GATE_NAME = 'windows-operator-input';
22
- const TEST_COMMAND_GATE_NAME = 'test-command-policy';
23
- const NUL_REDIRECT_GATE_NAME = 'nul-redirect';
24
- const MAX_SEGMENT_CHARS = 32_768;
25
-
26
- // #3121 fast-path: trivially-safe read-only commands that need no gate evaluation.
27
- // Any chaining/substitution character anywhere in the command disqualifies the fast path.
28
- const SHELL_META_CHARS = /[|&;`]|\$\(|[<>]|\|\||&&/;
29
- const FAST_PATH_COMMANDS = new Set([
30
- 'git status',
31
- 'git log',
32
- 'git diff',
33
- 'ls',
34
- 'Get-ChildItem',
35
- 'dir',
36
- ]);
37
-
38
- function preToolUseDeny(reason) {
39
- return JSON.stringify({
40
- hookSpecificOutput: {
41
- hookEventName: 'PreToolUse',
42
- permissionDecision: 'deny',
43
- permissionDecisionReason: reason,
44
- },
45
- });
46
- }
47
-
48
- /** Commands whose heredoc body IS executed, so its text must stay under analysis. Deliberately a
49
- * small allowlist matched by executable NAME: anything unrecognised counts as an interpreter and
50
- * keeps its body scanned, so an unknown consumer can never become a bypass (#5266). */
51
- const HEREDOC_DATA_CONSUMERS = new Set(['git', 'cat', 'tee', 'mmi-cli', 'jerv-cli', 'gh', 'jq', 'grep', 'sed', 'diff', 'sort', 'wc', 'head', 'tail']);
52
-
53
- /** Blank out heredoc BODIES that are data rather than script (#5266).
54
- *
55
- * Segmentation splits on `;|&` and newlines, so a heredoc body — a commit message, a PR body, an
56
- * issue report — was chopped into pseudo-segments and analysed as if it were a command. Prose that
57
- * merely QUOTED a test runner was therefore refused as an attempt to run tests, which penalised
58
- * writing accurate evidence about test behaviour: the exact opposite of what #5264 was fixing.
59
- *
60
- * Bodies are replaced with equal-length blanks, never deleted, so every offset the analysers and
61
- * the segment-ordinal reporting depend on is preserved. A body is only blanked when the command
62
- * introducing it is a known data consumer; `sh <<EOF … EOF` genuinely executes its body and stays
63
- * fully analysed. */
64
- function maskHeredocData(source) {
65
- const lines = source.split('\n');
66
- const out = [...lines];
67
- for (let i = 0; i < lines.length; i += 1) {
68
- const intro = /<<-?\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/.exec(lines[i]);
69
- if (!intro) continue;
70
- const delimiter = intro[1] ?? intro[2] ?? intro[3];
71
- // The consumer is the command the redirection attaches to — the LAST one before `<<`, not the
72
- // first on the line. `cd <dir> && git commit -F - <<EOF` is consumed by git, not by cd.
73
- const consumer = /(?:^|[;|&])\s*([^\s;|&]+)[^;|&]*$/.exec(lines[i].slice(0, intro.index));
74
- if (!HEREDOC_DATA_CONSUMERS.has(executableName(consumer?.[1]))) continue;
75
- for (let j = i + 1; j < lines.length; j += 1) {
76
- if (lines[j].trim() === delimiter) { i = j; break; }
77
- out[j] = ' '.repeat(lines[j].length);
78
- if (j === lines.length - 1) i = j;
79
- }
80
- }
81
- return out.join('\n');
82
- }
83
-
84
- /** Split only on unquoted compound-command operators. Text is retained solely for analyzers and is never surfaced. */
85
- function boundedShellSegments(command) {
86
- const source = maskHeredocData(String(command ?? ''));
87
- const segments = [];
88
- let start = 0;
89
- let quote = null;
90
- const push = (end) => {
91
- const text = source.slice(start, end).trim();
92
- if (text) segments.push({ ordinal: segments.length + 1, text: text.slice(0, MAX_SEGMENT_CHARS) });
93
- };
94
- for (let i = 0; i < source.length; i += 1) {
95
- const ch = source[i];
96
- if (quote === "'") {
97
- if (ch === "'" && source[i + 1] === "'") i += 1;
98
- else if (ch === "'") quote = null;
99
- continue;
100
- }
101
- if (quote === '"') {
102
- if (ch === '`') i += 1;
103
- else if (ch === '"') quote = null;
104
- continue;
105
- }
106
- if (ch === "'" || ch === '"') {
107
- quote = ch;
108
- continue;
109
- }
110
- if (ch === ';' || ch === '|' || ch === '&' || ch === '\r' || ch === '\n') {
111
- push(i);
112
- if ((ch === '|' || ch === '&') && source[i + 1] === ch) i += 1;
113
- if (ch === '\r' && source[i + 1] === '\n') i += 1;
114
- start = i + 1;
115
- }
116
- }
117
- push(source.length);
118
- return segments;
119
- }
120
-
121
- function locateBlockedSegment(command, analyzer, semanticClass) {
122
- for (const segment of boundedShellSegments(command)) {
123
- const result = analyzer(segment.text);
124
- if (result?.block) return { ordinal: segment.ordinal, semanticClass: semanticClass(result) };
125
- }
126
- return { ordinal: 1, semanticClass: semanticClass(null) };
127
- }
128
-
129
- function expectedProtectionReason(original, input, analyzer, semanticClass) {
130
- const located = locateBlockedSegment(input?.tool_input?.command, analyzer, semanticClass);
131
- const recovery = located.semanticClass === 'environment enumeration'
132
- ? 'Query only the required variables by explicit non-secret name, in a separate tool call.'
133
- : located.semanticClass === 'secret-value output'
134
- ? 'Use a verifier or consumer that returns status without values; when its contract requires vault injection, invoke it through `mmi-cli vault secrets use`.'
135
- : 'Use the vault-native route named above, and run any safe sibling diagnostics as separate tool calls.';
136
- return `${original} Entire compound tool call was cancelled before execution; safe sibling diagnostics did not run. `
137
- + `This is an expected safety refusal, not a tool defect. Rejected segment ${located.ordinal}: `
138
- + `${located.semanticClass}. ${recovery}`;
139
- }
140
-
141
- function tokenizePowerShell(segment) {
142
- const matches = [...segment.matchAll(/"(?:`.|[^"])*"|'(?:''|[^'])*'|[^\s'"]+/g)];
143
- if (!matches.length) return [];
144
- // Adjacent lexical pieces mean mixed quoting (`foo"bar"`); unmatched text means malformed quoting.
145
- // Both are ambiguous, so the operator guard fails open.
146
- let end = 0;
147
- for (const match of matches) {
148
- const gap = segment.slice(end, match.index);
149
- if (end > 0 && gap.length === 0) return null;
150
- if (gap.trim()) return null;
151
- end = (match.index ?? 0) + match[0].length;
152
- }
153
- if (segment.slice(end).trim()) return null;
154
- const tokens = [];
155
- for (const match of matches) {
156
- const raw = match[0];
157
- const quote = raw[0];
158
- const fullyQuoted = (quote === '"' || quote === "'") && raw.endsWith(quote);
159
- let value = fullyQuoted ? raw.slice(1, -1) : raw;
160
- if (quote === '"') value = value.replace(/`(.)/g, '$1');
161
- if (quote === "'") value = value.replace(/''/g, "'");
162
- tokens.push({
163
- value,
164
- raw,
165
- fullyDoubleQuoted: quote === '"' && fullyQuoted,
166
- });
167
- }
168
- return tokens;
169
- }
170
-
171
- function executableName(token) {
172
- return String(token ?? '').replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '';
173
- }
174
-
175
- const RG_VALUE_OPTIONS = {
176
- '-e': 'pattern', '--regexp': 'pattern', '-f': 'pattern', '--file': 'pattern',
177
- '-g': 'glob', '--glob': 'glob', '--iglob': 'glob', '-t': 'type', '--type': 'type', '--type-add': 'type',
178
- };
179
- const RG_SAFE_FLAGS = new Set((
180
- '--files --hidden --no-ignore --line-number -n --fixed-strings -F --word-regexp -w --case-sensitive -s '
181
- + '--ignore-case -i --smart-case -S --multiline -U --pcre2 -P --text -a --count -c '
182
- + '--files-with-matches -l --files-without-match --no-messages --quiet -q --json'
183
- ).split(' '));
184
-
185
- function analyzeRgWildcard(tokens) {
186
- if (!tokens?.length || !['rg', 'rg.exe'].includes(executableName(tokens[0].value))) return null;
187
- const positional = [];
188
- let explicitPattern = false;
189
- let filesMode = false;
190
- let afterOptions = false;
191
- for (let i = 1; i < tokens.length; i += 1) {
192
- const arg = tokens[i].value;
193
- if (!afterOptions && arg === '--') {
194
- afterOptions = true;
195
- continue;
196
- }
197
- if (!afterOptions) {
198
- const longEquals = arg.match(/^(--(?:regexp|file|glob|iglob|type|type-add))=(.*)$/);
199
- if (longEquals) {
200
- if (['--regexp', '--file'].includes(longEquals[1])) explicitPattern = true;
201
- continue;
202
- }
203
- const optionKind = RG_VALUE_OPTIONS[arg];
204
- if (optionKind) {
205
- if (i + 1 >= tokens.length) return null;
206
- if (optionKind === 'pattern') explicitPattern = true;
207
- i += 1;
208
- continue;
209
- }
210
- const shortAttached = arg.match(/^-(e|f|g|t)(.+)$/);
211
- if (shortAttached) {
212
- if (shortAttached[1] === 'e' || shortAttached[1] === 'f') explicitPattern = true;
213
- continue;
214
- }
215
- if (RG_SAFE_FLAGS.has(arg)) {
216
- if (arg === '--files') filesMode = true;
217
- continue;
218
- }
219
- // Unknown options may consume the next token. Shell parsing is undecidable; fail open on ambiguity.
220
- if (arg.startsWith('-')) return null;
221
- }
222
- positional.push(arg);
223
- }
224
- const paths = explicitPattern || filesMode ? positional : positional.slice(1);
225
- return paths.some((path) => /[*?\[]/.test(path))
226
- ? { reasonId: 'operator_input_rg_wildcard_path', semanticClass: 'unresolved wildcard path argument' }
227
- : null;
228
- }
229
-
230
- const SSH_VALUE_OPTIONS = new Set('-b -c -D -E -e -F -I -i -J -L -l -m -O -o -P -p -Q -R -S -W -w'.split(' '));
231
- const SSH_SAFE_FLAGS = new Set('-4 -6 -A -a -C -f -G -g -K -k -M -N -n -s -T -t -V -v -X -x -Y -y'.split(' '));
232
-
233
- function hasUnescapedPowerShellVariable(text) {
234
- for (const match of text.matchAll(/\$(?:\{)?([A-Za-z_][A-Za-z0-9_]*)/g)) {
235
- const index = match.index ?? 0;
236
- let backticks = 0;
237
- for (let i = index - 1; i >= 0 && text[i] === '`'; i -= 1) backticks += 1;
238
- if (backticks % 2 === 1) continue;
239
- if (/^\$env:/i.test(text.slice(index))) continue;
240
- return true;
241
- }
242
- return false;
243
- }
244
-
245
- function analyzeSshInterpolation(tokens) {
246
- if (!tokens?.length || !['ssh', 'ssh.exe'].includes(executableName(tokens[0].value))) return null;
247
- let hostAt = -1;
248
- for (let i = 1; i < tokens.length; i += 1) {
249
- const arg = tokens[i].value;
250
- if (arg === '--') {
251
- hostAt = i + 1;
252
- break;
253
- }
254
- if (SSH_VALUE_OPTIONS.has(arg)) {
255
- if (i + 1 >= tokens.length) return null;
256
- i += 1;
257
- continue;
258
- }
259
- if (SSH_SAFE_FLAGS.has(arg) || /^-[bcDEeFIiJLMmOoPpQRSWw].+/.test(arg)) continue;
260
- if (arg.startsWith('-')) return null;
261
- hostAt = i;
262
- break;
263
- }
264
- if (hostAt < 0 || hostAt + 1 >= tokens.length) return null;
265
- for (const token of tokens.slice(hostAt + 1)) {
266
- if (!token.fullyDoubleQuoted) continue;
267
- const remote = token.raw.slice(1, -1);
268
- if (hasUnescapedPowerShellVariable(remote)) {
269
- return { reasonId: 'operator_input_ssh_powershell_interpolation', semanticClass: 'PowerShell-interpolated remote program' };
270
- }
271
- }
272
- return null;
273
- }
274
-
275
- /**
276
- * Recognize test execution only as a shell command, never as prose in an argument. The small
277
- * command vocabulary deliberately covers the package-manager spellings agents actually emit and
278
- * the direct runners they use when no package script exists.
279
- */
280
- function testCommandInSegment(segment) {
281
- const tokens = tokenizePowerShell(segment);
282
- if (!tokens?.length) return false;
283
- const values = tokens.map((token) => token.value);
284
- let commandAt = 0;
285
- while (/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(values[commandAt] ?? '')) commandAt += 1;
286
- if (values[commandAt] === 'command') commandAt += 1;
287
- if (values[commandAt] === 'env') {
288
- commandAt += 1;
289
- while (/^(?:-[A-Za-z]+|[A-Za-z_][A-Za-z0-9_]*=.*)$/.test(values[commandAt] ?? '')) commandAt += 1;
290
- }
291
- const executable = executableName(values[commandAt]);
292
- const args = values.slice(commandAt + 1);
293
- const isTestScript = (value) => /^test(?:$|[:._-])/.test(value ?? '');
294
- const positional = (items, valueOptions = new Set()) => {
295
- const valuesOnly = [];
296
- for (let i = 0; i < items.length; i += 1) {
297
- if (valueOptions.has(items[i])) { i += 1; continue; }
298
- if (items[i].startsWith('-')) continue;
299
- valuesOnly.push(items[i]);
300
- }
301
- return valuesOnly;
302
- };
303
- const packageRunner = new Set(['npm', 'npm.cmd', 'pnpm', 'pnpm.cmd', 'yarn', 'yarn.cmd', 'bun', 'bun.exe']);
304
- if (packageRunner.has(executable)) {
305
- const words = positional(args, new Set(['--prefix', '--workspace', '-w', '--dir', '-C']));
306
- if (isTestScript(words[0]) || executableName(words[0]) === 'vitest') return true;
307
- if (words[0] === 'run') return isTestScript(words[1]);
308
- if (words[0] === 'exec' || words[0] === 'dlx') return executableName(words[1]) === 'vitest';
309
- }
310
- if (executable === 'npx' || executable === 'npx.cmd' || executable === 'pnpx' || executable === 'bunx') {
311
- return executableName(positional(args, new Set(['--package', '-p']))[0]) === 'vitest';
312
- }
313
- if (['vitest', 'vitest.cmd', 'vitest.exe', 'pytest', 'pytest.exe'].includes(executable)) return true;
314
- if (executable === 'node' && args[0] === '--test') return true;
315
- if (executable === 'python' || executable === 'python3' || executable === 'py') return args[0] === '-m' && args[1] === 'pytest';
316
- if (executable === 'cargo' || executable === 'go' || executable === 'dotnet') return args[0] === 'test';
317
- if (executable === 'mvn' || executable === 'mvnw' || executable === 'gradle' || executable === 'gradlew') return args.some((arg) => /(?:^|:)test$/i.test(arg));
318
- return false;
319
- }
320
-
321
- function requestedTestCommand(command) {
322
- return boundedShellSegments(command).some((segment) => testCommandInSegment(segment.text));
323
- }
324
-
325
- function git(root, args) {
326
- return execFileSync('git', ['-C', root, ...args], {
327
- encoding: 'utf8',
328
- windowsHide: true,
329
- stdio: ['ignore', 'pipe', 'ignore'],
330
- maxBuffer: 4 * 1024 * 1024,
331
- });
332
- }
333
-
334
- /** The directory the COMMAND will run in, when it names one itself (#5264).
335
- *
336
- * A compound command routinely opens with `cd <path> && …`, and the host's `cwd` is the session's,
337
- * not the command's. Judging `cd <other-repo-worktree> && npm test` by the session cwd evaluates a
338
- * DIFFERENT repository — its test-policy.json and its (typically clean) diff — and then states a
339
- * conclusion about the repo it never looked at. Reuses the same bounded segmentation the gate
340
- * already trusts, and only ever feeds a read-only `git -C` probe. */
341
- function commandWorkingDirectory(command, base) {
342
- const [first] = boundedShellSegments(command);
343
- const match = /^(?:cd|Set-Location)\s+(?!-)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))\s*$/i.exec(first?.text ?? '');
344
- const target = match?.[1] ?? match?.[2] ?? match?.[3];
345
- if (!target || target.startsWith('~')) return null;
346
- const resolved = isAbsolute(target) ? target : (typeof base === 'string' && base ? resolve(base, target) : null);
347
- return resolved && existsSync(resolved) ? resolved : null;
348
- }
349
-
350
- function repositoryRoot(input) {
351
- const candidates = [commandWorkingDirectory(input?.tool_input?.command, input?.cwd), input?.cwd, process.cwd()]
352
- .filter((cwd, index, values) => typeof cwd === 'string' && cwd && values.indexOf(cwd) === index);
353
- for (const cwd of candidates) {
354
- try {
355
- return git(resolve(cwd), ['rev-parse', '--show-toplevel']).trim();
356
- } catch {
357
- // A host may launch the plugin from its own installation directory; try the next valid cwd.
358
- }
359
- }
360
- return null;
361
- }
362
-
363
- function policyMandatoryEntries(root) {
364
- const path = resolve(root, 'test-policy.json');
365
- if (!existsSync(path)) return null;
366
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
367
- if (!Array.isArray(parsed.mandatory) || !parsed.mandatory.every((entry) => entry && typeof entry.glob === 'string')) {
368
- throw new Error('test-policy.json mandatory entries are invalid');
369
- }
370
- return parsed.mandatory;
371
- }
372
-
373
- function taskDiffBase(root) {
374
- const base = ['origin/development', 'origin/main'].find((ref) => {
375
- try {
376
- git(root, ['rev-parse', '--verify', `${ref}^{commit}`]);
377
- return true;
378
- } catch {
379
- return false;
380
- }
381
- });
382
- if (!base) throw new Error('neither origin/development nor origin/main resolves');
383
- return base;
384
- }
385
-
386
- function lines(output) {
387
- return output.split(/\r?\n/).map((path) => path.trim()).filter(Boolean);
388
- }
389
-
390
- /**
391
- * The task diff's paths, and the subset of them this diff CREATED (#5842).
392
- *
393
- * Both come from the same four reads, so the added set can never describe a different diff than the
394
- * paths it qualifies. Untracked files are added by definition; the tracked side asks git directly
395
- * with `--diff-filter=AR` — a rename's new name did not exist at the base either, so it is created
396
- * work for this purpose.
397
- */
398
- function taskDiffPaths(root) {
399
- const base = taskDiffBase(root);
400
- const untracked = lines(git(root, ['ls-files', '--others', '--exclude-standard']));
401
- const paths = [...new Set([
402
- ...lines(git(root, ['diff', '--name-only', `${base}...HEAD`])),
403
- ...lines(git(root, ['diff', '--name-only', '--cached'])),
404
- ...lines(git(root, ['diff', '--name-only'])),
405
- ...untracked,
406
- ])];
407
- // The added set only ever changes the verdict when a test file is in the diff at all, and it costs
408
- // three more git children. On the ordinary refusal path — a diff with no test in it — those reads
409
- // would be pure latency in front of every shell command the agent runs.
410
- if (!paths.some(isTestPath)) return { paths, addedPaths: untracked };
411
- const addedPaths = [...new Set([
412
- ...lines(git(root, ['diff', '--name-only', '--diff-filter=AR', `${base}...HEAD`])),
413
- ...lines(git(root, ['diff', '--name-only', '--diff-filter=AR', '--cached'])),
414
- ...lines(git(root, ['diff', '--name-only', '--diff-filter=AR'])),
415
- ...untracked,
416
- ])];
417
- return { paths, addedPaths };
418
- }
419
-
420
- /**
421
- * The waiver the gate may honour for this diff's range, or null (#5804).
422
- *
423
- * `mmi-cli tests policy` honours a valid `Test-Policy-Override` trailer on the findings layer while
424
- * this gate refused the same diff's focused test — one policy, two answers, and the agent that wrote
425
- * the audited trailer to run ONE focused test was then blocked from running exactly that test. The
426
- * reader is the SHARED one, so the gate cannot drift from the CLI's verdict on what a waiver is.
427
- *
428
- * Two fail-closed guards mirror what the CLI reports before it honours anything:
429
- * - a SHALLOW clone's `base..HEAD` is unbounded (#3628) — nothing is honoured from a range the gate
430
- * cannot trust;
431
- * - refusals newer than the winning trailer (malformed shape, unknown scope) block the CLI's run,
432
- * so they block here too — no honouring a waiver the reporting layer just refused.
433
- */
434
- function taskOverride(root, baseRef) {
435
- if (isShallowRepository(root)) return null;
436
- const base = git(root, ['merge-base', 'HEAD', baseRef]).toString().trim();
437
- const { override, refusals } = readOverride(base, root);
438
- return refusals.length === 0 ? override : null;
439
- }
440
-
441
- function runTestCommandPolicy(input, { stdout = process.stdout } = {}) {
442
- if (!requestedTestCommand(input?.tool_input?.command)) return { denied: false };
443
- const root = repositoryRoot(input);
444
- let mandatory;
445
- try {
446
- // No declaration is the estate default: this hook does not regulate test execution there.
447
- if (!root || (mandatory = policyMandatoryEntries(root)) === null) return { denied: false };
448
- // #5519: same evaluator `mmi-cli tests policy` attaches to its OK summary — matched globs and
449
- // command classes cannot disagree with the CLI on the same path set.
450
- // #5804: and the same waiver, read from the same range this diff was computed against, feeds the
451
- // evaluator — an honoured override for out-of-zone test work permits the matching focused test.
452
- const base = taskDiffBase(root);
453
- const { paths, addedPaths } = taskDiffPaths(root);
454
- const decision = evaluateTestCommandPolicy({
455
- paths,
456
- addedPaths,
457
- mandatory,
458
- regulated: true,
459
- override: taskOverride(root, base),
460
- });
461
- if (decision.testCommandsAllowed) return { denied: false, decision };
462
- } catch (error) {
463
- // #5726: the deny aborts the ENTIRE tool call, so say so first — see the matching note below.
464
- const reason = 'TEST-POLICY TEST COMMAND REFUSED [test-command-policy-unresolvable]: '
465
- + 'the ENTIRE command was aborted before execution; no segment ran, so any edit or other non-test step batched into the same call was NOT applied — re-run those steps separately. '
466
- + `a test-policy.json applies but its repository, policy, or task diff could not be established (${error.message}). `
467
- + 'Do not run tests; use policy-approved non-test verification, or repair the repository/base reference before retrying.';
468
- appendHookActivity({ event: 'PreToolUse', script: TEST_COMMAND_GATE_NAME, outcome: 'deny', action: reason, reasonId: 'test-command-policy-unresolvable', tool: input?.tool_name });
469
- stdout.write(preToolUseDeny(reason) + '\n');
470
- return { denied: true };
471
- }
472
- // #5264: name the repository this verdict was computed from. The refusal text is authoritative and
473
- // gets pasted into PR bodies as verification, so a bare "the current diff" — with no statement of
474
- // WHICH diff — reads as a claim about the repo the author is working in even when the gate resolved
475
- // a different one. Naming the root makes a wrong resolution self-evident instead of quotable.
476
- // #5726: a PreToolUse deny aborts the ENTIRE tool call before execution, but this refusal used to
477
- // read as a verdict on the test segment alone. An agent that batched `<edit> && <test runner>` kept
478
- // working believing the edit had landed when the deny had discarded it with the rest. Lead with the
479
- // abort fact, mirroring the operator-input guard's "entire compound tool call was cancelled" wording.
480
- const reason = 'TEST-POLICY TEST COMMAND REFUSED [test-command-outside-mandatory-zone]: '
481
- + 'the ENTIRE command was aborted before execution; no segment ran, so any edit or other non-test step batched into the same call was NOT applied — re-run those steps separately. '
482
- + `no path in ${root}'s task diff matches a mandatory glob in its test-policy.json, and it edits no test file that already existed. `
483
- + 'Verify that is the repository you meant before quoting this: it is resolved from a leading `cd` or `Set-Location` with a literal path, then the host cwd. '
484
- + 'Do not run tests; use policy-approved non-test verification, '
485
- + 'or touch and run mandatory-zone coverage only when the diff actually requires it. '
486
- + 'A diff that EDITS a test file already in the tree may run tests (#5842); one that CREATES an out-of-zone test still needs an honoured `Test-Policy-Override` (#5804).';
487
- appendHookActivity({ event: 'PreToolUse', script: TEST_COMMAND_GATE_NAME, outcome: 'deny', action: reason, reasonId: 'test-command-outside-mandatory-zone', tool: input?.tool_name });
488
- stdout.write(preToolUseDeny(reason) + '\n');
489
- return { denied: true };
490
- }
491
-
492
- // --- `>nul` under Git Bash creates a FILE (#5845) ---------------------------------------------
493
- //
494
- // `nul` is a reserved DEVICE name to Win32, so `>nul` from cmd or PowerShell discards output and
495
- // leaves nothing behind. Bash does not honour that reservation on ANY platform — MSYS/Git Bash
496
- // resolves the path itself, and on Linux `nul` was never special to begin with — so the identical
497
- // redirect creates a real, empty, untracked file called `nul` in the working directory, usually the
498
- // repository root, since that is where agents run. In bash `>nul` therefore never means "discard",
499
- // which is why the deny needs no platform test: only the SHELL decides what the word means.
500
- //
501
- // It then breaks every recursive search over that tree: `rg` and `grep` ask Windows to open `nul`,
502
- // Windows hands back the device, and the read fails with `Incorrect function. (os error 1)`. The
503
- // error goes to stderr while the exit code can still be 0, so a sweep looks like it worked and is
504
- // quietly missing whatever the walker abandoned.
505
- //
506
- // Measured on MMI-Katip: one 0-byte `nul` sat in the repo root from 2026-08-22 until 2026-08-29,
507
- // erroring on every recursive search in between. An audit of that repo's own scripts and CI found no
508
- // `>nul` at all — the file came from an ad-hoc agent command, which is exactly why the fix belongs
509
- // in the gate every agent shell passes through rather than in any one repo.
510
- //
511
- // Gitignoring `nul` was considered and rejected: `rg` would then skip it silently, so the artifact
512
- // would keep landing and the only signal that it had would be gone.
513
- const NUL_REDIRECT_RE = /(?:^|\s)\d*>>?\s*(?:\.[\\/])?nul(?=$|\s)/i;
514
-
515
- /** Bash-shaped tools only. `>nul` from PowerShell or cmd hits the device and creates nothing, so
516
- * denying it there would refuse a correct command. */
517
- function isBashShapedTool(toolName) {
518
- const tool = String(toolName ?? '').trim();
519
- return tool === 'Bash' || tool === 'bash';
520
- }
521
-
522
- function runNulRedirectGuard(input, { stdout = process.stdout } = {}) {
523
- if (!isBashShapedTool(input?.tool_name)) return { denied: false };
524
- const offending = boundedShellSegments(input?.tool_input?.command)
525
- .find((segment) => NUL_REDIRECT_RE.test(segment.text));
526
- if (!offending) return { denied: false };
527
- const reason = 'NUL REDIRECT REFUSED [bash-nul-redirect-creates-a-file]: '
528
- + 'the ENTIRE command was aborted before execution; no segment ran, so any edit or other step batched into the same call was NOT applied — re-run those steps separately. '
529
- + `segment ${offending.ordinal} redirects to \`nul\`, which bash treats as a PATH rather than the Windows null device — it would create an empty, untracked \`nul\` file here. On Windows every later recursive \`rg\`/\`grep\` over this tree then fails on it with "Incorrect function. (os error 1)", on stderr and often with exit 0, so the sweep looks fine while silently missing files. `
530
- + 'Write `2>/dev/null` (or `>/dev/null`) in a bash command; `2>$null` is the PowerShell form. '
531
- + 'To discard both streams, use `>/dev/null 2>&1`.';
532
- appendHookActivity({ event: 'PreToolUse', script: NUL_REDIRECT_GATE_NAME, outcome: 'deny', action: reason, reasonId: 'bash-nul-redirect-creates-a-file', tool: input?.tool_name });
533
- stdout.write(preToolUseDeny(reason) + '\n');
534
- return { denied: true };
535
- }
536
-
537
- function isPowerShellShapedTool(toolName) {
538
- const tool = String(toolName ?? '').trim();
539
- return tool === 'PowerShell' || (process.platform === 'win32' && (tool === 'shell' || tool === 'local_shell'));
540
- }
541
-
542
- function analyzeOperatorInput(input) {
543
- if (!WINDOWS_OPERATOR_GUARD_ON || !isPowerShellShapedTool(input?.tool_name)) return null;
544
- for (const segment of boundedShellSegments(input?.tool_input?.command)) {
545
- const tokens = tokenizePowerShell(segment.text);
546
- if (!tokens) continue;
547
- const hit = analyzeRgWildcard(tokens) ?? analyzeSshInterpolation(tokens);
548
- if (hit) return { ...hit, ordinal: segment.ordinal };
549
- }
550
- return null;
551
- }
552
-
553
-
554
- function runEnvWriteLint(input, { stdout = process.stdout, stderr = process.stderr } = {}) {
555
- const result = analyzeEnvWrite({ toolName: input?.tool_name, command: input?.tool_input?.command });
556
-
557
- appendHookActivity({
558
- event: 'PreToolUse',
559
- script: 'env-write-lint',
560
- outcome: result?.block ? (ENV_WRITE_MODE === 'observe' ? 'observe' : 'deny') : 'ran',
561
- action: result?.block ? result.reason : 'clean',
562
- reasonId: result?.reasonId,
563
- tool: input?.tool_name,
564
- });
565
-
566
- if (!result?.block) return { denied: false };
567
-
568
- if (ENV_WRITE_MODE === 'observe') {
569
- stderr.write(`[mmi-env-write] would-block: ${result.reason}\n`);
570
- return { denied: false };
571
- }
572
-
573
- const reason = expectedProtectionReason(
574
- result.reason,
575
- input,
576
- (segment) => analyzeEnvWrite({ toolName: input?.tool_name, command: segment }),
577
- (segmentResult) => segmentResult?.reasonId === 'env_write_apply_patch'
578
- ? 'environment-file patch write'
579
- : 'environment-file write',
580
- );
581
- stdout.write(preToolUseDeny(reason) + '\n');
582
- return { denied: true };
583
- }
584
-
585
- function runSecretEchoLint(input, { stdout = process.stdout, stderr = process.stderr } = {}) {
586
- if (SECRET_ECHO_MODE === 'off') return { denied: false };
587
- const result = analyzeSecretEcho(input?.tool_input?.command);
588
-
589
- appendHookActivity({
590
- event: 'PreToolUse',
591
- script: 'secret-echo-lint',
592
- outcome: result?.block ? (SECRET_ECHO_MODE === 'block' ? 'deny' : 'observe') : 'ran',
593
- action: result?.block ? result.reason : 'clean',
594
- tool: input?.tool_name,
595
- });
596
-
597
- if (!result?.block) return { denied: false };
598
-
599
- if (SECRET_ECHO_MODE !== 'block') {
600
- stderr.write(`[mmi-secret-echo-lint] would-block: ${result.reason}\n`);
601
- return { denied: false };
602
- }
603
-
604
- const reason = expectedProtectionReason(
605
- result.reason,
606
- input,
607
- analyzeSecretEcho,
608
- (segmentResult) => /dumps all environment variables/i.test(segmentResult?.reason ?? '')
609
- ? 'environment enumeration'
610
- : 'secret-value output',
611
- );
612
- stdout.write(preToolUseDeny(reason) + '\n');
613
- return { denied: true };
614
- }
615
-
616
- function runOperatorInputGuard(input, { stdout = process.stdout } = {}) {
617
- const result = analyzeOperatorInput(input);
618
- if (!result) return { denied: false };
619
- const recovery = result.reasonId === 'operator_input_rg_wildcard_path'
620
- ? 'Discover candidates with `rg --files`, then pass resolved literal paths or move wildcard selection to `-g`/`--glob`; do not guess a path, and keep recursive queries and output bounded.'
621
- : 'Put the remote program in a temporary literal script and pipe it to `ssh host bash -s`; select the intended remote runtime explicitly, and verify the inner program exit and output.';
622
- const reason = `Entire compound tool call was cancelled before execution; safe sibling segments did not run. `
623
- + `This is an expected operator-input refusal, not a tool defect. Rejected segment ${result.ordinal}: `
624
- + `${result.semanticClass}. ${recovery}`;
625
- appendHookActivity({
626
- event: 'PreToolUse',
627
- script: OPERATOR_GATE_NAME,
628
- outcome: 'deny',
629
- action: reason,
630
- reasonId: result.reasonId,
631
- tool: input?.tool_name,
632
- });
633
- stdout.write(preToolUseDeny(reason) + '\n');
634
- return { denied: true };
635
- }
636
-
637
- function runCommandLadder(input, { stdout = process.stdout, stderr = process.stderr } = {}) {
638
- recordGateSuccess(GATE_NAME);
639
-
640
- const decision = decideCommandLadder({ toolName: input?.tool_name, command: input?.tool_input?.command });
641
-
642
- appendHookActivity({
643
- event: 'PreToolUse',
644
- script: GATE_NAME,
645
- outcome: decision.action === 'allow' ? 'ran' : decision.action,
646
- action: decision.reason ?? 'clean',
647
- reasonId: decision.reasonId,
648
- tool: input?.tool_name,
649
- });
650
-
651
- if (decision.action === 'bypass') {
652
- stderr.write(
653
- `[mmi-ladder] BYPASS ${new Date().toISOString()} ${matchedVerb(decision.reasonId)} ` +
654
- `(covered by ${decision.replacement}); MMI_ALLOW_RAW_GH set — allowing raw gh\n`,
655
- );
656
- } else if (decision.action === 'observe') {
657
- stderr.write(`[mmi-ladder] would-block: ${decision.reason}\n`);
658
- } else if (decision.action === 'deny') {
659
- stdout.write(preToolUseDeny(decision.reason) + '\n');
660
- }
661
- }
662
-
663
- /** #4118: exported as `runHookGate` too — the uniform in-process entry hook-run.mjs imports instead of
664
- * booting a second node. `input` is the buffered payload when the runner already drained stdin. */
665
- export async function runPreToolUseShellGates({ input: buffered, stdout = process.stdout, stderr = process.stderr } = {}) {
666
- let input;
667
- try {
668
- input = await readHookInput(buffered);
669
- } catch {
670
- // Unreadable/absent payload = out-of-contract host (Cursor's Claude-plugin import, #2992):
671
- // fail open without counting a crash. Post-parse crashes below stay fail-closed (#2598).
672
- const res = handleMissingHookInput(GATE_NAME);
673
- if (res.stdout) stdout.write(res.stdout);
674
- if (res.stderr) stderr.write(res.stderr);
675
- return;
676
- }
677
-
678
- const command = input?.tool_input?.command;
679
- if (typeof command === 'string' && command.length > 0 && !SHELL_META_CHARS.test(command)) {
680
- const trimmed = command.trim();
681
- if (FAST_PATH_COMMANDS.has(trimmed)) {
682
- recordGateSuccess(GATE_NAME);
683
- appendHookActivity({
684
- event: 'PreToolUse',
685
- script: GATE_NAME,
686
- outcome: 'ran',
687
- action: 'fast-path allow (trivially-safe read-only)',
688
- tool: input?.tool_name,
689
- });
690
- return;
691
- }
692
- }
693
-
694
- const envWrite = runEnvWriteLint(input, { stdout, stderr });
695
- if (envWrite.denied) return;
696
- const echo = runSecretEchoLint(input, { stdout, stderr });
697
- if (echo.denied) return;
698
- const operatorInput = runOperatorInputGuard(input, { stdout });
699
- if (operatorInput.denied) return;
700
- const nulRedirect = runNulRedirectGuard(input, { stdout });
701
- if (nulRedirect.denied) return;
702
- const testCommandPolicy = runTestCommandPolicy(input, { stdout });
703
- if (testCommandPolicy.denied) return;
704
- runCommandLadder(input, { stdout, stderr });
705
-
706
- }
707
-
708
- export { runPreToolUseShellGates as runHookGate };
709
-
710
- if (
711
- process.argv[1] &&
712
- (process.argv[1].endsWith('pretooluse-shell-gates.mjs') ||
713
- process.argv[1].replace(/\\/g, '/').endsWith('scripts/pretooluse-shell-gates.mjs'))
714
- ) {
715
- runPreToolUseShellGates().catch(() => {
716
- const res = handleGateCrash(GATE_NAME);
717
- if (res.stdout) process.stdout.write(res.stdout);
718
- if (res.stderr) process.stderr.write(res.stderr);
719
- }).finally(() => process.exit(0));
720
- }