@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,424 @@
|
|
|
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 { analyze as analyzeSecretEcho } from './secret-echo-lint.mjs';
|
|
6
|
+
import { analyze as analyzeEnvWrite } from './env-write-lint.mjs';
|
|
7
|
+
import { decide as decideCommandLadder, matchedVerb } from './command-ladder-gate.mjs';
|
|
8
|
+
import { runValidateAdvisory } from './validate-hook.mjs';
|
|
9
|
+
import { handleGateCrash, handleMissingHookInput, recordGateSuccess } from './deny-gate-crash.mjs';
|
|
10
|
+
import { readHookInput } from './hook-io.mjs';
|
|
11
|
+
import { appendHookActivity } from './hook-trace.mjs';
|
|
12
|
+
|
|
13
|
+
// #3630: on Codex and Kimi a secret echoed to the terminal can never be masked afterwards (no
|
|
14
|
+
// updatedToolOutput channel — Codex PostToolUse is decision-only, Kimi PostToolUse is
|
|
15
|
+
// observation-only), so the echo gate fail-closes there by default; on Claude the PostToolUse
|
|
16
|
+
// redactor owns the exposure and the gate stays off unless explicitly enabled.
|
|
17
|
+
const SECRET_ECHO_MODE =
|
|
18
|
+
process.env.MMI_SECRET_ECHO_LINT || (['codex', 'kimi'].includes(process.env.MMI_HOOK_SURFACE || 'claude') ? 'block' : 'off');
|
|
19
|
+
const ENV_WRITE_MODE = process.env.MMI_ENV_WRITE_MODE ?? 'block';
|
|
20
|
+
const WINDOWS_OPERATOR_GUARD_ON = !/^(?:0|false|no|off)$/i.test(process.env.MMI_WINDOWS_OPERATOR_GUARD ?? 'on');
|
|
21
|
+
const GATE_NAME = 'command-ladder';
|
|
22
|
+
const OPERATOR_GATE_NAME = 'windows-operator-input';
|
|
23
|
+
const MAX_SEGMENT_CHARS = 32_768;
|
|
24
|
+
|
|
25
|
+
// #3121 fast-path: trivially-safe read-only commands that need no gate evaluation.
|
|
26
|
+
// Any chaining/substitution character anywhere in the command disqualifies the fast path.
|
|
27
|
+
const SHELL_META_CHARS = /[|&;`]|\$\(|[<>]|\|\||&&/;
|
|
28
|
+
const FAST_PATH_COMMANDS = new Set([
|
|
29
|
+
'git status',
|
|
30
|
+
'git log',
|
|
31
|
+
'git diff',
|
|
32
|
+
'ls',
|
|
33
|
+
'Get-ChildItem',
|
|
34
|
+
'dir',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
function preToolUseDeny(reason) {
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
hookSpecificOutput: {
|
|
40
|
+
hookEventName: 'PreToolUse',
|
|
41
|
+
permissionDecision: 'deny',
|
|
42
|
+
permissionDecisionReason: reason,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Split only on unquoted compound-command operators. Text is retained solely for analyzers and is never surfaced. */
|
|
48
|
+
function boundedShellSegments(command) {
|
|
49
|
+
const source = String(command ?? '');
|
|
50
|
+
const segments = [];
|
|
51
|
+
let start = 0;
|
|
52
|
+
let quote = null;
|
|
53
|
+
const push = (end) => {
|
|
54
|
+
const text = source.slice(start, end).trim();
|
|
55
|
+
if (text) segments.push({ ordinal: segments.length + 1, text: text.slice(0, MAX_SEGMENT_CHARS) });
|
|
56
|
+
};
|
|
57
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
58
|
+
const ch = source[i];
|
|
59
|
+
if (quote === "'") {
|
|
60
|
+
if (ch === "'" && source[i + 1] === "'") i += 1;
|
|
61
|
+
else if (ch === "'") quote = null;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (quote === '"') {
|
|
65
|
+
if (ch === '`') i += 1;
|
|
66
|
+
else if (ch === '"') quote = null;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (ch === "'" || ch === '"') {
|
|
70
|
+
quote = ch;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (ch === ';' || ch === '|' || ch === '&' || ch === '\r' || ch === '\n') {
|
|
74
|
+
push(i);
|
|
75
|
+
if ((ch === '|' || ch === '&') && source[i + 1] === ch) i += 1;
|
|
76
|
+
if (ch === '\r' && source[i + 1] === '\n') i += 1;
|
|
77
|
+
start = i + 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
push(source.length);
|
|
81
|
+
return segments;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function locateBlockedSegment(command, analyzer, semanticClass) {
|
|
85
|
+
for (const segment of boundedShellSegments(command)) {
|
|
86
|
+
const result = analyzer(segment.text);
|
|
87
|
+
if (result?.block) return { ordinal: segment.ordinal, semanticClass: semanticClass(result) };
|
|
88
|
+
}
|
|
89
|
+
return { ordinal: 1, semanticClass: semanticClass(null) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function expectedProtectionReason(original, input, analyzer, semanticClass) {
|
|
93
|
+
const located = locateBlockedSegment(input?.tool_input?.command, analyzer, semanticClass);
|
|
94
|
+
const recovery = located.semanticClass === 'environment enumeration'
|
|
95
|
+
? 'Query only the required variables by explicit non-secret name, in a separate tool call.'
|
|
96
|
+
: located.semanticClass === 'secret-value output'
|
|
97
|
+
? 'Use a verifier or consumer that returns status without values; when its contract requires vault injection, invoke it through `mmi-cli vault secrets use`.'
|
|
98
|
+
: 'Use the vault-native route named above, and run any safe sibling diagnostics as separate tool calls.';
|
|
99
|
+
return `${original} Entire compound tool call was cancelled before execution; safe sibling diagnostics did not run. `
|
|
100
|
+
+ `This is an expected safety refusal, not a tool defect. Rejected segment ${located.ordinal}: `
|
|
101
|
+
+ `${located.semanticClass}. ${recovery}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function tokenizePowerShell(segment) {
|
|
105
|
+
const matches = [...segment.matchAll(/"(?:`.|[^"])*"|'(?:''|[^'])*'|[^\s'"]+/g)];
|
|
106
|
+
if (!matches.length) return [];
|
|
107
|
+
// Adjacent lexical pieces mean mixed quoting (`foo"bar"`); unmatched text means malformed quoting.
|
|
108
|
+
// Both are ambiguous, so the operator guard fails open.
|
|
109
|
+
let end = 0;
|
|
110
|
+
for (const match of matches) {
|
|
111
|
+
const gap = segment.slice(end, match.index);
|
|
112
|
+
if (end > 0 && gap.length === 0) return null;
|
|
113
|
+
if (gap.trim()) return null;
|
|
114
|
+
end = (match.index ?? 0) + match[0].length;
|
|
115
|
+
}
|
|
116
|
+
if (segment.slice(end).trim()) return null;
|
|
117
|
+
const tokens = [];
|
|
118
|
+
for (const match of matches) {
|
|
119
|
+
const raw = match[0];
|
|
120
|
+
const quote = raw[0];
|
|
121
|
+
const fullyQuoted = (quote === '"' || quote === "'") && raw.endsWith(quote);
|
|
122
|
+
let value = fullyQuoted ? raw.slice(1, -1) : raw;
|
|
123
|
+
if (quote === '"') value = value.replace(/`(.)/g, '$1');
|
|
124
|
+
if (quote === "'") value = value.replace(/''/g, "'");
|
|
125
|
+
tokens.push({
|
|
126
|
+
value,
|
|
127
|
+
raw,
|
|
128
|
+
fullyDoubleQuoted: quote === '"' && fullyQuoted,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return tokens;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function executableName(token) {
|
|
135
|
+
return String(token ?? '').replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const RG_VALUE_OPTIONS = {
|
|
139
|
+
'-e': 'pattern', '--regexp': 'pattern', '-f': 'pattern', '--file': 'pattern',
|
|
140
|
+
'-g': 'glob', '--glob': 'glob', '--iglob': 'glob', '-t': 'type', '--type': 'type', '--type-add': 'type',
|
|
141
|
+
};
|
|
142
|
+
const RG_SAFE_FLAGS = new Set((
|
|
143
|
+
'--files --hidden --no-ignore --line-number -n --fixed-strings -F --word-regexp -w --case-sensitive -s '
|
|
144
|
+
+ '--ignore-case -i --smart-case -S --multiline -U --pcre2 -P --text -a --count -c '
|
|
145
|
+
+ '--files-with-matches -l --files-without-match --no-messages --quiet -q --json'
|
|
146
|
+
).split(' '));
|
|
147
|
+
|
|
148
|
+
function analyzeRgWildcard(tokens) {
|
|
149
|
+
if (!tokens?.length || !['rg', 'rg.exe'].includes(executableName(tokens[0].value))) return null;
|
|
150
|
+
const positional = [];
|
|
151
|
+
let explicitPattern = false;
|
|
152
|
+
let filesMode = false;
|
|
153
|
+
let afterOptions = false;
|
|
154
|
+
for (let i = 1; i < tokens.length; i += 1) {
|
|
155
|
+
const arg = tokens[i].value;
|
|
156
|
+
if (!afterOptions && arg === '--') {
|
|
157
|
+
afterOptions = true;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!afterOptions) {
|
|
161
|
+
const longEquals = arg.match(/^(--(?:regexp|file|glob|iglob|type|type-add))=(.*)$/);
|
|
162
|
+
if (longEquals) {
|
|
163
|
+
if (['--regexp', '--file'].includes(longEquals[1])) explicitPattern = true;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const optionKind = RG_VALUE_OPTIONS[arg];
|
|
167
|
+
if (optionKind) {
|
|
168
|
+
if (i + 1 >= tokens.length) return null;
|
|
169
|
+
if (optionKind === 'pattern') explicitPattern = true;
|
|
170
|
+
i += 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const shortAttached = arg.match(/^-(e|f|g|t)(.+)$/);
|
|
174
|
+
if (shortAttached) {
|
|
175
|
+
if (shortAttached[1] === 'e' || shortAttached[1] === 'f') explicitPattern = true;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (RG_SAFE_FLAGS.has(arg)) {
|
|
179
|
+
if (arg === '--files') filesMode = true;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
// Unknown options may consume the next token. Shell parsing is undecidable; fail open on ambiguity.
|
|
183
|
+
if (arg.startsWith('-')) return null;
|
|
184
|
+
}
|
|
185
|
+
positional.push(arg);
|
|
186
|
+
}
|
|
187
|
+
const paths = explicitPattern || filesMode ? positional : positional.slice(1);
|
|
188
|
+
return paths.some((path) => /[*?\[]/.test(path))
|
|
189
|
+
? { reasonId: 'operator_input_rg_wildcard_path', semanticClass: 'unresolved wildcard path argument' }
|
|
190
|
+
: null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
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(' '));
|
|
194
|
+
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(' '));
|
|
195
|
+
|
|
196
|
+
function hasUnescapedPowerShellVariable(text) {
|
|
197
|
+
for (const match of text.matchAll(/\$(?:\{)?([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
198
|
+
const index = match.index ?? 0;
|
|
199
|
+
let backticks = 0;
|
|
200
|
+
for (let i = index - 1; i >= 0 && text[i] === '`'; i -= 1) backticks += 1;
|
|
201
|
+
if (backticks % 2 === 1) continue;
|
|
202
|
+
if (/^\$env:/i.test(text.slice(index))) continue;
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function analyzeSshInterpolation(tokens) {
|
|
209
|
+
if (!tokens?.length || !['ssh', 'ssh.exe'].includes(executableName(tokens[0].value))) return null;
|
|
210
|
+
let hostAt = -1;
|
|
211
|
+
for (let i = 1; i < tokens.length; i += 1) {
|
|
212
|
+
const arg = tokens[i].value;
|
|
213
|
+
if (arg === '--') {
|
|
214
|
+
hostAt = i + 1;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
if (SSH_VALUE_OPTIONS.has(arg)) {
|
|
218
|
+
if (i + 1 >= tokens.length) return null;
|
|
219
|
+
i += 1;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (SSH_SAFE_FLAGS.has(arg) || /^-[bcDEeFIiJLMmOoPpQRSWw].+/.test(arg)) continue;
|
|
223
|
+
if (arg.startsWith('-')) return null;
|
|
224
|
+
hostAt = i;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
if (hostAt < 0 || hostAt + 1 >= tokens.length) return null;
|
|
228
|
+
for (const token of tokens.slice(hostAt + 1)) {
|
|
229
|
+
if (!token.fullyDoubleQuoted) continue;
|
|
230
|
+
const remote = token.raw.slice(1, -1);
|
|
231
|
+
if (hasUnescapedPowerShellVariable(remote)) {
|
|
232
|
+
return { reasonId: 'operator_input_ssh_powershell_interpolation', semanticClass: 'PowerShell-interpolated remote program' };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isPowerShellShapedTool(toolName) {
|
|
239
|
+
const tool = String(toolName ?? '').trim();
|
|
240
|
+
return tool === 'PowerShell' || (process.platform === 'win32' && (tool === 'shell' || tool === 'local_shell'));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function analyzeOperatorInput(input) {
|
|
244
|
+
if (!WINDOWS_OPERATOR_GUARD_ON || !isPowerShellShapedTool(input?.tool_name)) return null;
|
|
245
|
+
for (const segment of boundedShellSegments(input?.tool_input?.command)) {
|
|
246
|
+
const tokens = tokenizePowerShell(segment.text);
|
|
247
|
+
if (!tokens) continue;
|
|
248
|
+
const hit = analyzeRgWildcard(tokens) ?? analyzeSshInterpolation(tokens);
|
|
249
|
+
if (hit) return { ...hit, ordinal: segment.ordinal };
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
function runEnvWriteLint(input, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
256
|
+
const result = analyzeEnvWrite({ toolName: input?.tool_name, command: input?.tool_input?.command });
|
|
257
|
+
|
|
258
|
+
appendHookActivity({
|
|
259
|
+
event: 'PreToolUse',
|
|
260
|
+
script: 'env-write-lint',
|
|
261
|
+
outcome: result?.block ? (ENV_WRITE_MODE === 'observe' ? 'observe' : 'deny') : 'ran',
|
|
262
|
+
action: result?.block ? result.reason : 'clean',
|
|
263
|
+
reasonId: result?.reasonId,
|
|
264
|
+
tool: input?.tool_name,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
if (!result?.block) return { denied: false };
|
|
268
|
+
|
|
269
|
+
if (ENV_WRITE_MODE === 'observe') {
|
|
270
|
+
stderr.write(`[mmi-env-write] would-block: ${result.reason}\n`);
|
|
271
|
+
return { denied: false };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const reason = expectedProtectionReason(
|
|
275
|
+
result.reason,
|
|
276
|
+
input,
|
|
277
|
+
(segment) => analyzeEnvWrite({ toolName: input?.tool_name, command: segment }),
|
|
278
|
+
(segmentResult) => segmentResult?.reasonId === 'env_write_apply_patch'
|
|
279
|
+
? 'environment-file patch write'
|
|
280
|
+
: 'environment-file write',
|
|
281
|
+
);
|
|
282
|
+
stdout.write(preToolUseDeny(reason) + '\n');
|
|
283
|
+
return { denied: true };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function runSecretEchoLint(input, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
287
|
+
if (SECRET_ECHO_MODE === 'off') return { denied: false };
|
|
288
|
+
const result = analyzeSecretEcho(input?.tool_input?.command);
|
|
289
|
+
|
|
290
|
+
appendHookActivity({
|
|
291
|
+
event: 'PreToolUse',
|
|
292
|
+
script: 'secret-echo-lint',
|
|
293
|
+
outcome: result?.block ? (SECRET_ECHO_MODE === 'block' ? 'deny' : 'observe') : 'ran',
|
|
294
|
+
action: result?.block ? result.reason : 'clean',
|
|
295
|
+
tool: input?.tool_name,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
if (!result?.block) return { denied: false };
|
|
299
|
+
|
|
300
|
+
if (SECRET_ECHO_MODE !== 'block') {
|
|
301
|
+
stderr.write(`[mmi-secret-echo-lint] would-block: ${result.reason}\n`);
|
|
302
|
+
return { denied: false };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const reason = expectedProtectionReason(
|
|
306
|
+
result.reason,
|
|
307
|
+
input,
|
|
308
|
+
analyzeSecretEcho,
|
|
309
|
+
(segmentResult) => /dumps all environment variables/i.test(segmentResult?.reason ?? '')
|
|
310
|
+
? 'environment enumeration'
|
|
311
|
+
: 'secret-value output',
|
|
312
|
+
);
|
|
313
|
+
stdout.write(preToolUseDeny(reason) + '\n');
|
|
314
|
+
return { denied: true };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function runOperatorInputGuard(input, { stdout = process.stdout } = {}) {
|
|
318
|
+
const result = analyzeOperatorInput(input);
|
|
319
|
+
if (!result) return { denied: false };
|
|
320
|
+
const recovery = result.reasonId === 'operator_input_rg_wildcard_path'
|
|
321
|
+
? '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.'
|
|
322
|
+
: '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.';
|
|
323
|
+
const reason = `Entire compound tool call was cancelled before execution; safe sibling segments did not run. `
|
|
324
|
+
+ `This is an expected operator-input refusal, not a tool defect. Rejected segment ${result.ordinal}: `
|
|
325
|
+
+ `${result.semanticClass}. ${recovery}`;
|
|
326
|
+
appendHookActivity({
|
|
327
|
+
event: 'PreToolUse',
|
|
328
|
+
script: OPERATOR_GATE_NAME,
|
|
329
|
+
outcome: 'deny',
|
|
330
|
+
action: reason,
|
|
331
|
+
reasonId: result.reasonId,
|
|
332
|
+
tool: input?.tool_name,
|
|
333
|
+
});
|
|
334
|
+
stdout.write(preToolUseDeny(reason) + '\n');
|
|
335
|
+
return { denied: true };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function runCommandLadder(input, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
339
|
+
recordGateSuccess(GATE_NAME);
|
|
340
|
+
|
|
341
|
+
const decision = decideCommandLadder({ toolName: input?.tool_name, command: input?.tool_input?.command });
|
|
342
|
+
|
|
343
|
+
appendHookActivity({
|
|
344
|
+
event: 'PreToolUse',
|
|
345
|
+
script: GATE_NAME,
|
|
346
|
+
outcome: decision.action === 'allow' ? 'ran' : decision.action,
|
|
347
|
+
action: decision.reason ?? 'clean',
|
|
348
|
+
reasonId: decision.reasonId,
|
|
349
|
+
tool: input?.tool_name,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
if (decision.action === 'bypass') {
|
|
353
|
+
stderr.write(
|
|
354
|
+
`[mmi-ladder] BYPASS ${new Date().toISOString()} ${matchedVerb(decision.reasonId)} ` +
|
|
355
|
+
`(covered by ${decision.replacement}); MMI_ALLOW_RAW_GH set — allowing raw gh\n`,
|
|
356
|
+
);
|
|
357
|
+
} else if (decision.action === 'observe') {
|
|
358
|
+
stderr.write(`[mmi-ladder] would-block: ${decision.reason}\n`);
|
|
359
|
+
} else if (decision.action === 'deny') {
|
|
360
|
+
stdout.write(preToolUseDeny(decision.reason) + '\n');
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** #4118: exported as `runHookGate` too — the uniform in-process entry hook-run.mjs imports instead of
|
|
365
|
+
* booting a second node. `input` is the buffered payload when the runner already drained stdin. */
|
|
366
|
+
export async function runPreToolUseShellGates({ input: buffered, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
367
|
+
let input;
|
|
368
|
+
try {
|
|
369
|
+
input = await readHookInput(buffered);
|
|
370
|
+
} catch {
|
|
371
|
+
// Unreadable/absent payload = out-of-contract host (Cursor's Claude-plugin import, #2992):
|
|
372
|
+
// fail open without counting a crash. Post-parse crashes below stay fail-closed (#2598).
|
|
373
|
+
const res = handleMissingHookInput(GATE_NAME);
|
|
374
|
+
if (res.stdout) stdout.write(res.stdout);
|
|
375
|
+
if (res.stderr) stderr.write(res.stderr);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const command = input?.tool_input?.command;
|
|
380
|
+
if (typeof command === 'string' && command.length > 0 && !SHELL_META_CHARS.test(command)) {
|
|
381
|
+
const trimmed = command.trim();
|
|
382
|
+
if (FAST_PATH_COMMANDS.has(trimmed)) {
|
|
383
|
+
recordGateSuccess(GATE_NAME);
|
|
384
|
+
appendHookActivity({
|
|
385
|
+
event: 'PreToolUse',
|
|
386
|
+
script: GATE_NAME,
|
|
387
|
+
outcome: 'ran',
|
|
388
|
+
action: 'fast-path allow (trivially-safe read-only)',
|
|
389
|
+
tool: input?.tool_name,
|
|
390
|
+
});
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const envWrite = runEnvWriteLint(input, { stdout, stderr });
|
|
396
|
+
if (envWrite.denied) return;
|
|
397
|
+
const echo = runSecretEchoLint(input, { stdout, stderr });
|
|
398
|
+
if (echo.denied) return;
|
|
399
|
+
const operatorInput = runOperatorInputGuard(input, { stdout });
|
|
400
|
+
if (operatorInput.denied) return;
|
|
401
|
+
runCommandLadder(input, { stdout, stderr });
|
|
402
|
+
// Validate-hook (#2691): a mmi-cli WRITE gets a `--validate-only` pre-flight so bad flags/enums surface
|
|
403
|
+
// as an advisory before the real write spends a failed round-trip. ADVISORY ONLY (stderr, never a deny),
|
|
404
|
+
// fail-open, and self-gated to a simple mmi-cli command — never touches general shell.
|
|
405
|
+
try {
|
|
406
|
+
runValidateAdvisory(input, { stderr });
|
|
407
|
+
} catch {
|
|
408
|
+
/* fail-open: a validate probe must never block a tool call */
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export { runPreToolUseShellGates as runHookGate };
|
|
413
|
+
|
|
414
|
+
if (
|
|
415
|
+
process.argv[1] &&
|
|
416
|
+
(process.argv[1].endsWith('pretooluse-shell-gates.mjs') ||
|
|
417
|
+
process.argv[1].replace(/\\/g, '/').endsWith('scripts/pretooluse-shell-gates.mjs'))
|
|
418
|
+
) {
|
|
419
|
+
runPreToolUseShellGates().catch(() => {
|
|
420
|
+
const res = handleGateCrash(GATE_NAME);
|
|
421
|
+
if (res.stdout) process.stdout.write(res.stdout);
|
|
422
|
+
if (res.stderr) process.stderr.write(res.stderr);
|
|
423
|
+
}).finally(() => process.exit(0));
|
|
424
|
+
}
|
|
@@ -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/secret-redact.mjs SECRET_ASSIGNMENT.
|
|
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
|
+
}
|