@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.
Files changed (45) hide show
  1. package/.codex-plugin/plugin.json +30 -0
  2. package/bin/mmi-cli +6 -0
  3. package/bin/mmi-cli.cmd +3 -0
  4. package/bin/mmi-hook +2 -0
  5. package/bin/mmi-hook-console.cmd +10 -0
  6. package/bin/mmi-hook.exe +0 -0
  7. package/hooks/codex-hooks.json +41 -0
  8. package/package.json +21 -0
  9. package/scripts/command-ladder-core.mjs +334 -0
  10. package/scripts/command-ladder-gate.mjs +126 -0
  11. package/scripts/deny-gate-crash.mjs +179 -0
  12. package/scripts/edit-tool-paths.mjs +113 -0
  13. package/scripts/env-write-lint.mjs +137 -0
  14. package/scripts/hook-io.mjs +22 -0
  15. package/scripts/hook-policy.mjs +73 -0
  16. package/scripts/hook-run.mjs +437 -0
  17. package/scripts/hook-trace.mjs +151 -0
  18. package/scripts/pretooluse-shell-gates.mjs +424 -0
  19. package/scripts/secret-echo-lint.mjs +177 -0
  20. package/scripts/secret-redact.mjs +552 -0
  21. package/scripts/throttle-core.mjs +324 -0
  22. package/scripts/validate-hook.mjs +156 -0
  23. package/scripts/vault-edit-gate.mjs +94 -0
  24. package/skills/bootstrap/SKILL.md +550 -0
  25. package/skills/bootstrap/seeds/Dockerfile.template +30 -0
  26. package/skills/bootstrap/seeds/README.template.md +37 -0
  27. package/skills/bootstrap/seeds/architecture.template.md +34 -0
  28. package/skills/bootstrap/seeds/decisions-readme.template.md +45 -0
  29. package/skills/bootstrap/seeds/docker-compose.template.yml +26 -0
  30. package/skills/bootstrap/seeds/gate.template.yml +85 -0
  31. package/skills/bootstrap/seeds/google-login.template.md +33 -0
  32. package/skills/bootstrap/seeds/manifest.json +26 -0
  33. package/skills/bootstrap/seeds/mmi-product-required-checks.template.json +23 -0
  34. package/skills/browser-automation/SKILL.md +95 -0
  35. package/skills/epic/SKILL.md +104 -0
  36. package/skills/hotfix/SKILL.md +165 -0
  37. package/skills/mmi/SKILL.md +404 -0
  38. package/skills/mmi-doctor/SKILL.md +63 -0
  39. package/skills/onboard/SKILL.md +85 -0
  40. package/skills/rcand/SKILL.md +208 -0
  41. package/skills/release/SKILL.md +599 -0
  42. package/skills/resume/SKILL.md +90 -0
  43. package/skills/secrets/SKILL.md +159 -0
  44. package/skills/stage/SKILL.md +153 -0
  45. package/skills/worktree/SKILL.md +151 -0
@@ -0,0 +1,179 @@
1
+ // Shared fail-closed crash path for PreToolUse deny gates (#2598).
2
+ // Advisory hooks stay fail-open; this file is only for command-ladder/vault gates.
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { readHookInput } from './hook-io.mjs';
8
+ import { appendHookActivity } from './hook-trace.mjs';
9
+
10
+ const THRESHOLD = 3;
11
+ const ESCAPE_HATCH = 'MMI_GATES_FAIL_OPEN=1';
12
+
13
+ /** A bypass env var is on for any value except unset / empty / `0` / `false` / `no` / `off`. */
14
+ export function isFailOpenOn(value) {
15
+ if (value === undefined || value === null) return false;
16
+ const v = String(value).trim().toLowerCase();
17
+ return v !== '' && v !== '0' && v !== 'false' && v !== 'no' && v !== 'off';
18
+ }
19
+
20
+ function safeId(value) {
21
+ return createHash('sha256').update(String(value)).digest('hex').slice(0, 16);
22
+ }
23
+
24
+ function sessionId(env) {
25
+ // Fallback is cwd only — never ppid (#2992). Each hook invocation spawns through a fresh
26
+ // intermediate shell, so a ppid-keyed counter wrote a new state file per crash and the #2598
27
+ // breaker could never reach its threshold (permanently "Crash 1/3").
28
+ //
29
+ // #3591: `CLAUDE_CODE_SESSION_ID` is the name Claude Code actually exports; `CLAUDE_SESSION_ID`
30
+ // never resolved, so every Claude Code session fell straight through to the cwd fallback. That is
31
+ // not merely a weaker key, it is the WRONG one in both directions: two sessions working the same
32
+ // checkout shared one crash counter (one session's crashes trip the other's breaker), while one
33
+ // session moving between two checkouts got two counters and could never reach the threshold — the
34
+ // exact failure #2992 fixed for ppid, reintroduced through a name that does not exist.
35
+ return env.MMI_GATE_SESSION_ID || env.CLAUDE_SESSION_ID || env.CLAUDE_CODE_SESSION_ID || process.cwd();
36
+ }
37
+
38
+ function statePath(gateName, env) {
39
+ const root = env.MMI_GATE_STATE_DIR || join(tmpdir(), 'mmi-gate-crashes');
40
+ try {
41
+ mkdirSync(root, { recursive: true });
42
+ } catch {
43
+ return null;
44
+ }
45
+ return join(root, `${gateName}-${safeId(sessionId(env))}.json`);
46
+ }
47
+
48
+ function readState(path) {
49
+ if (!path) return { crashes: 0 };
50
+ if (!existsSync(path)) return { crashes: 0 };
51
+ try {
52
+ const state = JSON.parse(readFileSync(path, 'utf8'));
53
+ return { crashes: Number.isInteger(state.crashes) ? state.crashes : 0 };
54
+ } catch {
55
+ return { crashes: 0 };
56
+ }
57
+ }
58
+
59
+ function writeState(path, state) {
60
+ if (!path) return;
61
+ try {
62
+ writeFileSync(path, JSON.stringify(state), 'utf8');
63
+ } catch {
64
+ // Keep the crash path non-crashing; fail-closed without persistent breaker state.
65
+ }
66
+ }
67
+
68
+ export function gateCrashReason(gateName, crashes) {
69
+ return (
70
+ `${gateName} gate crashed — re-run or set escape hatch ${ESCAPE_HATCH}. ` +
71
+ `Crash ${crashes}/${THRESHOLD}; after ${THRESHOLD} consecutive crashes this gate degrades fail-open with a warning banner.`
72
+ );
73
+ }
74
+
75
+ export function gateBreakerWarning(gateName, crashes) {
76
+ return (
77
+ `[mmi-hook] ${gateName} gate circuit breaker open after ${crashes} consecutive crashes — ` +
78
+ `failing open with degraded protection visible. Re-run after fixing the gate, or set ${ESCAPE_HATCH} for an explicit per-session escape hatch.\n`
79
+ );
80
+ }
81
+
82
+ export function gateEscapeWarning(gateName) {
83
+ return `[mmi-hook] ${gateName} gate crashed; ${ESCAPE_HATCH} is set — failing open for this session.\n`;
84
+ }
85
+
86
+ export function denyDecision(reason) {
87
+ return JSON.stringify({
88
+ hookSpecificOutput: {
89
+ hookEventName: 'PreToolUse',
90
+ permissionDecision: 'deny',
91
+ permissionDecisionReason: reason,
92
+ },
93
+ });
94
+ }
95
+
96
+ export function missingInputWarning(gateName) {
97
+ return (
98
+ `[mmi-hook] ${gateName} gate: no readable Claude hook payload on stdin — ` +
99
+ `gate not evaluated on this host; failing open (#2992).\n`
100
+ );
101
+ }
102
+
103
+ /** Out-of-contract invocation (#2992): stdin carried no readable Claude hook payload. The gates speak
104
+ * the Claude payload shape (which Kimi also delivers verbatim); a host that runs the hook without
105
+ * delivering it (Cursor's Claude-plugin import) gets a fail-open skip, not a fail-closed deny — whoever controls stdin
106
+ * already controls the MMI_GATES_FAIL_OPEN env hatch, so denying here adds no protection and
107
+ * permanently bricks the foreign host. Not counted as a crash: the breaker stays reserved for
108
+ * genuine gate failures on a valid payload. */
109
+ export function handleMissingHookInput(gateName) {
110
+ appendHookActivity({
111
+ event: 'PreToolUse',
112
+ script: gateName,
113
+ outcome: 'skipped',
114
+ action: 'no readable Claude hook payload on stdin; gate fails open (#2992)',
115
+ });
116
+ return { stdout: '', stderr: missingInputWarning(gateName), degraded: false, crashes: 0 };
117
+ }
118
+
119
+ export function recordGateSuccess(gateName, env = process.env) {
120
+ const path = statePath(gateName, env);
121
+ writeState(path, { crashes: 0 });
122
+ }
123
+
124
+ export function handleGateCrash(gateName, env = process.env) {
125
+ if (isFailOpenOn(env.MMI_GATES_FAIL_OPEN)) {
126
+ appendHookActivity({
127
+ event: 'PreToolUse',
128
+ script: gateName,
129
+ outcome: 'failed',
130
+ action: `gate crashed; failing open (${ESCAPE_HATCH} set)`,
131
+ });
132
+ return { stdout: '', stderr: gateEscapeWarning(gateName), degraded: true, crashes: 0 };
133
+ }
134
+
135
+ const path = statePath(gateName, env);
136
+ const state = readState(path);
137
+ const crashes = state.crashes + 1;
138
+ writeState(path, { crashes });
139
+
140
+ if (crashes >= THRESHOLD) {
141
+ appendHookActivity({
142
+ event: 'PreToolUse',
143
+ script: gateName,
144
+ outcome: 'failed',
145
+ action: `gate crashed ${crashes}/${THRESHOLD}; circuit breaker open, failing open degraded`,
146
+ });
147
+ return { stdout: '', stderr: gateBreakerWarning(gateName, crashes), degraded: true, crashes };
148
+ }
149
+
150
+ appendHookActivity({
151
+ event: 'PreToolUse',
152
+ script: gateName,
153
+ outcome: 'failed',
154
+ action: `gate crashed ${crashes}/${THRESHOLD}; failing closed`,
155
+ });
156
+ return { stdout: denyDecision(gateCrashReason(gateName, crashes)) + '\n', stderr: '', degraded: false, crashes };
157
+ }
158
+
159
+ async function main() {
160
+ const gateName = process.argv[2] || 'unknown';
161
+ try {
162
+ await readHookInput();
163
+ } catch {
164
+ // Input is unused for session keying; still drain stdin when present.
165
+ }
166
+
167
+ const res = handleGateCrash(gateName);
168
+ if (res.stdout) process.stdout.write(res.stdout);
169
+ if (res.stderr) process.stderr.write(res.stderr);
170
+ process.exit(0);
171
+ }
172
+
173
+ if (
174
+ process.argv[1] &&
175
+ (process.argv[1].endsWith('deny-gate-crash.mjs') ||
176
+ process.argv[1].replace(/\\/g, '/').endsWith('scripts/deny-gate-crash.mjs'))
177
+ ) {
178
+ main().catch(() => process.exit(0));
179
+ }
@@ -0,0 +1,113 @@
1
+ // The files one edit-shaped tool call touches, across BOTH host contracts (#3563).
2
+ //
3
+ // Claude sends `Edit`/`Write` with `tool_input.file_path`. Codex also sends `apply_patch`, and puts the
4
+ // whole patch in `tool_input.command` — there is no `file_path` at all. A gate that reads only
5
+ // `file_path` therefore sees nothing to check and exits 0: on Codex the vault-edit gate FAILED OPEN and
6
+ // `apply_patch` could create a `.env`, while the guide claimed the path was protected (cross-vendor
7
+ // check, openai-sol). Matching `apply_patch` in a hook manifest does not rewrite the payload.
8
+ //
9
+ // One extractor, used by every edit-shaped hook, so a new host contract is taught in one place.
10
+
11
+ /** Tool names that mean "this call writes files". */
12
+ export const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'apply_patch', 'ApplyPatch']);
13
+
14
+ export function isEditTool(toolName) {
15
+ return typeof toolName === 'string' && EDIT_TOOL_NAMES.has(toolName);
16
+ }
17
+
18
+ // `*** Add File: path`, `*** Update File: path`, `*** Delete File: path`, and the `*** Move to: path`
19
+ // destination of a rename. Case-insensitive on the verb; the path runs to end of line.
20
+ const PATCH_TOUCH = /^\*\*\*\s+(add|update|delete)\s+file:\s*(.+?)\s*$/gim;
21
+ const PATCH_MOVE = /^\*\*\*\s+move\s+to:\s*(.+?)\s*$/gim;
22
+
23
+ function scan(re, patch, onMatch) {
24
+ re.lastIndex = 0;
25
+ for (let m = re.exec(patch); m; m = re.exec(patch)) onMatch(m);
26
+ }
27
+
28
+ /** Every path an `apply_patch` body TOUCHES — created, updated, deleted, or renamed onto. */
29
+ export function applyPatchTargets(patch) {
30
+ if (typeof patch !== 'string' || !patch) return [];
31
+ const out = [];
32
+ scan(PATCH_TOUCH, patch, (m) => out.push(m[2]));
33
+ scan(PATCH_MOVE, patch, (m) => out.push(m[1]));
34
+ return out;
35
+ }
36
+
37
+ /** Only the paths a patch CREATES or WRITES — `Add`, `Update`, and a rename destination. A `Delete File:`
38
+ * target is excluded: the no-env floor blocks creating a `.env`, never removing one, and treating every
39
+ * extracted target as a write turned a delete-only patch into a denial (cross-vendor check, openai-sol). */
40
+ export function applyPatchWriteTargets(patch) {
41
+ if (typeof patch !== 'string' || !patch) return [];
42
+ const out = [];
43
+ scan(PATCH_TOUCH, patch, (m) => { if (m[1].toLowerCase() !== 'delete') out.push(m[2]); });
44
+ scan(PATCH_MOVE, patch, (m) => out.push(m[1]));
45
+ return out;
46
+ }
47
+
48
+ /** True when a shell command actually INVOKES `apply_patch` — as the COMMAND of some segment, not merely
49
+ * as a word in it.
50
+ *
51
+ * Patch-looking text is not a patch: `cat <<EOF … *** Add File: .env … EOF` writes nothing. But a bare
52
+ * word-boundary test was wrong both ways — `"apply_patch" <<EOF` (quoted) and `/usr/bin/apply_patch`
53
+ * slipped through, while `echo apply_patch <<EOF` was denied for running `echo` (cross-vendor check,
54
+ * openai-sol). So: split into segments, take each segment's first token, strip quotes and any directory
55
+ * prefix, and skip the leading `env`/`command`/`exec`/`sudo` wrappers that only prefix a real command. */
56
+ const INVOKE_PREFIXES = new Set(['env', 'command', 'exec', 'sudo', 'nohup', 'time']);
57
+ // `bash -lc "apply_patch …"` is how Codex delivers a patch through a shell tool: the real command sits
58
+ // inside the wrapper's argument, so the wrapper and its flags are stepped over too.
59
+ const SHELL_WRAPPERS = new Set(['bash', 'sh', 'zsh', 'dash', 'pwsh', 'powershell', 'cmd', 'cmd.exe']);
60
+
61
+ export function invokesApplyPatch(command) {
62
+ if (typeof command !== 'string' || !command) return false;
63
+ // Redirects may abut the command word — `apply_patch<<'EOF'` and `apply_patch<<<"$P"` are both valid
64
+ // shell, and both hid the command name from a whitespace tokenizer (cross-vendor check, kimi-k3).
65
+ // Separating the operators first is what makes the command word its own token in every form.
66
+ const spaced = command.replace(/([<>]+)/g, ' $1 ');
67
+ for (const segment of spaced.split(/\|\||&&|[;&|\n]|<<-?\s*['"]?\w+['"]?/)) {
68
+ let wrapped = false;
69
+ for (const raw of segment.trim().split(/\s+/).filter(Boolean)) {
70
+ const bare = raw.replace(/^['"]+|['"]+$/g, '').replace(/\\/g, '/').split('/').pop() ?? '';
71
+ if (bare === 'apply_patch') return true;
72
+ // Step over wrappers, their env assignments, and — once inside a shell wrapper — its flags.
73
+ // Anything else in command position means this segment runs something other than apply_patch.
74
+ if (SHELL_WRAPPERS.has(bare.toLowerCase())) { wrapped = true; continue; }
75
+ if (INVOKE_PREFIXES.has(bare) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(raw)) continue;
76
+ if (wrapped && /^[-/]/.test(raw)) continue;
77
+ break;
78
+ }
79
+ }
80
+ return false;
81
+ }
82
+
83
+ /**
84
+ * Every file path a tool call writes to. Returns `[]` for non-edit tools and unreadable payloads —
85
+ * callers treat an empty list as "nothing to check", exactly as they treated a missing `file_path`.
86
+ * @param {{ tool_name?: unknown, tool_input?: Record<string, unknown> }} input raw hook payload
87
+ * @returns {string[]}
88
+ */
89
+ export function editedPaths(input) {
90
+ const toolName = input?.tool_name;
91
+ if (!isEditTool(toolName)) return [];
92
+ return collectEditedPaths(input?.tool_input ?? {});
93
+ }
94
+
95
+ /** Every write target in a tool_input, from a direct path AND from any patch body it carries.
96
+ *
97
+ * Union, never first-match. Returning early on `file_path` meant an `apply_patch` carrying a harmless
98
+ * `file_path: "safe.txt"` alongside `*** Add File: .env` reported only `safe.txt`, and the vault gate
99
+ * allowed it — a fail-open reachable by adding one benign field (cross-vendor check, openai-sol). A
100
+ * gate must see every path a call writes, not the first one it finds. */
101
+ export function collectEditedPaths(toolInput) {
102
+ const ti = toolInput ?? {};
103
+ const out = [];
104
+ for (const direct of [ti.file_path, ti.path]) {
105
+ if (typeof direct === 'string' && direct) out.push(direct);
106
+ }
107
+ // Codex `apply_patch`: the patch text arrives as `command` (or `patch`/`input` on some builds).
108
+ // WRITE targets only — the shell path already excluded a `*** Delete File:` and this one did not, so a
109
+ // delete-only patch was allowed through the shell and denied through the tool (cross-vendor check,
110
+ // openai-sol). One rule, both doors.
111
+ for (const field of ['command', 'patch', 'input']) out.push(...applyPatchWriteTargets(ti[field]));
112
+ return [...new Set(out)];
113
+ }
@@ -0,0 +1,137 @@
1
+ // No-env floor, shell surface (#2663): deny Bash/PowerShell/shell commands that CREATE a real .env file.
2
+ // #3563 adds the patch form: Codex accepts `apply_patch <<'EOF' … EOF` through its SHELL tools, which the
3
+ // Edit|Write|apply_patch matcher never sees, so patch bodies are read here too.
4
+ // The Edit/Write vault-edit-gate already blocks hand-writing a .env through the file tools; this closes the
5
+ // shell bypass — `cp .env.example .env`, `echo ... > .env`, `tee .env`, `Out-File .env`, etc. Reads
6
+ // (`cat .env`, `source .env`, `< .env`), deletes (`rm .env`), and the allowed template names
7
+ // (`.env.example`, `*.template`) are NOT blocked. Pure detection — exported for tests, no IO here.
8
+
9
+ import { applyPatchWriteTargets, invokesApplyPatch } from './edit-tool-paths.mjs';
10
+
11
+ const DENY_REASON =
12
+ 'No .env — the org is env-free. This command creates a .env file; don\'t. Deliver config vault-native '
13
+ + '(`mmi-cli stage` injects secrets into the process env; `mmi-cli vault secrets` manages names). If something '
14
+ + 'genuinely cannot proceed without a .env, STOP and consult the board (file an issue) — do not create it.';
15
+
16
+ /** True for a token that names a REAL .env file (`.env` or `.env.<suffix>`), excluding the allowed template
17
+ * names (`.env.example`, anything ending `.example`, anything containing `.template`). Strips surrounding
18
+ * quotes and any directory prefix. */
19
+ export function isRealEnvToken(token) {
20
+ if (!token || typeof token !== 'string') return false;
21
+ const unquoted = token.replace(/^['"]/, '').replace(/['"]$/, '');
22
+ const base = unquoted.replace(/\\/g, '/').split('/').pop() ?? '';
23
+ // Win32 strips trailing dots and spaces from the final path component, so `.env.` and `.env ` both
24
+ // CREATE `.env` — through any writer that goes via Win32 path normalisation, which includes Rust's
25
+ // std::fs, the writer behind Codex's apply_patch. Node/libuv uses verbatim paths and is the exception,
26
+ // which is why this never showed up from a Node probe (cross-vendor check, kimi-k3).
27
+ const b = base.replace(/[. ]+$/, '').toLowerCase() || base.toLowerCase();
28
+ if (!/^\.env(\.[^/]+)?$/.test(b)) return false;
29
+ if (b === '.env.example' || b.endsWith('.example') || b.includes('.template')) return false;
30
+ return true;
31
+ }
32
+
33
+ // A path-ish token: no shell metacharacters. Used to pull redirect/cmdlet targets out of a command.
34
+ const PATH_TOKEN = "(['\"]?[^\\s'\"|;&<>()]+['\"]?)";
35
+
36
+ // Redirection to a target: >, >>, 1>, 2>, &>, >| — the classic `echo x > .env` / `cat > .env` create.
37
+ const REDIRECT_RE = new RegExp(`(?:\\d?>>?\\|?|&>)\\s*${PATH_TOKEN}`, 'g');
38
+ // tee / Tee-Object (with any flags) writing a file.
39
+ const TEE_RE = new RegExp(`\\b(?:tee|Tee-Object)\\b(?:\\s+-{1,2}\\S+)*\\s+${PATH_TOKEN}`, 'gi');
40
+ // PowerShell content writers targeting a file (positional or -Path/-FilePath/-LiteralPath).
41
+ const PS_WRITE_RE = new RegExp(
42
+ `\\b(?:Out-File|Set-Content|Add-Content|New-Item)\\b[^|;&]*?(?:-(?:Path|FilePath|LiteralPath)\\s+)?${PATH_TOKEN}`,
43
+ 'gi',
44
+ );
45
+ // Copy/move/link verbs — the destination is (conventionally) the LAST path token; block when it is a real .env.
46
+ // `copy`/`move` are the cmd.exe spellings AND the default PowerShell aliases for Copy-Item/Move-Item, so
47
+ // `copy .env.example .env` reached disk past every analyzer (pre-existing since #2663; found by the #3563
48
+ // cross-vendor check, openai-sol). `xcopy`/`robocopy` are the other two Windows spellings.
49
+ const COPY_VERBS = /^(?:cp|mv|install|ln|rsync|copy|move|xcopy|robocopy|Copy-Item|Move-Item)$/i;
50
+
51
+ /** Inspect one command for a real-.env CREATE. Returns { block, reason, reasonId } or null (clean). */
52
+ export function analyze({ toolName, command } = {}) {
53
+ void toolName;
54
+ if (!command || typeof command !== 'string') return null;
55
+
56
+ // #3563: a patch can arrive through the SHELL, not just the `apply_patch` tool — Codex accepts
57
+ // `apply_patch <<'EOF' … EOF` and `bash -lc "apply_patch <<'EOF' …"` via its shell tools. Those calls
58
+ // carry `tool_name: "shell"`, so the Edit|Write|apply_patch matcher never fires the vault gate, and the
59
+ // shell lints below see no redirect, tee or copy to catch. Same matcher-vs-payload gap as the tool
60
+ // form, one door over (cross-vendor check, kimi-k3), so the shell gate reads patch bodies too.
61
+ //
62
+ // Two precision rules, both from a false deny: the command must actually INVOKE `apply_patch` (patch
63
+ // text inside a `cat` heredoc writes nothing), and only WRITE targets count (a `*** Delete File: .env`
64
+ // removes a .env, which this floor has never blocked).
65
+ if (invokesApplyPatch(command)) {
66
+ for (const target of applyPatchWriteTargets(command)) {
67
+ if (isRealEnvToken(target)) return deny('env_write_apply_patch');
68
+ }
69
+ }
70
+
71
+ for (const m of command.matchAll(REDIRECT_RE)) {
72
+ if (isRealEnvToken(m[1])) return deny('env_write_redirect');
73
+ }
74
+ for (const m of command.matchAll(TEE_RE)) {
75
+ if (isRealEnvToken(m[1])) return deny('env_write_tee');
76
+ }
77
+ for (const m of command.matchAll(PS_WRITE_RE)) {
78
+ if (isRealEnvToken(m[1])) return deny('env_write_ps');
79
+ }
80
+
81
+ // Copy/move/link: only the DESTINATION (last path token in the segment) creating a real .env is a write —
82
+ // `cat .env` (read) stays allowed. A COPY of a .env is itself a .env-shaped secret file, so
83
+ // `cp .env .env.bak` is blocked too; an earlier version of this comment promised otherwise and the code
84
+ // never did (cross-vendor check, openai-sol). Under "no .env ever" the code is right.
85
+ //
86
+ // The verb is found ANYWHERE in the segment, not only at token 0: `cmd /c copy …`, `sudo cp …` and
87
+ // `bash -lc "cp …"` all put a wrapper first. And the destination is not always last — PowerShell takes
88
+ // `-Destination`, and `cp <file> <dir>/` names the directory while the basename comes from the source
89
+ // (all four ALLOWED before this; cross-vendor check, openai-sol).
90
+ for (const raw of command.split(/(?:\|\||&&|[;&|])/)) {
91
+ const seg = raw.trim();
92
+ if (!seg) continue;
93
+ const tokens = seg.split(/\s+/).filter(Boolean);
94
+ const verbAt = tokens.findIndex((t) => COPY_VERBS.test(t.replace(/\\/g, '/').split('/').pop() ?? ''));
95
+ if (verbAt === -1) continue;
96
+ const after = tokens.slice(verbAt + 1);
97
+ // Named destination wins when present (`Copy-Item -Destination .env -Path x` puts it first).
98
+ const namedAt = after.findIndex((t) => /^-(?:Destination|dest)$/i.test(t));
99
+ if (namedAt !== -1 && isRealEnvToken(after[namedAt + 1])) return deny('env_write_copy');
100
+ const paths = after.filter((t) => !t.startsWith('-'));
101
+ if (isRealEnvToken(paths[paths.length - 1])) return deny('env_write_copy');
102
+ // `cp .env dir/` lands `dir/.env`: a directory destination takes the SOURCE's basename.
103
+ const dest = paths[paths.length - 1];
104
+ if (paths.length > 1 && dest && /[\\/]$/.test(dest) && paths.slice(0, -1).some((p) => isRealEnvToken(p))) {
105
+ return deny('env_write_copy');
106
+ }
107
+ }
108
+
109
+ // `git checkout <ref> -- <path>` / `git restore --source <ref> <path>` materialise a file from history.
110
+ for (const raw of command.split(/(?:\|\||&&|[;&|])/)) {
111
+ const tokens = raw.trim().split(/\s+/).filter(Boolean);
112
+ const gitAt = tokens.findIndex((t) => (t.replace(/\\/g, '/').split('/').pop() ?? '') === 'git');
113
+ if (gitAt === -1 || !/^(?:checkout|restore)$/i.test(tokens[gitAt + 1] ?? '')) continue;
114
+ if (tokens.slice(gitAt + 2).some((t) => isRealEnvToken(t))) return deny('env_write_git_restore');
115
+ }
116
+
117
+ // touch: any non-flag arg that is a real .env token creates the file.
118
+ for (const raw of command.split(/(?:\|\||&&|[;&|])/)) {
119
+ const seg = raw.trim();
120
+ if (!seg) continue;
121
+ const tokens = seg.split(/\s+/).filter(Boolean);
122
+ if (!tokens.length || tokens[0].toLowerCase() !== 'touch') continue;
123
+ const paths = tokens.slice(1).filter((t) => !t.startsWith('-'));
124
+ if (paths.some((p) => isRealEnvToken(p))) return deny('env_write_touch');
125
+ }
126
+
127
+ // dd of=<path>: block when the of= target is a real .env token.
128
+ for (const m of command.matchAll(/\bdd\b[^|;&]*?\bof=(['"]?[^\s'"|;&]+['"]?)/gi)) {
129
+ if (isRealEnvToken(m[1])) return deny('env_write_dd');
130
+ }
131
+
132
+ return null;
133
+ }
134
+
135
+ function deny(reasonId) {
136
+ return { block: true, reason: DENY_REASON, reasonId };
137
+ }
@@ -0,0 +1,22 @@
1
+ // Shared stdin reader for Claude Code hook scripts (#1668).
2
+ // Each hook script receives a JSON payload on stdin. This helper reads it and
3
+ // returns the parsed object. Throws on any read or parse error so callers can
4
+ // catch and exit 0 (hooks must never crash a turn).
5
+ import { createInterface } from 'node:readline';
6
+
7
+ /**
8
+ * Read all stdin lines and JSON-parse them.
9
+ * #4118: the shared runner now runs a gate IN-PROCESS and has already drained stdin, so it passes the
10
+ * buffered payload in. Parsing it here keeps ONE contract — same throw on unreadable input, so every
11
+ * gate's fail-open/fail-closed branch is reached identically whether it was spawned or imported.
12
+ * @param {Buffer|string} [buffered] Payload already read by the caller; stdin is read when absent.
13
+ * @returns {Promise<unknown>} Parsed JSON payload.
14
+ * @throws {Error} On readline or JSON parse failure.
15
+ */
16
+ export async function readHookInput(buffered) {
17
+ if (buffered !== undefined) return JSON.parse(Buffer.isBuffer(buffered) ? buffered.toString('utf8') : String(buffered));
18
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
19
+ const lines = [];
20
+ for await (const line of rl) lines.push(line);
21
+ return JSON.parse(lines.join('\n'));
22
+ }
@@ -0,0 +1,73 @@
1
+ // Canonical MMI hook policy. Host adapters select a named gate; they do not choose scripts,
2
+ // failure posture, timeouts, or response semantics themselves.
3
+
4
+ export const HOOK_POLICY_VERSION = 1;
5
+
6
+ export const HOOK_GATES = Object.freeze({
7
+ 'command-ladder': Object.freeze({
8
+ event: 'PreToolUse',
9
+ script: 'pretooluse-shell-gates.mjs',
10
+ failure: 'closed',
11
+ fallbackGate: 'command-ladder',
12
+ timeoutMs: 10_000,
13
+ }),
14
+ 'vault-edit': Object.freeze({
15
+ event: 'PreToolUse',
16
+ script: 'vault-edit-gate.mjs',
17
+ failure: 'closed',
18
+ fallbackGate: 'vault-edit',
19
+ timeoutMs: 5_000,
20
+ }),
21
+ 'secret-output': Object.freeze({
22
+ event: 'PostToolUse',
23
+ script: 'secret-redact.mjs',
24
+ failure: 'open',
25
+ fallbackGate: null,
26
+ timeoutMs: 5_000,
27
+ }),
28
+ });
29
+
30
+ export const HOOK_SURFACES = Object.freeze({
31
+ claude: Object.freeze({
32
+ lifecycle: 'active',
33
+ rootEnv: Object.freeze(['CLAUDE_PLUGIN_ROOT', 'PLUGIN_ROOT']),
34
+ postToolOutput: 'rewrite',
35
+ finalOutput: 'unsupported',
36
+ }),
37
+ codex: Object.freeze({
38
+ lifecycle: 'active',
39
+ rootEnv: Object.freeze(['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT']),
40
+ postToolOutput: 'detect-only',
41
+ finalOutput: 'unsupported',
42
+ }),
43
+ kimi: Object.freeze({
44
+ lifecycle: 'active',
45
+ rootEnv: Object.freeze(['KIMI_PLUGIN_ROOT', 'PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT']),
46
+ postToolOutput: 'detect-only',
47
+ finalOutput: 'unsupported',
48
+ }),
49
+ cursor: Object.freeze({
50
+ lifecycle: 'active',
51
+ rootEnv: Object.freeze([]),
52
+ postToolOutput: 'detect-only',
53
+ finalOutput: 'unsupported',
54
+ }),
55
+ kilo: Object.freeze({
56
+ lifecycle: 'active',
57
+ rootEnv: Object.freeze(['KILO_PLUGIN_ROOT', 'PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT']),
58
+ postToolOutput: 'rewrite',
59
+ finalOutput: 'rewrite',
60
+ }),
61
+ });
62
+
63
+ export function hookGate(id) {
64
+ const gate = HOOK_GATES[id];
65
+ if (!gate) throw new Error(`unknown MMI hook gate: ${id || '(empty)'}`);
66
+ return gate;
67
+ }
68
+
69
+ export function hookSurface(token) {
70
+ const surface = HOOK_SURFACES[token];
71
+ if (!surface) throw new Error(`unknown MMI hook surface: ${token || '(empty)'}`);
72
+ return surface;
73
+ }