@mutmutco/codex-plugin 3.131.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +30 -0
- package/bin/mmi-cli +6 -0
- package/bin/mmi-cli.cmd +3 -0
- package/bin/mmi-hook +2 -0
- package/bin/mmi-hook-console.cmd +10 -0
- package/bin/mmi-hook.exe +0 -0
- package/hooks/codex-hooks.json +41 -0
- package/package.json +21 -0
- package/scripts/command-ladder-core.mjs +334 -0
- package/scripts/command-ladder-gate.mjs +126 -0
- package/scripts/deny-gate-crash.mjs +179 -0
- package/scripts/edit-tool-paths.mjs +113 -0
- package/scripts/env-write-lint.mjs +137 -0
- package/scripts/hook-io.mjs +22 -0
- package/scripts/hook-policy.mjs +73 -0
- package/scripts/hook-run.mjs +437 -0
- package/scripts/hook-trace.mjs +151 -0
- package/scripts/pretooluse-shell-gates.mjs +424 -0
- package/scripts/secret-echo-lint.mjs +177 -0
- package/scripts/secret-redact.mjs +552 -0
- package/scripts/throttle-core.mjs +324 -0
- package/scripts/validate-hook.mjs +156 -0
- package/scripts/vault-edit-gate.mjs +94 -0
- package/skills/bootstrap/SKILL.md +550 -0
- package/skills/bootstrap/seeds/Dockerfile.template +30 -0
- package/skills/bootstrap/seeds/README.template.md +37 -0
- package/skills/bootstrap/seeds/architecture.template.md +34 -0
- package/skills/bootstrap/seeds/decisions-readme.template.md +45 -0
- package/skills/bootstrap/seeds/docker-compose.template.yml +26 -0
- package/skills/bootstrap/seeds/gate.template.yml +85 -0
- package/skills/bootstrap/seeds/google-login.template.md +33 -0
- package/skills/bootstrap/seeds/manifest.json +26 -0
- package/skills/bootstrap/seeds/mmi-product-required-checks.template.json +23 -0
- package/skills/browser-automation/SKILL.md +95 -0
- package/skills/epic/SKILL.md +104 -0
- package/skills/hotfix/SKILL.md +165 -0
- package/skills/mmi/SKILL.md +404 -0
- package/skills/mmi-doctor/SKILL.md +63 -0
- package/skills/onboard/SKILL.md +85 -0
- package/skills/rcand/SKILL.md +208 -0
- package/skills/release/SKILL.md +599 -0
- package/skills/resume/SKILL.md +90 -0
- package/skills/secrets/SKILL.md +159 -0
- package/skills/stage/SKILL.md +153 -0
- package/skills/worktree/SKILL.md +151 -0
|
@@ -0,0 +1,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,156 @@
|
|
|
1
|
+
// PreToolUse validate-hook (#2691): catch a bad mmi-cli WRITE before it spends a failed round-trip.
|
|
2
|
+
// When a Bash/PowerShell tool call is a simple `mmi-cli <verb> ...` invocation, this re-runs the SAME
|
|
3
|
+
// command with `--validate-only` (C3 middleware — a PURE LOCAL parse + flag/enum/ref check that writes
|
|
4
|
+
// NOTHING and never hits the network) against the shipped bundle. A validation failure surfaces as an
|
|
5
|
+
// ADVISORY (warn to stderr, never a deny) so the agent fixes the flags before the real write.
|
|
6
|
+
//
|
|
7
|
+
// FAST + FAIL-OPEN by contract: it only spawns for a detected simple mmi-cli command (never for general
|
|
8
|
+
// shell), skips any compound/piped/redirected command (too risky to reconstruct), bounds the spawn with a
|
|
9
|
+
// short timeout, and swallows every error — a validate probe must never block or slow a tool call it can't
|
|
10
|
+
// help. Disabled by default (opt-in): enable with MMI_VALIDATE_HOOK=on.
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { dirname, join, resolve } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { tokenizeShell } from './command-ladder-core.mjs';
|
|
16
|
+
import { appendHookActivity } from './hook-trace.mjs';
|
|
17
|
+
|
|
18
|
+
const SPAWN_TIMEOUT_MS = 6000;
|
|
19
|
+
const MMI_BINARIES = new Set(['mmi-cli', 'mmi', 'mmi-cli.cmd', 'mmi.cmd']);
|
|
20
|
+
const WRAPPER_TOKENS = new Set(['command', 'sudo', 'npx']);
|
|
21
|
+
// Any of these in the raw command means it is not a single simple invocation — skip (do not reconstruct).
|
|
22
|
+
const SHELL_META_RE = /[|&;`]|\$\(|[<>]|\|\||&&/;
|
|
23
|
+
|
|
24
|
+
/** Strip one layer of matching surrounding quotes from a token so it becomes a real argv value. */
|
|
25
|
+
function unquote(tok) {
|
|
26
|
+
if (tok.length >= 2 && ((tok[0] === '"' && tok.at(-1) === '"') || (tok[0] === "'" && tok.at(-1) === "'"))) {
|
|
27
|
+
return tok.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
return tok;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Detect a single, simple `mmi-cli <args...>` invocation and return its argv (quote-stripped), or null.
|
|
34
|
+
* Conservative by design: rejects any command carrying shell metacharacters (pipes, redirects, compounds,
|
|
35
|
+
* substitutions) so we never mis-reconstruct a complex command. Tolerates leading `command`/`sudo`/`npx`
|
|
36
|
+
* wrappers and inline `VAR=val` assignments. Exported (pure) for tests.
|
|
37
|
+
* @param {string} command
|
|
38
|
+
* @returns {{ argv: string[] } | null}
|
|
39
|
+
*/
|
|
40
|
+
export function detectMmiCliCommand(command) {
|
|
41
|
+
if (!command || typeof command !== 'string') return null;
|
|
42
|
+
if (SHELL_META_RE.test(command)) return null;
|
|
43
|
+
const tokens = tokenizeShell(command);
|
|
44
|
+
let i = 0;
|
|
45
|
+
while (i < tokens.length && (WRAPPER_TOKENS.has(tokens[i]) || /^[A-Za-z_][\w]*=/.test(tokens[i]))) i += 1;
|
|
46
|
+
const bin = tokens[i];
|
|
47
|
+
if (!bin || !MMI_BINARIES.has(bin.toLowerCase())) return null;
|
|
48
|
+
const argv = tokens.slice(i + 1).map(unquote);
|
|
49
|
+
if (!argv.length) return null; // bare `mmi-cli` with no verb — nothing to validate
|
|
50
|
+
// `--validate-only` already present ⇒ the agent is deliberately dry-running; don't double up.
|
|
51
|
+
if (argv.includes('--validate-only') || argv.includes('--dry-run')) return null;
|
|
52
|
+
return { argv };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Interpret a `--validate-only` spawn result into an advisory line, or null (silent). Pure + testable.
|
|
57
|
+
* - stdout `{ok:true,...}` → the command validates → silent.
|
|
58
|
+
* - stderr "unknown option/command" → not a mutating/known command (validate not applicable) → silent.
|
|
59
|
+
* - non-zero exit otherwise → a real validation problem the write would hit → advise with the message.
|
|
60
|
+
* @param {{ status: number|null, stdout: string, stderr: string }} res
|
|
61
|
+
* @returns {string | null}
|
|
62
|
+
*/
|
|
63
|
+
export function interpretValidateResult(res) {
|
|
64
|
+
const stdout = String(res?.stdout ?? '').trim();
|
|
65
|
+
const stderr = String(res?.stderr ?? '').trim();
|
|
66
|
+
if (stdout.startsWith('{')) {
|
|
67
|
+
try {
|
|
68
|
+
if (JSON.parse(stdout).ok === true) return null; // validates cleanly
|
|
69
|
+
} catch {
|
|
70
|
+
/* fall through — non-JSON stdout is not a success signal */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (/unknown option|unknown command/i.test(stderr)) return null; // validate not applicable to this command
|
|
74
|
+
if (res?.status && res.status !== 0) {
|
|
75
|
+
const firstLine = (stderr.split(/\r?\n/).find((l) => l.trim()) ?? stdout.split(/\r?\n/)[0] ?? '').trim();
|
|
76
|
+
return firstLine || null;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve the shipped CLI bundle path: explicit override, then the plugin root, then a path relative to
|
|
82
|
+
* this script (repo layout: scripts/ and cli/ are siblings). Returns null when none exists. */
|
|
83
|
+
function resolveBundle(env) {
|
|
84
|
+
const candidates = [];
|
|
85
|
+
if (env.MMI_CLI_BUNDLE) candidates.push(env.MMI_CLI_BUNDLE);
|
|
86
|
+
if (env.CLAUDE_PLUGIN_ROOT) candidates.push(join(env.CLAUDE_PLUGIN_ROOT, 'cli', 'dist', 'index.cjs'));
|
|
87
|
+
candidates.push(resolve(dirname(fileURLToPath(import.meta.url)), '..', 'cli', 'dist', 'index.cjs'));
|
|
88
|
+
return candidates.find((p) => existsSync(p)) ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run the validate advisory for a PreToolUse Bash/PowerShell input. IO-thin: gating + one guarded spawn.
|
|
93
|
+
* Injected `spawnFn` and `bundleResolver` keep it unit-testable without the real bundle.
|
|
94
|
+
*/
|
|
95
|
+
export function runValidateAdvisory(input, { stderr = process.stderr, env = process.env, spawnFn, bundleResolver = resolveBundle } = {}) {
|
|
96
|
+
// Default OFF (opt-in): the advisory spawns a ~150-400ms bundle on write-shaped mmi-cli calls, above the
|
|
97
|
+
// "tens of ms" hook budget, so it stays out of the hot path unless explicitly enabled with MMI_VALIDATE_HOOK=on.
|
|
98
|
+
if (String(env.MMI_VALIDATE_HOOK ?? '').toLowerCase() !== 'on') return { ran: false };
|
|
99
|
+
const tool = input?.tool_name;
|
|
100
|
+
if (tool !== 'Bash' && tool !== 'PowerShell') return { ran: false };
|
|
101
|
+
|
|
102
|
+
const detected = detectMmiCliCommand(input?.tool_input?.command);
|
|
103
|
+
if (!detected) return { ran: false };
|
|
104
|
+
|
|
105
|
+
const bundle = bundleResolver(env);
|
|
106
|
+
if (!bundle) return { ran: false };
|
|
107
|
+
|
|
108
|
+
let res;
|
|
109
|
+
try {
|
|
110
|
+
const run = spawnFn ?? ((args) => {
|
|
111
|
+
const r = execFileSync(process.execPath, [bundle, ...args], {
|
|
112
|
+
encoding: 'utf8',
|
|
113
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
114
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
env: { ...env, MMI_VALIDATE_HOOK: 'off' }, // guard against any re-entrant hook
|
|
117
|
+
});
|
|
118
|
+
return { status: 0, stdout: r, stderr: '' };
|
|
119
|
+
});
|
|
120
|
+
res = run([...detected.argv, '--validate-only']);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// execFileSync throws on a non-zero exit — that carries the validation failure on stdout/stderr.
|
|
123
|
+
if (e && (e.stdout !== undefined || e.stderr !== undefined)) {
|
|
124
|
+
res = { status: e.status ?? 1, stdout: String(e.stdout ?? ''), stderr: String(e.stderr ?? '') };
|
|
125
|
+
} else {
|
|
126
|
+
appendHookActivity({ event: 'PreToolUse', script: 'validate-hook', outcome: 'failed', action: 'spawn error', tool });
|
|
127
|
+
return { ran: false }; // spawn/timeout error — fail-open, silent
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const advice = interpretValidateResult(res);
|
|
132
|
+
appendHookActivity({
|
|
133
|
+
event: 'PreToolUse',
|
|
134
|
+
script: 'validate-hook',
|
|
135
|
+
outcome: advice ? 'observe' : 'ran',
|
|
136
|
+
action: advice ? advice.slice(0, 200) : 'valid',
|
|
137
|
+
tool,
|
|
138
|
+
});
|
|
139
|
+
if (advice) {
|
|
140
|
+
stderr.write(`[mmi-validate] --validate-only flagged this command before it runs: ${advice}\n`);
|
|
141
|
+
stderr.write('[mmi-validate] advisory only — fix the flags/enums above, or proceed if this is intended.\n');
|
|
142
|
+
}
|
|
143
|
+
return { ran: true, advised: Boolean(advice) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
process.argv[1] &&
|
|
148
|
+
(process.argv[1].endsWith('validate-hook.mjs') ||
|
|
149
|
+
process.argv[1].replace(/\\/g, '/').endsWith('scripts/validate-hook.mjs'))
|
|
150
|
+
) {
|
|
151
|
+
import('./hook-io.mjs')
|
|
152
|
+
.then(({ readHookInput }) => readHookInput())
|
|
153
|
+
.then((input) => runValidateAdvisory(input))
|
|
154
|
+
.catch(() => undefined)
|
|
155
|
+
.finally(() => process.exit(0));
|
|
156
|
+
}
|
|
@@ -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
|
+
}
|