@mutmutco/claude-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 (46) hide show
  1. package/.claude-plugin/plugin.json +11 -0
  2. package/.pi-plugin/package.json +23 -0
  3. package/bin/mmi-cli +6 -0
  4. package/bin/mmi-cli.cmd +3 -0
  5. package/bin/mmi-hook +2 -0
  6. package/bin/mmi-hook-console.cmd +10 -0
  7. package/bin/mmi-hook.exe +0 -0
  8. package/hooks/hooks.json +38 -0
  9. package/package.json +22 -0
  10. package/scripts/command-ladder-core.mjs +334 -0
  11. package/scripts/command-ladder-gate.mjs +126 -0
  12. package/scripts/deny-gate-crash.mjs +179 -0
  13. package/scripts/edit-tool-paths.mjs +113 -0
  14. package/scripts/env-write-lint.mjs +137 -0
  15. package/scripts/hook-io.mjs +22 -0
  16. package/scripts/hook-policy.mjs +73 -0
  17. package/scripts/hook-run.mjs +437 -0
  18. package/scripts/hook-trace.mjs +151 -0
  19. package/scripts/pretooluse-shell-gates.mjs +424 -0
  20. package/scripts/secret-echo-lint.mjs +177 -0
  21. package/scripts/secret-redact.mjs +552 -0
  22. package/scripts/throttle-core.mjs +324 -0
  23. package/scripts/validate-hook.mjs +156 -0
  24. package/scripts/vault-edit-gate.mjs +94 -0
  25. package/skills/bootstrap/SKILL.md +550 -0
  26. package/skills/bootstrap/seeds/Dockerfile.template +30 -0
  27. package/skills/bootstrap/seeds/README.template.md +37 -0
  28. package/skills/bootstrap/seeds/architecture.template.md +34 -0
  29. package/skills/bootstrap/seeds/decisions-readme.template.md +45 -0
  30. package/skills/bootstrap/seeds/docker-compose.template.yml +26 -0
  31. package/skills/bootstrap/seeds/gate.template.yml +85 -0
  32. package/skills/bootstrap/seeds/google-login.template.md +33 -0
  33. package/skills/bootstrap/seeds/manifest.json +26 -0
  34. package/skills/bootstrap/seeds/mmi-product-required-checks.template.json +23 -0
  35. package/skills/browser-automation/SKILL.md +95 -0
  36. package/skills/epic/SKILL.md +104 -0
  37. package/skills/hotfix/SKILL.md +165 -0
  38. package/skills/mmi/SKILL.md +404 -0
  39. package/skills/mmi-doctor/SKILL.md +63 -0
  40. package/skills/onboard/SKILL.md +85 -0
  41. package/skills/rcand/SKILL.md +208 -0
  42. package/skills/release/SKILL.md +599 -0
  43. package/skills/resume/SKILL.md +90 -0
  44. package/skills/secrets/SKILL.md +159 -0
  45. package/skills/stage/SKILL.md +153 -0
  46. package/skills/worktree/SKILL.md +151 -0
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "mmi",
3
+ "displayName": "MMI",
4
+ "version": "3.131.0",
5
+ "description": "MMI workflow skills and org gates delivery.",
6
+ "author": {
7
+ "name": "MMI Future",
8
+ "email": "69869555+jervaise@users.noreply.github.com"
9
+ },
10
+ "homepage": "https://github.com/mutmutco/MMI-Hub"
11
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "mmi",
3
+ "version": "3.131.0",
4
+ "description": "MMI workflow skills and org gates delivery.",
5
+ "author": {
6
+ "name": "MMI Future",
7
+ "email": "69869555+jervaise@users.noreply.github.com"
8
+ },
9
+ "homepage": "https://github.com/mutmutco/MMI-Hub",
10
+ "private": true,
11
+ "type": "module",
12
+ "keywords": [
13
+ "mmi",
14
+ "mutmutco",
15
+ "pi-package",
16
+ "jervcode"
17
+ ],
18
+ "pi": {
19
+ "skills": [
20
+ "../skills"
21
+ ]
22
+ }
23
+ }
package/bin/mmi-cli ADDED
@@ -0,0 +1,6 @@
1
+ #!/bin/sh
2
+ # MMI CLI launcher. Shipped in the plugin's bin/, which Claude Code auto-adds to the agent's
3
+ # Bash PATH — so `mmi-cli` is a bare command in every session the moment the plugin is enabled,
4
+ # with no hook, no PATH mutation, no install step. Resolves the bundled engine relative to its
5
+ # own location, so it stays correct across plugin updates (version-pinned root, stable layout).
6
+ exec node "$(dirname "$0")/../cli/dist/index.cjs" "$@"
@@ -0,0 +1,3 @@
1
+ @echo off
2
+ rem MMI CLI launcher (Windows). Plugin bin/ -> PATH behavior is undocumented on Windows; best-effort.
3
+ node "%~dp0\..\cli\dist\index.cjs" %*
package/bin/mmi-hook ADDED
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ exec node "$@"
@@ -0,0 +1,10 @@
1
+ :; exec node "$@" # sh half: cmd sees a label, POSIX shells exec node exactly like bin/mmi-hook
2
+ @echo off
3
+ rem sh/cmd polyglot console launcher (#4878), stored CRLF: cmd mis-parses LF-only batch files,
4
+ rem and the trailing # on line 1 keeps the CR out of the sh exec. PowerShell (the shell Cursor
5
+ rem wraps every hook command in on Windows) never waits on or reads stdout from the GUI-subsystem
6
+ rem mmi-hook.exe, so this batch half enters that same windowless exe through cmd, which PowerShell
7
+ rem does wait on. Referenced WITH the .cmd extension: an extensionless sibling would win
8
+ rem PowerShell's path resolution and run as neither.
9
+ "%~dp0mmi-hook.exe" %*
10
+ exit /b %errorlevel%
Binary file
@@ -0,0 +1,38 @@
1
+ {
2
+ "hooks": {
3
+ "PostToolUse": [
4
+ {
5
+ "matcher": "Bash|PowerShell|Shell|Grep|Glob",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/bin/mmi-hook-console.cmd\" \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-run.mjs\" --surface claude --gate secret-output",
10
+ "timeout": 5
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "PreToolUse": [
16
+ {
17
+ "matcher": "Bash|PowerShell",
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/bin/mmi-hook-console.cmd\" \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-run.mjs\" --surface claude --gate command-ladder",
22
+ "timeout": 10
23
+ }
24
+ ]
25
+ },
26
+ {
27
+ "matcher": "Edit|Write",
28
+ "hooks": [
29
+ {
30
+ "type": "command",
31
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/bin/mmi-hook-console.cmd\" \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-run.mjs\" --surface claude --gate vault-edit",
32
+ "timeout": 5
33
+ }
34
+ ]
35
+ }
36
+ ]
37
+ }
38
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@mutmutco/claude-plugin",
3
+ "version": "3.131.0",
4
+ "description": "MMI workflow skills and org gates delivery.",
5
+ "author": {
6
+ "name": "MMI Future",
7
+ "email": "69869555+jervaise@users.noreply.github.com"
8
+ },
9
+ "homepage": "https://github.com/mutmutco/MMI-Hub",
10
+ "type": "module",
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ ".claude-plugin/plugin.json",
16
+ ".pi-plugin/package.json",
17
+ "skills",
18
+ "hooks",
19
+ "scripts",
20
+ "bin"
21
+ ]
22
+ }
@@ -0,0 +1,334 @@
1
+ // Command-ladder detection (#2347) — pure, no IO; exported for the command-ladder gate + tests.
2
+ //
3
+ // Agents keep reaching for raw `gh` for board/issue/PR work even where `mmi-cli` already owns the verb
4
+ // (the Jerv-PowerTools #181 incident). This module flags ONLY the covered raw `gh` WRITE verbs — the ones
5
+ // `mmi-cli` has a real equivalent for — so the gate can deny them and point at the canonical replacement.
6
+ //
7
+ // DELIBERATELY NOT FLAGGED (no mmi-cli equivalent — blocking these would break real workflows):
8
+ // gh issue close/edit/view/list, gh pr view/list/checks/diff/status/close/edit/comment, gh api,
9
+ // gh project, gh auth, gh repo, gh workflow, gh run, and every read/query command. When unsure whether
10
+ // a `gh` command is covered, it is NOT listed here — the gate is a guard, not a cage.
11
+
12
+ /** The exhaustive covered-write set: a raw `gh <object> <verb>` that `mmi-cli` already owns. */
13
+ export const COVERED_GH_WRITES = [
14
+ { object: 'issue', verb: 'create', replacement: 'mmi-cli oracle issue create' },
15
+ { object: 'issue', verb: 'comment', replacement: 'mmi-cli oracle issue comment' },
16
+ { object: 'pr', verb: 'create', replacement: 'mmi-cli devops pr create' },
17
+ { object: 'pr', verb: 'merge', replacement: 'mmi-cli devops pr merge (or mmi-cli devops pr land)' },
18
+ ];
19
+
20
+ /** Remove quoted spans so a covered phrase inside a `--body "..."` literal does not trip the gate. */
21
+ export function stripQuoted(cmd) {
22
+ return String(cmd ?? '').replace(/'[^']*'/g, ' ').replace(/"[^"]*"/g, ' ');
23
+ }
24
+
25
+ /**
26
+ * Strip quote characters that wrap a SINGLE bare word (no interior whitespace or shell metacharacters),
27
+ * so a quoted head token like `gh pr "create"` still anchors as a covered write. A quoted literal that
28
+ * carries spaces or operators (`--body "gh pr create"`, `--body "foo; gh pr create"`) has interior
29
+ * whitespace/metachars and is left intact for `stripQuoted` to blank — so this cannot open a
30
+ * quoted-body false positive. Runs BEFORE `stripQuoted` in the analyzer.
31
+ */
32
+ export function dequoteBareWords(cmd) {
33
+ return String(cmd ?? '').replace(/(['"])([^\s'"|;&<>(){}$`]+)\1/g, '$2');
34
+ }
35
+
36
+ /** The documented escape hatch's variable name — one spelling, shared by the gate and this parser. */
37
+ export const RAW_GH_BYPASS_VAR = 'MMI_ALLOW_RAW_GH';
38
+
39
+ /**
40
+ * Is the escape hatch requested INSIDE the command itself, as a leading env assignment (#3284)?
41
+ *
42
+ * The gate used to read only the hook process's own environment. The hook is a separate process spawned
43
+ * per tool call, and shell state does not persist between calls, so NO form a user can type —
44
+ * `MMI_ALLOW_RAW_GH=1 gh …`, a prior `export`, PowerShell `$env:` — ever reached it. The deny message
45
+ * said "set MMI_ALLOW_RAW_GH=1", which was unreachable as written: a documented hatch that could not be
46
+ * opened. During the 2026-07-20 Actions outage that forced a ruleset-disable workaround, which is
47
+ * strictly heavier and riskier than the bypass it replaced.
48
+ *
49
+ * Anchored to a segment head, exactly like `matchCovered`: only a leading assignment run counts, so the
50
+ * literal text `MMI_ALLOW_RAW_GH=1` sitting in a report body or a comment cannot disarm the gate. Callers
51
+ * pass the heredoc-stripped, quote-stripped text for the same reason.
52
+ */
53
+ export function inlineBypassRequested(cmd, isOn = (v) => v !== '' && !['0', 'false', 'no', 'off'].includes(v.toLowerCase())) {
54
+ const head = new RegExp(
55
+ String.raw`^(?:(?:command|sudo|npx|env)\s+|[A-Za-z_][\w]*=\S*\s+)*${RAW_GH_BYPASS_VAR}=(\S*)`,
56
+ 'i',
57
+ );
58
+ for (const segment of splitSegments(String(cmd ?? ''))) {
59
+ const m = segment.replace(/^\s+/, '').replace(/^[({]\s*/, '').match(head);
60
+ if (m && isOn(m[1])) return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ /** Commands that EXECUTE their stdin, so a heredoc fed to them is script, not data. */
66
+ const STDIN_INTERPRETERS = /(?:^|[\s/])(?:ba|z|k|da)?sh(?:\.exe)?\s|(?:^|[\s/])(?:node|python3?|perl|ruby|pwsh|powershell)(?:\.exe)?\s/i;
67
+
68
+ /**
69
+ * Blank the BODY of every heredoc whose owner does not execute stdin (#3284).
70
+ *
71
+ * `splitSegments` splits on newlines, so each line of a heredoc body becomes its own "segment" and is
72
+ * matched as though it were a command. A `mmi-cli report --body-file - <<EOF` whose prose merely QUOTES
73
+ * `gh pr merge …` was therefore denied — the gate inspected report text, not an invocation. Body text is
74
+ * data; it is never executed, so blanking it cannot hide a real write.
75
+ *
76
+ * The exception that keeps this from opening a hole: `bash <<EOF` (and node/python/…) really do run their
77
+ * heredoc, so when the owning line names an stdin interpreter the body is left intact and still scanned.
78
+ * Blank LINES replace the body rather than deleting it, so nothing downstream sees shifted line numbers.
79
+ */
80
+ export function stripHeredocBodies(cmd) {
81
+ const lines = String(cmd ?? '').split(/\r?\n/);
82
+ const out = [];
83
+ for (let i = 0; i < lines.length; i++) {
84
+ const line = lines[i];
85
+ out.push(line);
86
+ // `<<EOF`, `<<-EOF`, `<<'EOF'`, `<<"EOF"` — but never the `<<<` here-STRING, which has no body.
87
+ const heredoc = line.match(/<<-?\s*(?!<)(['"]?)([A-Za-z_][\w-]*)\1/);
88
+ if (!heredoc) continue;
89
+ const executed = STDIN_INTERPRETERS.test(`${line} `);
90
+ const delimiter = heredoc[2];
91
+ for (i += 1; i < lines.length; i++) {
92
+ const body = lines[i];
93
+ if (body.trim() === delimiter) { out.push(body); break; }
94
+ out.push(executed ? body : '');
95
+ }
96
+ }
97
+ return out.join('\n');
98
+ }
99
+
100
+ /**
101
+ * Split a compound command into top-level segments so a covered verb anchors at a real command start.
102
+ * Besides the shell operators, split on the command-substitution openers `$(` and backtick — both execute
103
+ * their contents, so `$(gh pr create)` / `` `gh pr create` `` are real invocations, not text.
104
+ */
105
+ function splitSegments(cmd) {
106
+ // `&&` must precede the lone `&` in the alternation so a logical-AND is not mis-split as two
107
+ // backgrounds. The single `&` covers the bash background operator AND the leading PowerShell call
108
+ // operator (`& gh pr create`), so a covered write cannot hide behind either (#2725).
109
+ return cmd.split(/\|\||&&|&|;|\||\r?\n|\$\(|`/);
110
+ }
111
+
112
+ /**
113
+ * Match a covered `gh <object> <verb>` at the start of a single command segment. Tolerates a leading
114
+ * `command` / `sudo` / `npx` wrapper and inline env assignments, plus `gh.exe`. First strips a subshell /
115
+ * brace-group opener and a control-flow keyword left at the segment head by the operator split (e.g.
116
+ * `if x; then gh pr create` -> segment ` then gh pr create`), so a covered write cannot hide behind a
117
+ * wrapper. The verb must follow the object directly (gh's own syntax), so `gh pr comment` (no mmi-cli
118
+ * verb) and `gh issue close` never match.
119
+ *
120
+ * Two gaps closed in #2725:
121
+ * - Global flags before the subcommand. cobra accepts `gh -R owner/repo pr create` (a `-R`/`--repo`
122
+ * global flag between `gh` and the object), so the head skips a run of leading `-flag [value]`
123
+ * tokens before the object. The optional value backtracks, so a boolean global flag (e.g. `--verbose`)
124
+ * does not swallow the object.
125
+ * - A quoted inline env value. `stripQuoted` runs first and blanks `FOO="a b"` to `FOO= ` (empty value),
126
+ * so the env-prefix must tolerate an empty value (`=\S*`, not `=\S+`) or the assignment defeats the head.
127
+ */
128
+ function matchCovered(segment) {
129
+ const seg = String(segment ?? '')
130
+ .replace(/^\s+/, '')
131
+ .replace(/^[({]\s*/, '')
132
+ .replace(/^(?:then|do|else|elif)\s+/i, '')
133
+ .replace(/^\s+/, '')
134
+ // Strip a leading `env` invocation (with its own flags / inline assignments) so `env gh pr create`,
135
+ // `env -i gh ...`, and `env FOO=bar gh ...` cannot smuggle a covered write past the head anchor.
136
+ .replace(/^env\s+(?:-[A-Za-z-]+\s+(?:[^-\s]\S*\s+)?|[A-Za-z_]\w*=\S*\s+)*/i, '');
137
+ for (const entry of COVERED_GH_WRITES) {
138
+ const re = new RegExp(
139
+ String.raw`^(?:(?:command|sudo|npx|env)\s+|[A-Za-z_][\w]*=\S*\s+)*gh(?:\.exe)?\s+(?:--?[A-Za-z][\w-]*(?:=\S+)?\s+(?:[^-\s]\S*\s+)?)*` +
140
+ entry.object +
141
+ String.raw`\s+` +
142
+ entry.verb +
143
+ String.raw`(?![\w-])`,
144
+ 'i',
145
+ );
146
+ if (re.test(seg)) return entry;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Raw-`gh` -> `mmi-cli` REWRITE (#2691): the gate denies a covered write, but a deny without the exact
153
+ // replacement leaves the agent to guess the flag mapping. `mapGhToMmiCli` maps the actual `gh` flags to
154
+ // the `mmi-cli` equivalent so the deny reason hands back a runnable command. Best-effort and pure: any
155
+ // parse miss returns null and the reason falls back to the bare `mmi-cli <verb>` (never worse than before).
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /** Per covered write: gh flag (long or short) -> mmi-cli long flag, which flags take no value, and any
159
+ * mmi-cli-required flag gh has no source for (surfaced as an explicit `<...>` placeholder to fill). A gh
160
+ * flag ABSENT from `flags` is gh-only (no mmi-cli equivalent, e.g. --fill/--assignee) and is DROPPED.
161
+ * `ghBooleans` names the gh-only flags that take NO value, so dropping one does not wrongly swallow the
162
+ * following token — e.g. `gh pr merge --admin 123` must keep the positional `123` (#2832). */
163
+ export const GH_FLAG_MAP = {
164
+ 'issue create': {
165
+ positional: false,
166
+ flags: { '--title': '--title', '-t': '--title', '--body': '--body', '-b': '--body', '--body-file': '--body-file', '-F': '--body-file', '--label': '--label', '-l': '--label', '--repo': '--repo', '-R': '--repo' },
167
+ boolean: new Set(),
168
+ ghBooleans: new Set(['--web', '-w']),
169
+ require: [{ flag: '--type', placeholder: 'bug|feature|task' }],
170
+ },
171
+ 'issue comment': {
172
+ positional: true,
173
+ flags: { '--body': '--body', '-b': '--body', '--body-file': '--body-file', '-F': '--body-file', '--repo': '--repo', '-R': '--repo' },
174
+ boolean: new Set(),
175
+ ghBooleans: new Set(['--web', '-w', '--edit-last', '--delete-last', '--create-if-none']),
176
+ require: [],
177
+ },
178
+ 'pr create': {
179
+ positional: false,
180
+ flags: { '--title': '--title', '-t': '--title', '--body': '--body', '-b': '--body', '--body-file': '--body-file', '-F': '--body-file', '--base': '--base', '-B': '--base', '--head': '--head', '-H': '--head', '--repo': '--repo', '-R': '--repo', '--draft': '--draft', '-d': '--draft' },
181
+ boolean: new Set(['--draft', '-d']),
182
+ ghBooleans: new Set(['--web', '-w', '--fill', '--fill-first', '--fill-verbose', '--dry-run']),
183
+ require: [],
184
+ },
185
+ 'pr merge': {
186
+ positional: true,
187
+ flags: { '--squash': '--squash', '-s': '--squash', '--merge': '--merge', '-m': '--merge', '--rebase': '--rebase', '-r': '--rebase', '--auto': '--auto', '--repo': '--repo', '-R': '--repo' },
188
+ boolean: new Set(['--squash', '-s', '--merge', '-m', '--rebase', '-r', '--auto']),
189
+ ghBooleans: new Set(['--admin', '--delete-branch', '-d', '--web', '-w']),
190
+ require: [],
191
+ },
192
+ };
193
+
194
+ /**
195
+ * Quote-aware shell tokenizer. Emits shell separators (`;`, `|`, `||`, `&`, `&&`, `` ` ``, `$(`, `)`,
196
+ * `{`, `}`) as their own single tokens, and keeps quoted spans intact WITHIN a word (so `--title "a b"`
197
+ * yields two tokens and the quotes survive for a runnable re-emit). Not a full shell parser — enough to
198
+ * map the flag list after a `gh <object> <verb>` head. Exported for tests.
199
+ * @param {string} cmd
200
+ * @returns {string[]}
201
+ */
202
+ export function tokenizeShell(cmd) {
203
+ const s = String(cmd ?? '');
204
+ const tokens = [];
205
+ let i = 0;
206
+ const breaks = new Set([';', '`', '(', ')', '{', '}']);
207
+ while (i < s.length) {
208
+ const c = s[i];
209
+ if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { i += 1; continue; }
210
+ if ((c === '&' && s[i + 1] === '&') || (c === '|' && s[i + 1] === '|')) { tokens.push(s.slice(i, i + 2)); i += 2; continue; }
211
+ if (c === '$' && s[i + 1] === '(') { tokens.push('$('); i += 2; continue; }
212
+ if (c === ';' || c === '|' || c === '&' || c === '`' || c === '(' || c === ')' || c === '{' || c === '}') { tokens.push(c); i += 1; continue; }
213
+ let word = '';
214
+ while (i < s.length) {
215
+ const d = s[i];
216
+ if (d === ' ' || d === '\t' || d === '\r' || d === '\n') break;
217
+ if (d === ';' || d === '|' || d === '&' || d === '`' || breaks.has(d)) break;
218
+ if (d === '$' && s[i + 1] === '(') break;
219
+ if (d === "'" || d === '"') {
220
+ const end = s.indexOf(d, i + 1);
221
+ if (end === -1) { word += s.slice(i); i = s.length; break; }
222
+ word += s.slice(i, end + 1);
223
+ i = end + 1;
224
+ continue;
225
+ }
226
+ word += d;
227
+ i += 1;
228
+ }
229
+ if (word) tokens.push(word);
230
+ }
231
+ return tokens;
232
+ }
233
+
234
+ const WRAPPER_TOKENS = new Set(['command', 'sudo', 'npx', 'env']);
235
+ const SEGMENT_BREAKS = new Set(['&&', '||', ';', '|', '&', '`', '$(', ')', '{', '}', '(']);
236
+
237
+ /** Find the token index right after a covered `gh <object> <verb>` head, skipping leading wrappers
238
+ * (command/sudo/npx) and inline `VAR=val` assignments. Returns -1 when the head is not present. */
239
+ function findGhVerbTail(tokens, entry) {
240
+ for (let i = 0; i < tokens.length; i += 1) {
241
+ let j = i;
242
+ while (j < tokens.length && (WRAPPER_TOKENS.has(tokens[j]) || /^[A-Za-z_][\w]*=/.test(tokens[j]))) j += 1;
243
+ const gh = tokens[j];
244
+ if (gh === 'gh' || gh === 'gh.exe') {
245
+ if (tokens[j + 1] === entry.object && tokens[j + 2] === entry.verb) return j + 3;
246
+ }
247
+ }
248
+ return -1;
249
+ }
250
+
251
+ /**
252
+ * Map a covered raw `gh` write to its runnable `mmi-cli` equivalent, carrying the recognized flags across
253
+ * and dropping gh-only flags with no mmi-cli counterpart. Returns the mapped command string, or null when
254
+ * the head cannot be located (caller then keeps the bare `mmi-cli <verb>` replacement).
255
+ * @param {string} command
256
+ * @param {{ object: string, verb: string, replacement: string }} entry
257
+ * @returns {string | null}
258
+ */
259
+ export function mapGhToMmiCli(command, entry) {
260
+ const spec = GH_FLAG_MAP[`${entry.object} ${entry.verb}`];
261
+ if (!spec) return null;
262
+ const tokens = tokenizeShell(command);
263
+ const tail = findGhVerbTail(tokens, entry);
264
+ if (tail < 0) return null;
265
+
266
+ // The mmi-cli base verb (strip the parenthetical alt in "pr merge (or mmi-cli pr land)").
267
+ const base = entry.replacement.replace(/\s*\(.*$/, '');
268
+ const out = [base];
269
+ const seen = new Set();
270
+ let positional;
271
+
272
+ for (let i = tail; i < tokens.length; i += 1) {
273
+ const tok = tokens[i];
274
+ if (SEGMENT_BREAKS.has(tok)) break; // next command in a compound — stop mapping this one
275
+ if (tok.startsWith('-')) {
276
+ const eq = tok.indexOf('=');
277
+ const name = eq >= 0 ? tok.slice(0, eq) : tok;
278
+ const inlineVal = eq >= 0 ? tok.slice(eq + 1) : undefined;
279
+ const mapped = spec.flags[name];
280
+ if (!mapped) { // gh-only flag with no mmi-cli equivalent: drop it (and its value, if any)
281
+ const valueless = spec.boolean.has(name) || spec.ghBooleans?.has(name);
282
+ if (inlineVal === undefined && !valueless && i + 1 < tokens.length && !tokens[i + 1].startsWith('-') && !SEGMENT_BREAKS.has(tokens[i + 1])) i += 1;
283
+ continue;
284
+ }
285
+ seen.add(mapped);
286
+ if (spec.boolean.has(name)) { out.push(mapped); continue; }
287
+ if (inlineVal !== undefined) { out.push(`${mapped} ${inlineVal}`); continue; }
288
+ if (i + 1 < tokens.length && !tokens[i + 1].startsWith('-') && !SEGMENT_BREAKS.has(tokens[i + 1])) { out.push(`${mapped} ${tokens[i + 1]}`); i += 1; continue; }
289
+ out.push(mapped);
290
+ } else if (spec.positional && positional === undefined) {
291
+ positional = tok;
292
+ }
293
+ }
294
+
295
+ if (spec.positional && positional !== undefined) out.splice(1, 0, positional);
296
+ for (const req of spec.require) {
297
+ if (!seen.has(req.flag)) out.push(`${req.flag} <${req.placeholder}>`);
298
+ }
299
+ return out.join(' ');
300
+ }
301
+
302
+ /**
303
+ * Inspect a shell command for a covered raw `gh` write. Returns the hit (with the canonical replacement,
304
+ * the arg-mapped `mmi-cli` command when derivable, and a model-facing reason) or null when nothing covered
305
+ * is present.
306
+ *
307
+ * @param {string} command
308
+ * @returns {{ reasonId: string, replacement: string, mapped: string | null, reason: string } | null}
309
+ */
310
+ export function analyzeGhLadder(command) {
311
+ if (!command || typeof command !== 'string') return null;
312
+ // Heredoc bodies are data, not commands — blank them before segmenting or every prose line that quotes
313
+ // a covered write becomes a "segment" and trips the gate (#3284).
314
+ const unquoted = stripQuoted(dequoteBareWords(stripHeredocBodies(command)));
315
+ for (const seg of splitSegments(unquoted)) {
316
+ const hit = matchCovered(seg);
317
+ if (hit) {
318
+ const mapped = mapGhToMmiCli(command, hit);
319
+ const runLine = mapped ? `Run: \`${mapped}\`. ` : '';
320
+ return {
321
+ reasonId: `command_ladder_gh_${hit.object}_${hit.verb}`,
322
+ replacement: hit.replacement,
323
+ mapped,
324
+ reason:
325
+ `Command ladder (#2347): use \`${hit.replacement}\` instead of raw \`gh ${hit.object} ${hit.verb}\`. ` +
326
+ runLine +
327
+ 'mmi-cli is the required path for covered board/issue/PR writes in org repos. ' +
328
+ 'Genuine gaps stay allowed: gh api reads, gh project, gh issue/pr view|list|close|edit, gh pr checks, gh auth/repo/workflow/run. ' +
329
+ 'For a real gap that only raw gh can do, set MMI_ALLOW_RAW_GH=1 (the bypass is logged).',
330
+ };
331
+ }
332
+ }
333
+ return null;
334
+ }
@@ -0,0 +1,126 @@
1
+ // Command-ladder gate (#2347): deny covered raw `gh` board/issue/PR writes; name the mmi-cli verb.
2
+ // PreToolUse on Claude Code for Bash|PowerShell. Fail-closed on gate crashes (#2598).
3
+ //
4
+ // Escape hatch: MMI_ALLOW_RAW_GH (truthy) lets a covered write through for a genuine gap, emitting a
5
+ // logged audit line (matched verb + replacement + timestamp, never a secret body) to stderr.
6
+ // MODE (env MMI_LADDER_GATE_MODE) — 'block' (default) emits the deny JSON; 'observe' only logs.
7
+ import { analyzeGhLadder, inlineBypassRequested, stripHeredocBodies, stripQuoted } from './command-ladder-core.mjs';
8
+ import { handleGateCrash, handleMissingHookInput, recordGateSuccess } from './deny-gate-crash.mjs';
9
+ import { readHookInput } from './hook-io.mjs';
10
+ import { appendHookActivity } from './hook-trace.mjs';
11
+ // One list for every shell gate (#3563) — a local copy here let the Codex manifest match `shell` /
12
+ // `local_shell` while this gate silently ignored them, making the matcher decorative.
13
+ import { isShellTool } from './throttle-core.mjs';
14
+
15
+ const MODE = process.env.MMI_LADDER_GATE_MODE ?? 'block';
16
+ const GATE_NAME = 'command-ladder';
17
+
18
+ /** A bypass env var is on for any value except unset / empty / `0` / `false` / `no` / `off`. */
19
+ export function isBypassOn(value) {
20
+ if (value === undefined || value === null) return false;
21
+ const v = String(value).trim().toLowerCase();
22
+ return v !== '' && v !== '0' && v !== 'false' && v !== 'no' && v !== 'off';
23
+ }
24
+
25
+ /**
26
+ * Pure detection: does this tool call carry a covered raw `gh` write?
27
+ * @param {{ toolName?: string, command?: string }} input
28
+ * @returns {{ block: boolean, reason: string, reasonId?: string, replacement?: string }}
29
+ */
30
+ export function analyze(input) {
31
+ if (!isShellTool(input?.toolName)) return { block: false, reason: '' };
32
+ const hit = analyzeGhLadder(input?.command);
33
+ if (!hit) return { block: false, reason: '' };
34
+ return { block: true, reason: hit.reason, reasonId: hit.reasonId, replacement: hit.replacement };
35
+ }
36
+
37
+ /**
38
+ * Pure decision over the analysis + environment — testable without IO. Actions:
39
+ * 'allow' — nothing covered, proceed.
40
+ * 'bypass' — covered but MMI_ALLOW_RAW_GH is on; proceed and log an audit line.
41
+ * 'observe' — covered, MODE=observe; log a would-block, proceed.
42
+ * 'deny' — covered, default; emit the PreToolUse deny.
43
+ * @param {{ toolName?: string, command?: string }} input
44
+ * @param {Record<string,string|undefined>} env
45
+ */
46
+ export function decide(input, env = process.env) {
47
+ const res = analyze(input);
48
+ if (!res.block) return { action: 'allow' };
49
+ // Two ways to open the documented hatch, both logged as `bypass`:
50
+ // - the hook process's own env (how it always worked — settings-level, survives a restart), and
51
+ // - an inline `MMI_ALLOW_RAW_GH=1 gh …` prefix on the command itself (#3284).
52
+ // The second is what the deny message has always TOLD people to do, and what nobody could make work:
53
+ // the hook runs as its own process per tool call, so an inline prefix, an `export` in a previous Bash
54
+ // call, and PowerShell `$env:` all landed somewhere the hook never reads.
55
+ if (isBypassOn(env.MMI_ALLOW_RAW_GH) || inlineBypassRequested(stripQuoted(stripHeredocBodies(input?.command)), isBypassOn)) {
56
+ return { action: 'bypass', reason: res.reason, reasonId: res.reasonId, replacement: res.replacement };
57
+ }
58
+ const mode = env.MMI_LADDER_GATE_MODE ?? 'block';
59
+ if (mode === 'observe') return { action: 'observe', reason: res.reason, reasonId: res.reasonId };
60
+ return { action: 'deny', reason: res.reason, reasonId: res.reasonId };
61
+ }
62
+
63
+ /** "gh pr create" from a `command_ladder_gh_pr_create` reasonId — the matched verb, secret-free. */
64
+ export function matchedVerb(reasonId) {
65
+ const tail = String(reasonId ?? '').replace(/^command_ladder_gh_/, '').replace('_', ' ');
66
+ return `gh ${tail}`;
67
+ }
68
+
69
+ async function main() {
70
+ let input;
71
+ try {
72
+ input = await readHookInput();
73
+ } catch {
74
+ // Unreadable/absent payload = out-of-contract host (#2992): fail open without counting a crash.
75
+ const res = handleMissingHookInput(GATE_NAME);
76
+ if (res.stdout) process.stdout.write(res.stdout);
77
+ if (res.stderr) process.stderr.write(res.stderr);
78
+ process.exit(0);
79
+ }
80
+ recordGateSuccess(GATE_NAME);
81
+
82
+ const decision = decide({ toolName: input?.tool_name, command: input?.tool_input?.command });
83
+
84
+ appendHookActivity({
85
+ event: 'PreToolUse',
86
+ script: GATE_NAME,
87
+ outcome: decision.action === 'allow' ? 'ran' : decision.action,
88
+ action: decision.reason ?? 'clean',
89
+ reasonId: decision.reasonId,
90
+ tool: input?.tool_name,
91
+ });
92
+
93
+ if (decision.action === 'bypass') {
94
+ // Audit: command (matched verb) + reason (covered replacement) + timestamp. Never the arg body.
95
+ process.stderr.write(
96
+ `[mmi-ladder] BYPASS ${new Date().toISOString()} ${matchedVerb(decision.reasonId)} ` +
97
+ `(covered by ${decision.replacement}); MMI_ALLOW_RAW_GH set — allowing raw gh\n`,
98
+ );
99
+ } else if (decision.action === 'observe') {
100
+ process.stderr.write(`[mmi-ladder] would-block: ${decision.reason}\n`);
101
+ } else if (decision.action === 'deny') {
102
+ const out = JSON.stringify({
103
+ hookSpecificOutput: {
104
+ hookEventName: 'PreToolUse',
105
+ permissionDecision: 'deny',
106
+ permissionDecisionReason: decision.reason,
107
+ },
108
+ });
109
+ process.stdout.write(out + '\n');
110
+ }
111
+
112
+ process.exit(0);
113
+ }
114
+
115
+ if (
116
+ process.argv[1] &&
117
+ (process.argv[1].endsWith('command-ladder-gate.mjs') ||
118
+ process.argv[1].replace(/\\/g, '/').endsWith('scripts/command-ladder-gate.mjs'))
119
+ ) {
120
+ main().catch(() => {
121
+ const res = handleGateCrash(GATE_NAME);
122
+ if (res.stdout) process.stdout.write(res.stdout);
123
+ if (res.stderr) process.stderr.write(res.stderr);
124
+ process.exit(0);
125
+ });
126
+ }