@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,177 @@
1
+ // Secret-echo PreToolUse lint (#2611): zero-LLM guard against printing secrets-inserted env
2
+ // vars to the transcript. Runs before every Bash / PowerShell tool use (PreToolUse hook).
3
+ // Flags echo/printf/printenv of env vars whose name matches the secret-name pattern, and
4
+ // whole-environment dumps (bare env, printenv, set, Get-ChildItem env:). Never crashes a
5
+ // turn — every failure path exits 0 and allows the call.
6
+ //
7
+ // MODE: advisory by default (warn on stderr, exit 0). MMI_SECRET_ECHO_LINT=block flips to
8
+ // emit the PreToolUse deny JSON. Ship: advisory.
9
+ const MODE = process.env.MMI_SECRET_ECHO_LINT ?? 'advisory';
10
+
11
+ import { readHookInput } from './hook-io.mjs';
12
+ import { appendHookActivity } from './hook-trace.mjs';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Pure detection logic — exported for tests (no IO here).
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Secret-name pattern reused from scripts/sensitive-value-mask.mjs secret-assignment matcher.
20
+ */
21
+ const SECRET_NAME_RE = /(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|ACCESS[_-]?KEY|CREDENTIALS?)/i;
22
+
23
+ function isSecretName(name) {
24
+ return SECRET_NAME_RE.test(name);
25
+ }
26
+
27
+ // Whole-environment dump patterns (per-segment anchors)
28
+ const BARE_ENV = /^\s*env\s*$/;
29
+ const BARE_PRINTENV = /^\s*printenv\s*$/;
30
+ const BARE_SET = /^\s*set\s*$/;
31
+ const PS_ENV_DUMP = /\b(?:Get-ChildItem|gci|ls|dir)\s+env:/i;
32
+
33
+ /**
34
+ * Inspect a shell command for secret-named env var print or whole-environment dumps.
35
+ * Returns null (clean) or { block: true, reason }.
36
+ *
37
+ * @param {string} command
38
+ * @returns {{ block: boolean, reason: string } | null}
39
+ */
40
+ export function analyze(command) {
41
+ if (!command || typeof command !== 'string') return null;
42
+ const trimmed = command.trim();
43
+ if (!trimmed) return null;
44
+
45
+ // Split first so `secrets use` exemptions and dump checks are per-segment
46
+ // (closes compound bypass + newline-separated dump miss; lone-CR line breaks split too).
47
+ const segments = trimmed.split(/[;&|]|[\r\n]+/);
48
+ for (const raw of segments) {
49
+ const seg = raw.trim();
50
+ if (!seg) continue;
51
+ if (/^secrets\s+use\b/.test(seg)) continue;
52
+ if (/^(?:export|local|declare|typeset)\s/.test(seg)) continue;
53
+
54
+ if (BARE_ENV.test(seg)) {
55
+ return { block: true, reason: 'bare `env` dumps all environment variables including secrets-sealed values' };
56
+ }
57
+ if (BARE_PRINTENV.test(seg)) {
58
+ return { block: true, reason: 'bare `printenv` dumps all environment variables including secrets-sealed values' };
59
+ }
60
+ if (BARE_SET.test(seg)) {
61
+ return { block: true, reason: 'bare `set` dumps all environment variables including secrets-sealed values' };
62
+ }
63
+ if (PS_ENV_DUMP.test(seg)) {
64
+ return { block: true, reason: '`Get-ChildItem env:` dumps all environment variables including secrets-sealed values' };
65
+ }
66
+
67
+ const hit = checkBashPrint(seg) || checkPsPrint(seg);
68
+ if (hit) return hit;
69
+ }
70
+
71
+ return null;
72
+ }
73
+
74
+ function checkBashPrint(segment) {
75
+ // printenv SECRET_NAME
76
+ const peMatch = segment.match(/(?:^|\s)printenv\s+(\w+)(?:\s|$)/);
77
+ if (peMatch && isSecretName(peMatch[1])) {
78
+ return { block: true, reason: `printenv of $${peMatch[1]} prints a secret-named env var` };
79
+ }
80
+
81
+ // echo/printf with $SECRET_VAR
82
+ if (/\b(?:echo|printf)\b/.test(segment)) {
83
+ for (const m of segment.matchAll(/\$[\{]?(\w+)/g)) {
84
+ if (isSecretName(m[1])) {
85
+ return { block: true, reason: `echo/printf of $${m[1]} prints a secret-named env var` };
86
+ }
87
+ }
88
+ }
89
+
90
+ return null;
91
+ }
92
+
93
+ function checkPsPrint(segment) {
94
+ // PowerShell accepts both $env:SECRET_VAR and ${env:SECRET_VAR}; keep one matcher so the braced form
95
+ // cannot bypass the same output checks as the ordinary form.
96
+ const envReference = /\$(?:\{env:(\w+)\}|env:(\w+))/gi;
97
+ const envName = (match) => match[1] ?? match[2];
98
+
99
+ // Write-Output / Write-Host / echo with $env:SECRET_VAR
100
+ if (/\b(?:Write-Output|Write-Host|echo)\b/i.test(segment)) {
101
+ for (const m of segment.matchAll(envReference)) {
102
+ const name = envName(m);
103
+ if (isSecretName(name)) {
104
+ return { block: true, reason: `Write/echo of $env:${name} prints a secret-named env var` };
105
+ }
106
+ }
107
+ }
108
+
109
+ // $env:SECRET_VAR in output position (start of command or after pipe, not assignment LHS)
110
+ for (const m of segment.matchAll(new RegExp(`(?:^|[;&|]\\s*)${envReference.source}`, 'gi'))) {
111
+ const name = envName(m);
112
+ const after = segment.slice(m.index + m[0].length);
113
+ if (/^\s*=(?!=)/.test(after)) continue; // assignment LHS, not output
114
+ if (isSecretName(name)) {
115
+ return { block: true, reason: `$env:${name} in output position prints a secret-named env var` };
116
+ }
117
+ }
118
+
119
+ return null;
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // IO + main — only runs when this file is the entry point.
124
+ // ---------------------------------------------------------------------------
125
+
126
+ function preToolUseDeny(reason) {
127
+ return JSON.stringify({
128
+ hookSpecificOutput: {
129
+ hookEventName: 'PreToolUse',
130
+ permissionDecision: 'deny',
131
+ permissionDecisionReason: reason,
132
+ },
133
+ });
134
+ }
135
+
136
+ async function main() {
137
+ let input;
138
+ try {
139
+ input = await readHookInput();
140
+ } catch {
141
+ appendHookActivity({
142
+ event: 'PreToolUse',
143
+ script: 'secret-echo-lint',
144
+ outcome: 'failed',
145
+ action: 'could not read hook input',
146
+ });
147
+ process.exit(0);
148
+ }
149
+
150
+ const result = analyze(input?.tool_input?.command);
151
+
152
+ appendHookActivity({
153
+ event: 'PreToolUse',
154
+ script: 'secret-echo-lint',
155
+ outcome: result?.block ? (MODE === 'block' ? 'deny' : 'observe') : 'ran',
156
+ action: result?.block ? result.reason : 'clean',
157
+ tool: input?.tool_name,
158
+ });
159
+
160
+ if (result?.block) {
161
+ if (MODE === 'block') {
162
+ process.stdout.write(preToolUseDeny(result.reason) + '\n');
163
+ } else {
164
+ process.stderr.write(`[mmi-secret-echo-lint] would-block: ${result.reason}\n`);
165
+ }
166
+ }
167
+
168
+ process.exit(0);
169
+ }
170
+
171
+ if (
172
+ process.argv[1] &&
173
+ (process.argv[1].endsWith('secret-echo-lint.mjs') ||
174
+ process.argv[1].replace(/\\/g, '/').endsWith('scripts/secret-echo-lint.mjs'))
175
+ ) {
176
+ main().catch(() => process.exit(0));
177
+ }
@@ -0,0 +1,324 @@
1
+ // Shell-dialect detection (#979/#2089) — pure, no IO; exported for the shell-redirect lint + tests.
2
+ // Catches PowerShell syntax emitted into Bash and bash/zsh syntax emitted into PowerShell, so the
3
+ // model re-issues the command in the correct dialect instead of burning a turn on a failed call.
4
+
5
+ /** Remove quoted spans so redirect/log patterns in string literals do not trip the lint. */
6
+ export function stripQuoted(cmd) {
7
+ return cmd.replace(/'[^']*'/g, ' ').replace(/"[^"]*"/g, ' ');
8
+ }
9
+
10
+ export function normalizeToolName(name) {
11
+ return String(name ?? '').trim();
12
+ }
13
+
14
+ // #3563: Codex canonicalises its shell call to `Bash` on 0.145.0, but the Codex hook manifest also
15
+ // matches `shell`/`local_shell` for forward safety — and a matcher that fires a gate the gate itself
16
+ // does not recognise is a decorative claim, not protection (cross-vendor check, openai-sol). One list,
17
+ // used by every shell gate, so the matcher and the implementation cannot drift apart.
18
+ const SHELL_TOOL_FAMILY = new Map([
19
+ ['Bash', 'bash'],
20
+ ['PowerShell', 'powershell'],
21
+ ['shell', 'bash'],
22
+ ['local_shell', 'bash'],
23
+ ]);
24
+
25
+ export function isShellTool(toolName) {
26
+ return SHELL_TOOL_FAMILY.has(normalizeToolName(toolName));
27
+ }
28
+
29
+ export function shellFamilyFromToolName(toolName) {
30
+ return SHELL_TOOL_FAMILY.get(normalizeToolName(toolName)) ?? null;
31
+ }
32
+
33
+ export function normalizeShellFamily(shell, platform = process.platform) {
34
+ const s = String(shell ?? '').trim().toLowerCase();
35
+ if (/\b(pwsh|powershell|power-shell)\b/.test(s)) return 'powershell';
36
+ if (/\b(bash|zsh|sh|fish|git-bash|wsl)\b/.test(s)) return 'bash';
37
+ if (platform === 'win32') return 'powershell';
38
+ if (platform === 'darwin' || platform === 'linux' || platform === 'freebsd') return 'bash';
39
+ return null;
40
+ }
41
+
42
+ function shellUnquoted(command) {
43
+ return stripQuoted(String(command ?? ''));
44
+ }
45
+
46
+ function dialectReason(reasonId, reason) {
47
+ return { reasonId, reason };
48
+ }
49
+
50
+ function isExplicitCrossShellInvocation(cmd, family) {
51
+ const hasOuterSeparator = /(?:^|[^|])\|(?!\|)|;|&&|\|\|/.test(cmd);
52
+ if (hasOuterSeparator) return false;
53
+ if (family === 'bash') {
54
+ return /^\s*(?:pwsh|powershell)(?:\.exe)?\s+(?:(?:-NoProfile|-NonInteractive)\s+)*(?:-Command|-c)\b/i.test(cmd);
55
+ }
56
+ if (family === 'powershell') {
57
+ return /^\s*(?:bash|zsh|sh)(?:\.exe)?\s+-l?c\b/i.test(cmd) || /^\s*wsl(?:\.exe)?\b/i.test(cmd);
58
+ }
59
+ return false;
60
+ }
61
+
62
+ /**
63
+ * True when the command carries a real heredoc operator (`<<` / `<<-`) at a shell position — i.e. a `<<`
64
+ * that sits OUTSIDE any quoted span, followed by an (optionally quoted) delimiter word. The delimiter
65
+ * itself may be quoted (`<<'EOF'`), so we scan the RAW command for a `<<` outside quotes rather than the
66
+ * quote-stripped form. This is what distinguishes a genuine heredoc from a `<<` inside a string literal
67
+ * (e.g. `echo 'a << b'`), which must not trip the #2268 native-CLI heredoc lint.
68
+ * Returns the raw-command index of the `<<` (so callers can scope checks to the text BEFORE the
69
+ * heredoc, #2648), or -1 when no heredoc exists.
70
+ */
71
+ function heredocIndexOutsideQuotes(command) {
72
+ let inSingle = false;
73
+ let inDouble = false;
74
+ for (let i = 0; i < command.length - 1; i += 1) {
75
+ const c = command[i];
76
+ if (inSingle) {
77
+ if (c === "'") inSingle = false;
78
+ continue;
79
+ }
80
+ if (inDouble) {
81
+ if (c === '\\') { i += 1; continue; }
82
+ if (c === '"') inDouble = false;
83
+ continue;
84
+ }
85
+ if (c === "'") { inSingle = true; continue; }
86
+ if (c === '"') { inDouble = true; continue; }
87
+ if (c === '<' && command[i + 1] === '<') {
88
+ if (/^-?\s*['"]?[A-Za-z_]/.test(command.slice(i + 2))) return i;
89
+ i += 1;
90
+ }
91
+ }
92
+ return -1;
93
+ }
94
+
95
+ /**
96
+ * True when a backtick sits at end-of-line OUTSIDE any quoted span — the real PowerShell
97
+ * line-continuation smell. A backtick+newline inside a single/double-quoted body (e.g. a Markdown
98
+ * code fence in an inert `printf '%s' '…'` payload) is data, not shell syntax, and must not trip
99
+ * the lint (#2648).
100
+ */
101
+ function backtickAtEolOutsideQuotes(command) {
102
+ let inSingle = false;
103
+ let inDouble = false;
104
+ for (let i = 0; i < command.length; i += 1) {
105
+ const c = command[i];
106
+ if (inSingle) {
107
+ if (c === "'") inSingle = false;
108
+ continue;
109
+ }
110
+ if (inDouble) {
111
+ if (c === '\\') { i += 1; continue; }
112
+ if (c === '"') inDouble = false;
113
+ continue;
114
+ }
115
+ if (c === "'") { inSingle = true; continue; }
116
+ if (c === '"') { inDouble = true; continue; }
117
+ if (c === '`' && (command[i + 1] === '\n' || (command[i + 1] === '\r' && command[i + 2] === '\n'))) return true;
118
+ }
119
+ return false;
120
+ }
121
+
122
+ /**
123
+ * Neutralize heredoc bodies so shell-dialect matching never trips on literal file content being written
124
+ * verbatim (#2746). A heredoc body is not shell to execute, so a `<<` header opens a BODY that must be kept
125
+ * out of the char-scanner:
126
+ * - QUOTED heredoc (`<<'EOF' … EOF`): the body is 100% inert data, so it is BLANKED — PowerShell-cmdlet
127
+ * tokens (`Select-Object`, `2>$null`), backticks, etc. inside authored Markdown become empty lines.
128
+ * - UNQUOTED heredoc (`<<EOF … EOF`): the body may expand, so it is PASSED THROUGH unchanged for the other
129
+ * lints — but it is STILL not scanned for quote state, because scanning it lets an odd quote in the body
130
+ * (an apostrophe in prose, `don't`) poison the cross-line `inSingle`/`inDouble` state and suppress
131
+ * detection of a LATER quoted heredoc (the recurrence #2750 fixes).
132
+ * The HEADER and TERMINATOR lines are kept (so the native-CLI/heredoc lints still see the `<<`, and no
133
+ * unterminated-heredoc artifact is created), text before the header and after the terminator is untouched, and
134
+ * every character index up to the header is preserved. Quote state is tracked ACROSS non-body lines so a
135
+ * `<<'…'` inside a multi-line quoted string is NOT mistaken for a real header. Plain `<<` needs an EXACT
136
+ * terminator; bash strips indentation only for `<<-`.
137
+ */
138
+ export function stripQuotedHeredocBodies(command) {
139
+ const text = String(command ?? '');
140
+ if (!text.includes('<<')) return text;
141
+ const lines = text.split('\n');
142
+ const out = [];
143
+ let closer = null; // marker word when inside a heredoc body (quoted OR unquoted)
144
+ let closerDash = false; // `<<-` allows an indented terminator; plain `<<` needs an exact match
145
+ let closerBlank = false; // true = blank the body (quoted heredoc); false = pass it through (unquoted)
146
+ let inSingle = false; // quote state carried ACROSS non-body lines
147
+ let inDouble = false;
148
+ for (const line of lines) {
149
+ if (closer !== null) {
150
+ const bare = line.replace(/\r$/, '');
151
+ if (closerDash ? bare.trim() === closer : bare === closer) {
152
+ out.push(line); // keep the terminator line intact
153
+ closer = null; closerDash = false; closerBlank = false;
154
+ } else {
155
+ out.push(closerBlank ? '' : line); // blank a quoted body; pass an unquoted body through untouched
156
+ }
157
+ continue; // body lines never touch quote state
158
+ }
159
+ let marker = null;
160
+ let dash = false;
161
+ let quoted = false;
162
+ for (let i = 0; i < line.length; i += 1) {
163
+ const c = line[i];
164
+ if (inSingle) { if (c === "'") inSingle = false; continue; }
165
+ if (inDouble) { if (c === '\\') { i += 1; continue; } if (c === '"') inDouble = false; continue; }
166
+ if (c === "'") { inSingle = true; continue; }
167
+ if (c === '"') { inDouble = true; continue; }
168
+ if (marker === null && c === '<' && line[i + 1] === '<') {
169
+ const m = /^(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/.exec(line.slice(i + 2));
170
+ if (m) { dash = m[1] === '-'; quoted = m[2] !== ''; marker = m[3]; i += m[0].length + 1; continue; }
171
+ i += 1;
172
+ }
173
+ }
174
+ out.push(line);
175
+ if (marker !== null) { closer = marker; closerDash = dash; closerBlank = quoted; }
176
+ }
177
+ return out.join('\n');
178
+ }
179
+
180
+ /**
181
+ * ADVISORY (#2650): a Windows backslash-separated path (two or more `\`-joined segments, e.g.
182
+ * `docs\Architecture\x.md`) inside a Bash command body. The Bash tool's own pipeline can silently
183
+ * rewrite specific backslash+letter byte pairs (`\A` became a JSON-escaped bell, `\r` lost its
184
+ * backslash — harness-level, see #2650) before the interpreter runs, corrupting authored file
185
+ * content with no error. This never blocks — it warns and points at the safe patterns (forward
186
+ * slashes; the Write tool for file bodies). Single escapes like `printf '%s\n'` never match:
187
+ * the pattern requires two path separators.
188
+ */
189
+ const WINDOWS_BACKSLASH_PATH_RE = /[A-Za-z0-9_.):-]+\\[A-Za-z][A-Za-z0-9_.-]*\\[A-Za-z0-9_.-]/;
190
+ export function adviseBackslashPath(command) {
191
+ if (!command || typeof command !== 'string') return null;
192
+ if (!WINDOWS_BACKSLASH_PATH_RE.test(command)) return null;
193
+ return {
194
+ reasonId: 'shell_backslash_path_advisory',
195
+ reason:
196
+ 'Windows backslash path in a Bash command body. The Bash tool pipeline can silently mangle '
197
+ + 'backslash+letter byte pairs (e.g. `\\A`, `\\r`) before the interpreter sees them (#2650) — '
198
+ + 'use forward slashes for paths, or author file bodies with the Write tool.',
199
+ };
200
+ }
201
+
202
+ export function analyzeShellDialect(command, shellFamily, platform = process.platform) {
203
+ if (!command || typeof command !== 'string') return null;
204
+ const family = shellFamily === 'bash' || shellFamily === 'powershell' ? shellFamily : null;
205
+ if (!family) return null;
206
+
207
+ const cmd = shellUnquoted(command).trim();
208
+ if (!cmd) return null;
209
+ if (isExplicitCrossShellInvocation(cmd, family)) return null;
210
+
211
+ if (family === 'bash') {
212
+ // #2746: a quoted heredoc body (`<<'EOF' … EOF`) is literal file content, not shell — strip it before
213
+ // dialect matching so PowerShell-cmdlet-shaped tokens, backticks, and `2>$null` inside authored Markdown
214
+ // never trip these Bash lints. The header, everything outside the body, and unquoted heredocs are
215
+ // matched exactly as before (indices up to the header are preserved by the blank-line substitution).
216
+ const bashScan = stripQuotedHeredocBodies(command);
217
+ const bashCmd = shellUnquoted(bashScan).trim();
218
+ // PowerShell stderr/stdout suppression smuggled into Bash: `2>$null`, `>$null`, `*>&$null`,
219
+ // or a piped `... | Out-Null`. In Bash `$null` is the empty string → ambiguous redirect.
220
+ if (/\$null\b/.test(bashCmd) || /\bOut-Null\b/.test(bashCmd)) {
221
+ return dialectReason(
222
+ 'shell_dialect_powershell_redirect_in_bash',
223
+ 'PowerShell `$null` in a Bash command. `$null` is empty in Bash, and redirects like `2>$null` leave '
224
+ + '`2>` with no target → "ambiguous redirect". Use `2>/dev/null` for stderr suppression, '
225
+ + '`>/dev/null` for stdout suppression, or Bash variables instead.',
226
+ );
227
+ }
228
+ if (/\bSelect-Object\b/i.test(bashCmd)) {
229
+ return dialectReason(
230
+ 'shell_dialect_select_object_in_bash',
231
+ 'PowerShell `Select-Object` in a Bash command. Use `head -n <n>` or `tail -n <n>` in Bash.',
232
+ );
233
+ }
234
+ if (/\bGet-Content\b/i.test(bashCmd)) {
235
+ return dialectReason(
236
+ 'shell_dialect_get_content_in_bash',
237
+ 'PowerShell `Get-Content` in a Bash command. Use `cat`, `head`, `tail`, or `sed -n` in Bash.',
238
+ );
239
+ }
240
+ if (/(^|[;&|]\s*)`[^\r\n]+/.test(bashCmd) || backtickAtEolOutsideQuotes(bashScan)) {
241
+ return dialectReason(
242
+ 'shell_dialect_powershell_backtick_in_bash',
243
+ 'Backtick reached the Bash tool. On a PowerShell-backed shell this is usually a here-string or '
244
+ + 'Markdown body, which the Bash tool mangles — write the body to a temp file with the Write tool '
245
+ + 'and pass its path (e.g. `--body-file <path>`). For a real Bash line continuation use `\\`.',
246
+ );
247
+ }
248
+ // #2268: on Windows the Bash tool is a `.cmd` shim whose stdin is not forwarded (#1511), so a
249
+ // heredoc feeding a native CLI silently sends an empty body. Redirect to the file-path pattern.
250
+ // #2648: test the native-CLI names only against the command SEGMENT that consumes the heredoc
251
+ // (the text between the last unquoted separator and the `<<`), never the heredoc BODY — markdown
252
+ // prose mentioning "mmi-cli" fed to `cat > file` is inert.
253
+ const heredocIdx = platform === 'win32' ? heredocIndexOutsideQuotes(bashScan) : -1;
254
+ if (
255
+ heredocIdx >= 0
256
+ && /\b(?:mmi-cli|jerv-cli|gh|git|npm|aws|node|claude)\b/i.test(
257
+ shellUnquoted(bashScan.slice(0, heredocIdx)).split(/[;&|]/).pop() ?? '',
258
+ )
259
+ ) {
260
+ return dialectReason(
261
+ 'shell_dialect_heredoc_native_cli_windows',
262
+ 'Bash heredoc feeding a native CLI fails on Windows: the Bash tool is a `.cmd` shim and stdin is '
263
+ + 'not forwarded (#1511), so the body arrives empty. Write the body to a temp file with the Write '
264
+ + 'tool and pass its path (e.g. `mmi-cli ... --body-file <path>`).',
265
+ );
266
+ }
267
+ }
268
+
269
+ if (family === 'powershell') {
270
+ // Bash device-null redirect smuggled into PowerShell: `2>/dev/null`, `>/dev/null`, `&>/dev/null`.
271
+ // PowerShell has no `/dev/null`; the correct sink is `$null`.
272
+ if (/[0-9&]?>\s*\/dev\/null\b/.test(cmd)) {
273
+ return dialectReason(
274
+ 'shell_dialect_bash_redirect_in_powershell',
275
+ 'Bash redirect in a PowerShell command. PowerShell has no `/dev/null`. Use `2>$null` '
276
+ + '(or `| Out-Null`) to suppress output.',
277
+ );
278
+ }
279
+ if (/\|\s*head(?:\s|$|-)/i.test(cmd)) {
280
+ return dialectReason(
281
+ 'shell_dialect_head_in_powershell',
282
+ 'Bash `head` in a PowerShell command. Use `Select-Object -First <n>`.',
283
+ );
284
+ }
285
+ if (/\|\s*tail(?:\s|$|-)/i.test(cmd)) {
286
+ return dialectReason(
287
+ 'shell_dialect_tail_in_powershell',
288
+ 'Bash `tail` in a PowerShell command. Use `Select-Object -Last <n>` or `Get-Content -Tail <n>`.',
289
+ );
290
+ }
291
+ if (/(^|[;&|\r\n]\s*)sed\s+/i.test(cmd)) {
292
+ return dialectReason(
293
+ 'shell_dialect_sed_in_powershell',
294
+ 'Bash `sed` in a PowerShell command. Use `Select-String`, `ForEach-Object`, or run it explicitly through `bash -lc`.',
295
+ );
296
+ }
297
+ if (/(^|[;&|\r\n]\s*)awk\s+/i.test(cmd)) {
298
+ return dialectReason(
299
+ 'shell_dialect_awk_in_powershell',
300
+ 'Bash `awk` in a PowerShell command. Use PowerShell object processing, or run it explicitly through `bash -lc`.',
301
+ );
302
+ }
303
+ if (/(^|[;&|\r\n]\s*)xargs(?:\s|$)/i.test(cmd)) {
304
+ return dialectReason(
305
+ 'shell_dialect_xargs_in_powershell',
306
+ 'Bash `xargs` in a PowerShell command. Use the PowerShell pipeline with `ForEach-Object`, or run it explicitly through `bash -lc`.',
307
+ );
308
+ }
309
+ if (/(^|[;&|\r\n]\s*)rm\s+-[^\s]*r[^\s]*f|(^|[;&|\r\n]\s*)rm\s+-[^\s]*f[^\s]*r/i.test(cmd)) {
310
+ return dialectReason(
311
+ 'shell_dialect_rm_rf_in_powershell',
312
+ 'Bash `rm -rf` in a PowerShell command. Use `Remove-Item -Recurse -Force -LiteralPath <path>` after verifying the target path.',
313
+ );
314
+ }
315
+ if (/<<-?\s*['"]?[A-Za-z_][A-Za-z0-9_]*['"]?/.test(cmd)) {
316
+ return dialectReason(
317
+ 'shell_dialect_heredoc_in_powershell',
318
+ 'Bash heredoc syntax in a PowerShell command. Use a PowerShell here-string or write a temp file with `Set-Content`.',
319
+ );
320
+ }
321
+ }
322
+
323
+ return null;
324
+ }
@@ -0,0 +1,94 @@
1
+ // No-env floor: deny Edit/Write on real .env files (#1706, hardened #2663).
2
+ // PreToolUse on Claude Code for Edit|Write, and on Codex for Edit|Write|apply_patch (#3563) — Codex's
3
+ // apply_patch carries the target paths inside the patch body, not in `file_path`, so path extraction is
4
+ // shared with plan-edit-sync via edit-tool-paths.mjs. Allows .env.example and template paths.
5
+ // The org is env-free — secrets/config are vault-native (mmi-cli stage injects them into the process env;
6
+ // /secrets manages names). NO .env is ever created, even locally. Fail-closed on gate crashes (#2598).
7
+ import { handleGateCrash, handleMissingHookInput, recordGateSuccess } from './deny-gate-crash.mjs';
8
+ import { editedPaths, isEditTool } from './edit-tool-paths.mjs';
9
+ import { isRealEnvToken } from './env-write-lint.mjs';
10
+ import { readHookInput } from './hook-io.mjs';
11
+ import { appendHookActivity } from './hook-trace.mjs';
12
+
13
+ const MODE = process.env.MMI_VAULT_GATE_MODE ?? 'block';
14
+ const GATE_NAME = 'vault-edit';
15
+
16
+ /**
17
+ * @param {{ toolName?: string, filePath?: string }} input
18
+ * @returns {{ block: boolean, reason: string }}
19
+ */
20
+ export function analyze(input) {
21
+ const toolName = input?.toolName;
22
+ if (!isEditTool(toolName)) return { block: false, reason: '' };
23
+ // #3563: `filePaths` is the list, because Codex's `apply_patch` carries no `file_path` and can touch
24
+ // several files in one call. A single-path caller still passes `filePath`.
25
+ const paths = (input?.filePaths ?? []).concat(typeof input?.filePath === 'string' ? [input.filePath] : []);
26
+ const hit = paths.find((p) => typeof p === 'string' && isRealEnvToken(p.replace(/\\/g, '/').split('/').pop() ?? ''));
27
+ if (!hit) return { block: false, reason: '' };
28
+
29
+ return {
30
+ block: true,
31
+ reason:
32
+ 'No .env — the org is env-free. Never create, edit, or write a .env file (not even a local or '
33
+ + 'gitignored one). Deliver config vault-native: `mmi-cli stage` injects the declared secrets into the '
34
+ + 'compose process env (no file), and `mmi-cli vault secrets` manages the names. If something genuinely cannot '
35
+ + 'proceed without a .env, STOP and consult the board (file an issue) — do not create the file.',
36
+ };
37
+ }
38
+
39
+ /** Shared entry (#4118): awaitable and exit-free so hook-run.mjs can import it in-process instead of
40
+ * booting a second node. `input` is the buffered payload when the runner already drained stdin. */
41
+ export async function runHookGate({ input } = {}) {
42
+ let parsed;
43
+ try {
44
+ parsed = await readHookInput(input);
45
+ } catch {
46
+ // Unreadable/absent payload = out-of-contract host (#2992): fail open without counting a crash.
47
+ const res = handleMissingHookInput(GATE_NAME);
48
+ if (res.stdout) process.stdout.write(res.stdout);
49
+ if (res.stderr) process.stderr.write(res.stderr);
50
+ return;
51
+ }
52
+ recordGateSuccess(GATE_NAME);
53
+
54
+ const { block, reason } = analyze({
55
+ toolName: parsed?.tool_name,
56
+ filePaths: editedPaths(parsed),
57
+ });
58
+
59
+ appendHookActivity({
60
+ event: 'PreToolUse',
61
+ script: GATE_NAME,
62
+ outcome: block ? (MODE === 'observe' ? 'observe' : 'deny') : 'ran',
63
+ action: block ? reason : 'clean',
64
+ tool: parsed?.tool_name,
65
+ });
66
+
67
+ if (block) {
68
+ if (MODE === 'observe') {
69
+ process.stderr.write(`[mmi-vault-gate] would-block: ${reason}\n`);
70
+ } else {
71
+ const decision = JSON.stringify({
72
+ hookSpecificOutput: {
73
+ hookEventName: 'PreToolUse',
74
+ permissionDecision: 'deny',
75
+ permissionDecisionReason: reason,
76
+ },
77
+ });
78
+ process.stdout.write(decision + '\n');
79
+ }
80
+ }
81
+ }
82
+
83
+ if (
84
+ process.argv[1] &&
85
+ (process.argv[1].endsWith('vault-edit-gate.mjs') ||
86
+ process.argv[1].replace(/\\/g, '/').endsWith('scripts/vault-edit-gate.mjs'))
87
+ ) {
88
+ runHookGate().then(() => process.exit(0)).catch(() => {
89
+ const res = handleGateCrash(GATE_NAME);
90
+ if (res.stdout) process.stdout.write(res.stdout);
91
+ if (res.stderr) process.stderr.write(res.stderr);
92
+ process.exit(0);
93
+ });
94
+ }