@mutmutco/hermes-plugin 3.139.1

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 (40) hide show
  1. package/README.md +4 -0
  2. package/__init__.py +72 -0
  3. package/package.json +20 -0
  4. package/plugin.yaml +9 -0
  5. package/prompts/soul.md +71 -0
  6. package/scripts/command-ladder-core.mjs +334 -0
  7. package/scripts/command-ladder-gate.mjs +126 -0
  8. package/scripts/deny-gate-crash.mjs +179 -0
  9. package/scripts/edit-tool-paths.mjs +113 -0
  10. package/scripts/env-write-lint.mjs +137 -0
  11. package/scripts/hook-io.mjs +22 -0
  12. package/scripts/hook-policy.mjs +78 -0
  13. package/scripts/hook-run.mjs +416 -0
  14. package/scripts/hook-trace.mjs +151 -0
  15. package/scripts/pretooluse-shell-gates.mjs +564 -0
  16. package/scripts/secret-echo-lint.mjs +177 -0
  17. package/scripts/throttle-core.mjs +324 -0
  18. package/scripts/vault-edit-gate.mjs +94 -0
  19. package/skills/bootstrap/SKILL.md +561 -0
  20. package/skills/bootstrap/seeds/Dockerfile.template +30 -0
  21. package/skills/bootstrap/seeds/README.template.md +37 -0
  22. package/skills/bootstrap/seeds/architecture.template.md +34 -0
  23. package/skills/bootstrap/seeds/decisions-readme.template.md +45 -0
  24. package/skills/bootstrap/seeds/docker-compose.template.yml +26 -0
  25. package/skills/bootstrap/seeds/gate.template.yml +85 -0
  26. package/skills/bootstrap/seeds/google-login.template.md +33 -0
  27. package/skills/bootstrap/seeds/manifest.json +26 -0
  28. package/skills/bootstrap/seeds/mmi-product-required-checks.template.json +23 -0
  29. package/skills/bootstrap/seeds/readme-mmi-developer-environment.block.md +5 -0
  30. package/skills/browser-automation/SKILL.md +95 -0
  31. package/skills/epic/SKILL.md +112 -0
  32. package/skills/hotfix/SKILL.md +165 -0
  33. package/skills/mmi/SKILL.md +398 -0
  34. package/skills/mmi-doctor/SKILL.md +66 -0
  35. package/skills/mmi-resume/SKILL.md +90 -0
  36. package/skills/onboard/SKILL.md +86 -0
  37. package/skills/rcand/SKILL.md +208 -0
  38. package/skills/release/SKILL.md +604 -0
  39. package/skills/secrets/SKILL.md +159 -0
  40. package/skills/stage/SKILL.md +153 -0
@@ -0,0 +1,564 @@
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 { 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
+
15
+ // Secret echoes are blocked before execution on every active host.
16
+ const SECRET_ECHO_MODE = process.env.MMI_SECRET_ECHO_LINT || 'block';
17
+ const ENV_WRITE_MODE = process.env.MMI_ENV_WRITE_MODE ?? 'block';
18
+ const WINDOWS_OPERATOR_GUARD_ON = !/^(?:0|false|no|off)$/i.test(process.env.MMI_WINDOWS_OPERATOR_GUARD ?? 'on');
19
+ const GATE_NAME = 'command-ladder';
20
+ const OPERATOR_GATE_NAME = 'windows-operator-input';
21
+ const TEST_COMMAND_GATE_NAME = 'test-command-policy';
22
+ const MAX_SEGMENT_CHARS = 32_768;
23
+
24
+ // #3121 fast-path: trivially-safe read-only commands that need no gate evaluation.
25
+ // Any chaining/substitution character anywhere in the command disqualifies the fast path.
26
+ const SHELL_META_CHARS = /[|&;`]|\$\(|[<>]|\|\||&&/;
27
+ const FAST_PATH_COMMANDS = new Set([
28
+ 'git status',
29
+ 'git log',
30
+ 'git diff',
31
+ 'ls',
32
+ 'Get-ChildItem',
33
+ 'dir',
34
+ ]);
35
+
36
+ function preToolUseDeny(reason) {
37
+ return JSON.stringify({
38
+ hookSpecificOutput: {
39
+ hookEventName: 'PreToolUse',
40
+ permissionDecision: 'deny',
41
+ permissionDecisionReason: reason,
42
+ },
43
+ });
44
+ }
45
+
46
+ /** Split only on unquoted compound-command operators. Text is retained solely for analyzers and is never surfaced. */
47
+ function boundedShellSegments(command) {
48
+ const source = String(command ?? '');
49
+ const segments = [];
50
+ let start = 0;
51
+ let quote = null;
52
+ const push = (end) => {
53
+ const text = source.slice(start, end).trim();
54
+ if (text) segments.push({ ordinal: segments.length + 1, text: text.slice(0, MAX_SEGMENT_CHARS) });
55
+ };
56
+ for (let i = 0; i < source.length; i += 1) {
57
+ const ch = source[i];
58
+ if (quote === "'") {
59
+ if (ch === "'" && source[i + 1] === "'") i += 1;
60
+ else if (ch === "'") quote = null;
61
+ continue;
62
+ }
63
+ if (quote === '"') {
64
+ if (ch === '`') i += 1;
65
+ else if (ch === '"') quote = null;
66
+ continue;
67
+ }
68
+ if (ch === "'" || ch === '"') {
69
+ quote = ch;
70
+ continue;
71
+ }
72
+ if (ch === ';' || ch === '|' || ch === '&' || ch === '\r' || ch === '\n') {
73
+ push(i);
74
+ if ((ch === '|' || ch === '&') && source[i + 1] === ch) i += 1;
75
+ if (ch === '\r' && source[i + 1] === '\n') i += 1;
76
+ start = i + 1;
77
+ }
78
+ }
79
+ push(source.length);
80
+ return segments;
81
+ }
82
+
83
+ function locateBlockedSegment(command, analyzer, semanticClass) {
84
+ for (const segment of boundedShellSegments(command)) {
85
+ const result = analyzer(segment.text);
86
+ if (result?.block) return { ordinal: segment.ordinal, semanticClass: semanticClass(result) };
87
+ }
88
+ return { ordinal: 1, semanticClass: semanticClass(null) };
89
+ }
90
+
91
+ function expectedProtectionReason(original, input, analyzer, semanticClass) {
92
+ const located = locateBlockedSegment(input?.tool_input?.command, analyzer, semanticClass);
93
+ const recovery = located.semanticClass === 'environment enumeration'
94
+ ? 'Query only the required variables by explicit non-secret name, in a separate tool call.'
95
+ : located.semanticClass === 'secret-value output'
96
+ ? 'Use a verifier or consumer that returns status without values; when its contract requires vault injection, invoke it through `mmi-cli vault secrets use`.'
97
+ : 'Use the vault-native route named above, and run any safe sibling diagnostics as separate tool calls.';
98
+ return `${original} Entire compound tool call was cancelled before execution; safe sibling diagnostics did not run. `
99
+ + `This is an expected safety refusal, not a tool defect. Rejected segment ${located.ordinal}: `
100
+ + `${located.semanticClass}. ${recovery}`;
101
+ }
102
+
103
+ function tokenizePowerShell(segment) {
104
+ const matches = [...segment.matchAll(/"(?:`.|[^"])*"|'(?:''|[^'])*'|[^\s'"]+/g)];
105
+ if (!matches.length) return [];
106
+ // Adjacent lexical pieces mean mixed quoting (`foo"bar"`); unmatched text means malformed quoting.
107
+ // Both are ambiguous, so the operator guard fails open.
108
+ let end = 0;
109
+ for (const match of matches) {
110
+ const gap = segment.slice(end, match.index);
111
+ if (end > 0 && gap.length === 0) return null;
112
+ if (gap.trim()) return null;
113
+ end = (match.index ?? 0) + match[0].length;
114
+ }
115
+ if (segment.slice(end).trim()) return null;
116
+ const tokens = [];
117
+ for (const match of matches) {
118
+ const raw = match[0];
119
+ const quote = raw[0];
120
+ const fullyQuoted = (quote === '"' || quote === "'") && raw.endsWith(quote);
121
+ let value = fullyQuoted ? raw.slice(1, -1) : raw;
122
+ if (quote === '"') value = value.replace(/`(.)/g, '$1');
123
+ if (quote === "'") value = value.replace(/''/g, "'");
124
+ tokens.push({
125
+ value,
126
+ raw,
127
+ fullyDoubleQuoted: quote === '"' && fullyQuoted,
128
+ });
129
+ }
130
+ return tokens;
131
+ }
132
+
133
+ function executableName(token) {
134
+ return String(token ?? '').replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '';
135
+ }
136
+
137
+ const RG_VALUE_OPTIONS = {
138
+ '-e': 'pattern', '--regexp': 'pattern', '-f': 'pattern', '--file': 'pattern',
139
+ '-g': 'glob', '--glob': 'glob', '--iglob': 'glob', '-t': 'type', '--type': 'type', '--type-add': 'type',
140
+ };
141
+ const RG_SAFE_FLAGS = new Set((
142
+ '--files --hidden --no-ignore --line-number -n --fixed-strings -F --word-regexp -w --case-sensitive -s '
143
+ + '--ignore-case -i --smart-case -S --multiline -U --pcre2 -P --text -a --count -c '
144
+ + '--files-with-matches -l --files-without-match --no-messages --quiet -q --json'
145
+ ).split(' '));
146
+
147
+ function analyzeRgWildcard(tokens) {
148
+ if (!tokens?.length || !['rg', 'rg.exe'].includes(executableName(tokens[0].value))) return null;
149
+ const positional = [];
150
+ let explicitPattern = false;
151
+ let filesMode = false;
152
+ let afterOptions = false;
153
+ for (let i = 1; i < tokens.length; i += 1) {
154
+ const arg = tokens[i].value;
155
+ if (!afterOptions && arg === '--') {
156
+ afterOptions = true;
157
+ continue;
158
+ }
159
+ if (!afterOptions) {
160
+ const longEquals = arg.match(/^(--(?:regexp|file|glob|iglob|type|type-add))=(.*)$/);
161
+ if (longEquals) {
162
+ if (['--regexp', '--file'].includes(longEquals[1])) explicitPattern = true;
163
+ continue;
164
+ }
165
+ const optionKind = RG_VALUE_OPTIONS[arg];
166
+ if (optionKind) {
167
+ if (i + 1 >= tokens.length) return null;
168
+ if (optionKind === 'pattern') explicitPattern = true;
169
+ i += 1;
170
+ continue;
171
+ }
172
+ const shortAttached = arg.match(/^-(e|f|g|t)(.+)$/);
173
+ if (shortAttached) {
174
+ if (shortAttached[1] === 'e' || shortAttached[1] === 'f') explicitPattern = true;
175
+ continue;
176
+ }
177
+ if (RG_SAFE_FLAGS.has(arg)) {
178
+ if (arg === '--files') filesMode = true;
179
+ continue;
180
+ }
181
+ // Unknown options may consume the next token. Shell parsing is undecidable; fail open on ambiguity.
182
+ if (arg.startsWith('-')) return null;
183
+ }
184
+ positional.push(arg);
185
+ }
186
+ const paths = explicitPattern || filesMode ? positional : positional.slice(1);
187
+ return paths.some((path) => /[*?\[]/.test(path))
188
+ ? { reasonId: 'operator_input_rg_wildcard_path', semanticClass: 'unresolved wildcard path argument' }
189
+ : null;
190
+ }
191
+
192
+ 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(' '));
193
+ 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(' '));
194
+
195
+ function hasUnescapedPowerShellVariable(text) {
196
+ for (const match of text.matchAll(/\$(?:\{)?([A-Za-z_][A-Za-z0-9_]*)/g)) {
197
+ const index = match.index ?? 0;
198
+ let backticks = 0;
199
+ for (let i = index - 1; i >= 0 && text[i] === '`'; i -= 1) backticks += 1;
200
+ if (backticks % 2 === 1) continue;
201
+ if (/^\$env:/i.test(text.slice(index))) continue;
202
+ return true;
203
+ }
204
+ return false;
205
+ }
206
+
207
+ function analyzeSshInterpolation(tokens) {
208
+ if (!tokens?.length || !['ssh', 'ssh.exe'].includes(executableName(tokens[0].value))) return null;
209
+ let hostAt = -1;
210
+ for (let i = 1; i < tokens.length; i += 1) {
211
+ const arg = tokens[i].value;
212
+ if (arg === '--') {
213
+ hostAt = i + 1;
214
+ break;
215
+ }
216
+ if (SSH_VALUE_OPTIONS.has(arg)) {
217
+ if (i + 1 >= tokens.length) return null;
218
+ i += 1;
219
+ continue;
220
+ }
221
+ if (SSH_SAFE_FLAGS.has(arg) || /^-[bcDEeFIiJLMmOoPpQRSWw].+/.test(arg)) continue;
222
+ if (arg.startsWith('-')) return null;
223
+ hostAt = i;
224
+ break;
225
+ }
226
+ if (hostAt < 0 || hostAt + 1 >= tokens.length) return null;
227
+ for (const token of tokens.slice(hostAt + 1)) {
228
+ if (!token.fullyDoubleQuoted) continue;
229
+ const remote = token.raw.slice(1, -1);
230
+ if (hasUnescapedPowerShellVariable(remote)) {
231
+ return { reasonId: 'operator_input_ssh_powershell_interpolation', semanticClass: 'PowerShell-interpolated remote program' };
232
+ }
233
+ }
234
+ return null;
235
+ }
236
+
237
+ /**
238
+ * Recognize test execution only as a shell command, never as prose in an argument. The small
239
+ * command vocabulary deliberately covers the package-manager spellings agents actually emit and
240
+ * the direct runners they use when no package script exists.
241
+ */
242
+ function testCommandInSegment(segment) {
243
+ const tokens = tokenizePowerShell(segment);
244
+ if (!tokens?.length) return false;
245
+ const values = tokens.map((token) => token.value);
246
+ let commandAt = 0;
247
+ while (/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(values[commandAt] ?? '')) commandAt += 1;
248
+ if (values[commandAt] === 'command') commandAt += 1;
249
+ if (values[commandAt] === 'env') {
250
+ commandAt += 1;
251
+ while (/^(?:-[A-Za-z]+|[A-Za-z_][A-Za-z0-9_]*=.*)$/.test(values[commandAt] ?? '')) commandAt += 1;
252
+ }
253
+ const executable = executableName(values[commandAt]);
254
+ const args = values.slice(commandAt + 1);
255
+ const isTestScript = (value) => /^test(?:$|[:._-])/.test(value ?? '');
256
+ const positional = (items, valueOptions = new Set()) => {
257
+ const valuesOnly = [];
258
+ for (let i = 0; i < items.length; i += 1) {
259
+ if (valueOptions.has(items[i])) { i += 1; continue; }
260
+ if (items[i].startsWith('-')) continue;
261
+ valuesOnly.push(items[i]);
262
+ }
263
+ return valuesOnly;
264
+ };
265
+ const packageRunner = new Set(['npm', 'npm.cmd', 'pnpm', 'pnpm.cmd', 'yarn', 'yarn.cmd', 'bun', 'bun.exe']);
266
+ if (packageRunner.has(executable)) {
267
+ const words = positional(args, new Set(['--prefix', '--workspace', '-w', '--dir', '-C']));
268
+ if (isTestScript(words[0]) || executableName(words[0]) === 'vitest') return true;
269
+ if (words[0] === 'run') return isTestScript(words[1]);
270
+ if (words[0] === 'exec' || words[0] === 'dlx') return executableName(words[1]) === 'vitest';
271
+ }
272
+ if (executable === 'npx' || executable === 'npx.cmd' || executable === 'pnpx' || executable === 'bunx') {
273
+ return executableName(positional(args, new Set(['--package', '-p']))[0]) === 'vitest';
274
+ }
275
+ if (['vitest', 'vitest.cmd', 'vitest.exe', 'pytest', 'pytest.exe'].includes(executable)) return true;
276
+ if (executable === 'node' && args[0] === '--test') return true;
277
+ if (executable === 'python' || executable === 'python3' || executable === 'py') return args[0] === '-m' && args[1] === 'pytest';
278
+ if (executable === 'cargo' || executable === 'go' || executable === 'dotnet') return args[0] === 'test';
279
+ if (executable === 'mvn' || executable === 'mvnw' || executable === 'gradle' || executable === 'gradlew') return args.some((arg) => /(?:^|:)test$/i.test(arg));
280
+ return false;
281
+ }
282
+
283
+ function requestedTestCommand(command) {
284
+ return boundedShellSegments(command).some((segment) => testCommandInSegment(segment.text));
285
+ }
286
+
287
+ function git(root, args) {
288
+ return execFileSync('git', ['-C', root, ...args], {
289
+ encoding: 'utf8',
290
+ windowsHide: true,
291
+ stdio: ['ignore', 'pipe', 'ignore'],
292
+ maxBuffer: 4 * 1024 * 1024,
293
+ });
294
+ }
295
+
296
+ function repositoryRoot(input) {
297
+ const candidates = [input?.cwd, process.cwd()]
298
+ .filter((cwd, index, values) => typeof cwd === 'string' && cwd && values.indexOf(cwd) === index);
299
+ for (const cwd of candidates) {
300
+ try {
301
+ return git(resolve(cwd), ['rev-parse', '--show-toplevel']).trim();
302
+ } catch {
303
+ // A host may launch the plugin from its own installation directory; try the next valid cwd.
304
+ }
305
+ }
306
+ return null;
307
+ }
308
+
309
+ function policyMandatoryGlobs(root) {
310
+ const path = resolve(root, 'test-policy.json');
311
+ if (!existsSync(path)) return null;
312
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
313
+ if (!Array.isArray(parsed.mandatory) || !parsed.mandatory.every((entry) => entry && typeof entry.glob === 'string')) {
314
+ throw new Error('test-policy.json mandatory entries are invalid');
315
+ }
316
+ return parsed.mandatory.map((entry) => entry.glob);
317
+ }
318
+
319
+ function policyGlobToRegExp(glob) {
320
+ let out = '';
321
+ for (let i = 0; i < glob.length; i += 1) {
322
+ const char = glob[i];
323
+ if (char === '*') {
324
+ if (glob[i + 1] === '*') {
325
+ if (glob[i + 2] === '/') { out += '(?:.*/)?'; i += 2; } else { out += '.*'; i += 1; }
326
+ } else out += '[^/]*';
327
+ } else if (char === '{') {
328
+ const close = glob.indexOf('}', i);
329
+ if (close === -1) out += '\\{';
330
+ else {
331
+ out += `(?:${glob.slice(i + 1, close).split(',').map(policyGlobToRegExp).join('|')})`;
332
+ i = close;
333
+ }
334
+ } else out += /[.+?^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
335
+ }
336
+ return out;
337
+ }
338
+
339
+ function taskDiffPaths(root) {
340
+ const base = ['origin/development', 'origin/main'].find((ref) => {
341
+ try {
342
+ git(root, ['rev-parse', '--verify', `${ref}^{commit}`]);
343
+ return true;
344
+ } catch {
345
+ return false;
346
+ }
347
+ });
348
+ if (!base) throw new Error('neither origin/development nor origin/main resolves');
349
+ const outputs = [
350
+ git(root, ['diff', '--name-only', `${base}...HEAD`]),
351
+ git(root, ['diff', '--name-only', '--cached']),
352
+ git(root, ['diff', '--name-only']),
353
+ git(root, ['ls-files', '--others', '--exclude-standard']),
354
+ ];
355
+ return [...new Set(outputs.flatMap((output) => output.split(/\r?\n/).map((path) => path.trim()).filter(Boolean)))];
356
+ }
357
+
358
+ function runTestCommandPolicy(input, { stdout = process.stdout } = {}) {
359
+ if (!requestedTestCommand(input?.tool_input?.command)) return { denied: false };
360
+ const root = repositoryRoot(input);
361
+ let globs;
362
+ try {
363
+ // No declaration is the estate default: this hook does not regulate test execution there.
364
+ if (!root || (globs = policyMandatoryGlobs(root)) === null) return { denied: false };
365
+ const paths = taskDiffPaths(root);
366
+ if (paths.some((path) => globs.some((glob) => new RegExp(`^${policyGlobToRegExp(glob)}$`).test(path)))) return { denied: false };
367
+ } catch (error) {
368
+ const reason = 'TEST-POLICY TEST COMMAND REFUSED [test-command-policy-unresolvable]: '
369
+ + `a test-policy.json applies but its repository, policy, or task diff could not be established (${error.message}). `
370
+ + 'Do not run tests; use policy-approved non-test verification, or repair the repository/base reference before retrying.';
371
+ appendHookActivity({ event: 'PreToolUse', script: TEST_COMMAND_GATE_NAME, outcome: 'deny', action: reason, reasonId: 'test-command-policy-unresolvable', tool: input?.tool_name });
372
+ stdout.write(preToolUseDeny(reason) + '\n');
373
+ return { denied: true };
374
+ }
375
+ const reason = 'TEST-POLICY TEST COMMAND REFUSED [test-command-outside-mandatory-zone]: '
376
+ + 'the current diff does not touch any test-policy.json mandatory glob. Do not run tests; use policy-approved non-test verification, '
377
+ + 'or touch and run mandatory-zone coverage only when the diff actually requires it.';
378
+ appendHookActivity({ event: 'PreToolUse', script: TEST_COMMAND_GATE_NAME, outcome: 'deny', action: reason, reasonId: 'test-command-outside-mandatory-zone', tool: input?.tool_name });
379
+ stdout.write(preToolUseDeny(reason) + '\n');
380
+ return { denied: true };
381
+ }
382
+
383
+ function isPowerShellShapedTool(toolName) {
384
+ const tool = String(toolName ?? '').trim();
385
+ return tool === 'PowerShell' || (process.platform === 'win32' && (tool === 'shell' || tool === 'local_shell'));
386
+ }
387
+
388
+ function analyzeOperatorInput(input) {
389
+ if (!WINDOWS_OPERATOR_GUARD_ON || !isPowerShellShapedTool(input?.tool_name)) return null;
390
+ for (const segment of boundedShellSegments(input?.tool_input?.command)) {
391
+ const tokens = tokenizePowerShell(segment.text);
392
+ if (!tokens) continue;
393
+ const hit = analyzeRgWildcard(tokens) ?? analyzeSshInterpolation(tokens);
394
+ if (hit) return { ...hit, ordinal: segment.ordinal };
395
+ }
396
+ return null;
397
+ }
398
+
399
+
400
+ function runEnvWriteLint(input, { stdout = process.stdout, stderr = process.stderr } = {}) {
401
+ const result = analyzeEnvWrite({ toolName: input?.tool_name, command: input?.tool_input?.command });
402
+
403
+ appendHookActivity({
404
+ event: 'PreToolUse',
405
+ script: 'env-write-lint',
406
+ outcome: result?.block ? (ENV_WRITE_MODE === 'observe' ? 'observe' : 'deny') : 'ran',
407
+ action: result?.block ? result.reason : 'clean',
408
+ reasonId: result?.reasonId,
409
+ tool: input?.tool_name,
410
+ });
411
+
412
+ if (!result?.block) return { denied: false };
413
+
414
+ if (ENV_WRITE_MODE === 'observe') {
415
+ stderr.write(`[mmi-env-write] would-block: ${result.reason}\n`);
416
+ return { denied: false };
417
+ }
418
+
419
+ const reason = expectedProtectionReason(
420
+ result.reason,
421
+ input,
422
+ (segment) => analyzeEnvWrite({ toolName: input?.tool_name, command: segment }),
423
+ (segmentResult) => segmentResult?.reasonId === 'env_write_apply_patch'
424
+ ? 'environment-file patch write'
425
+ : 'environment-file write',
426
+ );
427
+ stdout.write(preToolUseDeny(reason) + '\n');
428
+ return { denied: true };
429
+ }
430
+
431
+ function runSecretEchoLint(input, { stdout = process.stdout, stderr = process.stderr } = {}) {
432
+ if (SECRET_ECHO_MODE === 'off') return { denied: false };
433
+ const result = analyzeSecretEcho(input?.tool_input?.command);
434
+
435
+ appendHookActivity({
436
+ event: 'PreToolUse',
437
+ script: 'secret-echo-lint',
438
+ outcome: result?.block ? (SECRET_ECHO_MODE === 'block' ? 'deny' : 'observe') : 'ran',
439
+ action: result?.block ? result.reason : 'clean',
440
+ tool: input?.tool_name,
441
+ });
442
+
443
+ if (!result?.block) return { denied: false };
444
+
445
+ if (SECRET_ECHO_MODE !== 'block') {
446
+ stderr.write(`[mmi-secret-echo-lint] would-block: ${result.reason}\n`);
447
+ return { denied: false };
448
+ }
449
+
450
+ const reason = expectedProtectionReason(
451
+ result.reason,
452
+ input,
453
+ analyzeSecretEcho,
454
+ (segmentResult) => /dumps all environment variables/i.test(segmentResult?.reason ?? '')
455
+ ? 'environment enumeration'
456
+ : 'secret-value output',
457
+ );
458
+ stdout.write(preToolUseDeny(reason) + '\n');
459
+ return { denied: true };
460
+ }
461
+
462
+ function runOperatorInputGuard(input, { stdout = process.stdout } = {}) {
463
+ const result = analyzeOperatorInput(input);
464
+ if (!result) return { denied: false };
465
+ const recovery = result.reasonId === 'operator_input_rg_wildcard_path'
466
+ ? '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.'
467
+ : '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.';
468
+ const reason = `Entire compound tool call was cancelled before execution; safe sibling segments did not run. `
469
+ + `This is an expected operator-input refusal, not a tool defect. Rejected segment ${result.ordinal}: `
470
+ + `${result.semanticClass}. ${recovery}`;
471
+ appendHookActivity({
472
+ event: 'PreToolUse',
473
+ script: OPERATOR_GATE_NAME,
474
+ outcome: 'deny',
475
+ action: reason,
476
+ reasonId: result.reasonId,
477
+ tool: input?.tool_name,
478
+ });
479
+ stdout.write(preToolUseDeny(reason) + '\n');
480
+ return { denied: true };
481
+ }
482
+
483
+ function runCommandLadder(input, { stdout = process.stdout, stderr = process.stderr } = {}) {
484
+ recordGateSuccess(GATE_NAME);
485
+
486
+ const decision = decideCommandLadder({ toolName: input?.tool_name, command: input?.tool_input?.command });
487
+
488
+ appendHookActivity({
489
+ event: 'PreToolUse',
490
+ script: GATE_NAME,
491
+ outcome: decision.action === 'allow' ? 'ran' : decision.action,
492
+ action: decision.reason ?? 'clean',
493
+ reasonId: decision.reasonId,
494
+ tool: input?.tool_name,
495
+ });
496
+
497
+ if (decision.action === 'bypass') {
498
+ stderr.write(
499
+ `[mmi-ladder] BYPASS ${new Date().toISOString()} ${matchedVerb(decision.reasonId)} ` +
500
+ `(covered by ${decision.replacement}); MMI_ALLOW_RAW_GH set — allowing raw gh\n`,
501
+ );
502
+ } else if (decision.action === 'observe') {
503
+ stderr.write(`[mmi-ladder] would-block: ${decision.reason}\n`);
504
+ } else if (decision.action === 'deny') {
505
+ stdout.write(preToolUseDeny(decision.reason) + '\n');
506
+ }
507
+ }
508
+
509
+ /** #4118: exported as `runHookGate` too — the uniform in-process entry hook-run.mjs imports instead of
510
+ * booting a second node. `input` is the buffered payload when the runner already drained stdin. */
511
+ export async function runPreToolUseShellGates({ input: buffered, stdout = process.stdout, stderr = process.stderr } = {}) {
512
+ let input;
513
+ try {
514
+ input = await readHookInput(buffered);
515
+ } catch {
516
+ // Unreadable/absent payload = out-of-contract host (Cursor's Claude-plugin import, #2992):
517
+ // fail open without counting a crash. Post-parse crashes below stay fail-closed (#2598).
518
+ const res = handleMissingHookInput(GATE_NAME);
519
+ if (res.stdout) stdout.write(res.stdout);
520
+ if (res.stderr) stderr.write(res.stderr);
521
+ return;
522
+ }
523
+
524
+ const command = input?.tool_input?.command;
525
+ if (typeof command === 'string' && command.length > 0 && !SHELL_META_CHARS.test(command)) {
526
+ const trimmed = command.trim();
527
+ if (FAST_PATH_COMMANDS.has(trimmed)) {
528
+ recordGateSuccess(GATE_NAME);
529
+ appendHookActivity({
530
+ event: 'PreToolUse',
531
+ script: GATE_NAME,
532
+ outcome: 'ran',
533
+ action: 'fast-path allow (trivially-safe read-only)',
534
+ tool: input?.tool_name,
535
+ });
536
+ return;
537
+ }
538
+ }
539
+
540
+ const envWrite = runEnvWriteLint(input, { stdout, stderr });
541
+ if (envWrite.denied) return;
542
+ const echo = runSecretEchoLint(input, { stdout, stderr });
543
+ if (echo.denied) return;
544
+ const operatorInput = runOperatorInputGuard(input, { stdout });
545
+ if (operatorInput.denied) return;
546
+ const testCommandPolicy = runTestCommandPolicy(input, { stdout });
547
+ if (testCommandPolicy.denied) return;
548
+ runCommandLadder(input, { stdout, stderr });
549
+
550
+ }
551
+
552
+ export { runPreToolUseShellGates as runHookGate };
553
+
554
+ if (
555
+ process.argv[1] &&
556
+ (process.argv[1].endsWith('pretooluse-shell-gates.mjs') ||
557
+ process.argv[1].replace(/\\/g, '/').endsWith('scripts/pretooluse-shell-gates.mjs'))
558
+ ) {
559
+ runPreToolUseShellGates().catch(() => {
560
+ const res = handleGateCrash(GATE_NAME);
561
+ if (res.stdout) process.stdout.write(res.stdout);
562
+ if (res.stderr) process.stderr.write(res.stderr);
563
+ }).finally(() => process.exit(0));
564
+ }