@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,552 @@
1
+ // Claude PostToolUse secret redaction (#1589). No throttling, no compaction; fail-soft always.
2
+ // Patterns are self-contained here (conservative, prefix-anchored secret shapes).
3
+
4
+ import { appendHookActivity } from './hook-trace.mjs';
5
+
6
+ export const REDACTED = '[REDACTED]';
7
+
8
+ // #2967 asked for redaction to be VISIBLY redaction: a bare `[REDACTED]` can read as a legitimate
9
+ // rendering of the file, which is how #2967 happened — the agent could not tell the text it was shown
10
+ // was not verbatim, used it as an Edit old_string, and the edit failed. So every match is replaced with
11
+ // `[REDACTED](secret)`, a superset of REDACTED (`x.includes(REDACTED)` still holds, so nothing that
12
+ // already checks "was anything redacted" breaks).
13
+ //
14
+ // The marker names the MECHANISM, never the provider. A per-pattern label (`[REDACTED](aws-key)`)
15
+ // leaks classification metadata about a value we just decided was too sensitive to show: it tells a
16
+ // reader which provider to target and lets an attacker tune a bypass pattern-by-pattern. That argument
17
+ // still stands and per-provider labels stay refused.
18
+ //
19
+ // #3296 refines it to TWO levels, which the per-provider argument does not cover. There is a real
20
+ // difference between "this matched an anchored credential shape" and "this matched a generic 40-char
21
+ // blob", and collapsing them has its own security cost: when an agent repeatedly sees `(secret)` on
22
+ // things it knows are not secrets — worktree paths, schedule names, its own help text — the marker
23
+ // stops being a trustworthy signal, and routing around it becomes a habit. That habit is exactly what
24
+ // you do not want the day a real credential appears.
25
+ //
26
+ // Two levels leak almost nothing (the patterns are committed source; an attacker reads them directly)
27
+ // and repair that trust erosion. Both mask IDENTICALLY — same fail-closed posture, same bytes removed.
28
+ // The label describes WHAT MATCHED, never HOW MUCH TO WORRY: no "low confidence", no "probably public".
29
+ // A confidence grade would teach the agent to discount the backstop, which is the failure mode above
30
+ // wearing a different hat.
31
+ const REDACTION_MARKER = `${REDACTED}(secret)`;
32
+ // Shape-only matches: the high-entropy blob and bare-hex heuristics. No provider was identified — the
33
+ // text merely has credential-like SHAPE. Still fully masked; the reader just learns not to conclude a
34
+ // credential was detected.
35
+ const SHAPE_MARKER = `${REDACTED}(token-shape)`;
36
+
37
+ // #3121 cheap pre-filter: skip full regex machinery for small payloads that contain none of the
38
+ // candidate secret-pattern markers. Conservative: when in doubt (any marker found, or payload
39
+ // exceeds the budget) the full scan runs. Coverage must never shrink.
40
+ //
41
+ // Pre-filter surface is JSON.stringify(input) — same recursive surface the full scanner visits.
42
+ // Every pattern family from PATTERNS + SECRET_ASSIGNMENT is represented:
43
+ // PEM (-----BEGIN), JWT (eyJ), AWS (AKIA etc.), GitHub (ghp_ etc., github_pat_), Stripe/OpenAI
44
+ // (sk-, sk_live_, sk_test_, pk_live_, pk_test_), NPM (npm_), Slack (xox, xapp, hooks.slack.com),
45
+ // Discord (discord), Google (AIza, GOCSPX), URL creds (://), Bearer/Basic (case-insensitive),
46
+ // high-entropy blob / hex (32+ [A-Za-z0-9_-] run), secret-assignment key words (case-insensitive).
47
+ const PRE_FILTER_BUDGET = 1024;
48
+
49
+ const CANDIDATE_PREFIXES = [
50
+ '-----BEGIN',
51
+ 'eyJ',
52
+ 'AKIA', 'ASIA', 'AGPA', 'AROA', 'AIDA', 'ANPA', 'ANVA', 'AIPA',
53
+ 'ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_',
54
+ 'github_pat_',
55
+ 'sk-', 'sk_live_', 'sk_test_',
56
+ 'pk_live_', 'pk_test_',
57
+ 'npm_',
58
+ 'xox', 'xapp',
59
+ 'AIza',
60
+ 'GOCSPX',
61
+ 'hooks.slack.com',
62
+ 'discord',
63
+ '://',
64
+ ];
65
+
66
+ // Plain SUBSTRING keywords, deliberately NOT \b-anchored: underscore is a word character, so
67
+ // \bsecret\b never matches STRIPE_SECRET — the exact head-noun shape SECRET_ASSIGNMENT redacts.
68
+ // Substring matching over-triggers (more full scans); that is the conservative direction here.
69
+ const CI_KEYWORDS = [
70
+ 'bearer', 'basic', 'apikey', 'api_key', 'api-key',
71
+ 'appkey', 'app_key', 'app-key',
72
+ 'applicationkey', 'application_key', 'application-key',
73
+ 'secret', 'token',
74
+ 'password', 'passwd', 'passphrase', 'credential', 'accesskey', 'access_key', 'access-key',
75
+ 'privatekey', 'private_key', 'private-key',
76
+ ];
77
+ const hasCiKeyword = (raw) => {
78
+ const lower = raw.toLowerCase();
79
+ return CI_KEYWORDS.some((k) => lower.includes(k));
80
+ };
81
+
82
+ // Char class must be a superset of ENTROPY_BLOB_RE's ([A-Za-z0-9+/\-_]): a slash/plus-bearing blob
83
+ // (e.g. an AWS secret access key) splits into sub-32 runs under a narrower class and slips the filter.
84
+ const ENTROPY_PREFILTER_RE = /[A-Za-z0-9+/_-]{32,}/;
85
+
86
+ // Tools whose output the harness lets a PostToolUse hook REPLACE via a structured
87
+ // updatedToolOutput. Read/Edit/Write/WebFetch/Task/Agent/mcp__* are NOT replaceable.
88
+ const UPDATABLE_TOOLS = new Set(['Bash', 'PowerShell', 'Shell', 'Grep', 'Glob']);
89
+
90
+ // Multi-line PEM/private-key block. Kept as a named const so it can be applied to the
91
+ // WHOLE text before chunking — a private key can span more than one chunk, and chunk-local
92
+ // scanning would split the BEGIN..END span and miss it (#2821 chunk-boundary defect).
93
+ const PRIVATE_KEY_RE =
94
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g;
95
+ const PRIVATE_KEY_REPLACEMENT = '[REDACTED PRIVATE KEY]';
96
+
97
+ // High-entropy catch-all. Its two forward `.*` lookaheads are O(n^2) worst-case and it matches
98
+ // UNBOUNDED-length runs, so — unlike every other pattern — it is NOT safe to run across the whole
99
+ // text; it stays inside the budgeted per-chunk loop. A boundary split of an unbounded heuristic
100
+ // blob in dense whitespace-free content is the one documented residual (bounded, anchored
101
+ // credential shapes are all handled whole-text and can never be split by a chunk edge).
102
+ //
103
+ // #2942: the char class contains `/`, `\-` and `_`, so a long slash-joined PATH or URL tail matched as one
104
+ // "high-entropy blob" and was masked: `https://github.com/mutmutco/MMI-Hub/actions/runs/…` came back as
105
+ // `https://github.[REDACTED]`, and worktree/temp paths were destroyed outright. A path is not a credential.
106
+ //
107
+ // The fix is CONTEXTUAL, not a narrower char class. Dropping `/` from the class would stop matching an AWS
108
+ // secret access key (`wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` — 40 chars, two slashes), and an entropy
109
+ // threshold would stop matching the pinned 40-char `XXX…yyy` fixture (1.0 bits/char). Both are true
110
+ // positives we must keep. What actually separates them is the surrounding text: a matched blob that is a
111
+ // SLICE of a longer path or URL is glued to a path separator, while a credential sits on a word boundary.
112
+ //
113
+ // #3299 — KNOWN, ACCEPTED BYPASS. Read this before touching anything below.
114
+ //
115
+ // This exemption is inferred from attacker-influenced printable text, so anyone who can shape what a
116
+ // command prints can satisfy it. One adjacent character is enough. Reproduced against the AWS-published
117
+ // example secret access key:
118
+ //
119
+ // bare masked
120
+ // leading slash LEAKED
121
+ // leading dot LEAKED
122
+ // leading backslash LEAKED
123
+ // in a sentence masked
124
+ // sentence, slash-prefixed LEAKED
125
+ // trailing slash LEAKED
126
+ //
127
+ // BOTH arms are reachable — a trailing separator leaks exactly like a leading one.
128
+ //
129
+ // It reaches a real credential because the AWS SECRET ACCESS KEY has no anchored pattern of its own: the
130
+ // `AKIA|ASIA|…` family matches key IDs, not secret keys. This heuristic is its only cover, and this
131
+ // exemption is what disables it.
132
+ //
133
+ // It is NOT fixed, by owner decision, because every available fix is worse:
134
+ // - deleting the exemption resurrects #2942 wholesale (URLs, worktree paths, Actions run URLs all
135
+ // destroyed again) — a shipped functional regression, not a hypothetical;
136
+ // - widening the lookback to tolerate spaces enlarges the hole, since a forged path-shaped prefix is
137
+ // as easy to print as a real one;
138
+ // - a generic 40-char base64 "AWS" pattern over-matches catastrophically.
139
+ //
140
+ // So: treat the entropy heuristic as a BACKSTOP, never as primary defence, and do not build new
141
+ // protection on top of it. `infra/secret-redact.test.mjs` pins this behaviour deliberately — if one of
142
+ // those tests starts failing, someone has changed the exemption and must revisit #3299, not "fix" the
143
+ // test.
144
+ //
145
+ // Second defect, also unfixed and deliberately so: in the chunked pass this function receives the CHUNK,
146
+ // not the whole text, so at a chunk edge it inspects characters that are not the real neighbours — the
147
+ // same input can mask or not depending on size. It is NOT safe to fix in isolation: supplying the real
148
+ // neighbours would ACTIVATE the exemption where it currently misses, masking LESS rather than more.
149
+ // It must be fixed together with whatever finally settles the path policy.
150
+ //
151
+ // #3469 / #3338 — A PATH CONTAINING A SPACE HAS ITS TAIL MASKED. THAT IS THE ACCEPTED OUTCOME.
152
+ // Do not re-attempt the exemption; this is the third time it has been proposed.
153
+ //
154
+ // A space is not in the char class, so a spaced path splits into separate runs and the TAIL run is no
155
+ // longer glued to a recognisable prefix — it matches as a bare token. Every path this org uses contains a
156
+ // space (`E:\AI Projects\…`, `E:\Jerv's Vault\`, `E:\Cold Storage\`, `C:\Program Files\`), so on
157
+ // forward-slash output it fires constantly: `git worktree list` renders as
158
+ // `E:/AI Projects/Mutatis [REDACTED](token-shape) 1d4c2aab [3240-clock-fp]`.
159
+ //
160
+ // The obvious fix — look BACKWARDS across whitespace and suppress when the run is preceded on the same
161
+ // line by a rooted path anchor (`X:\`, `X:/`, `//`, `./`, `~/`) — was BUILT as PR #3373 and closed
162
+ // unmerged. Cross-vendor security review (GPT-5.6 Sol) blocked it twice, because a rooted path followed
163
+ // by one space and a slash-bearing token is STRUCTURALLY IDENTICAL to a real spaced path:
164
+ //
165
+ // E:/AI Projects/Mutatis ‹Mutandis/mmi-worktrees/3469-…› one path; the space is inside a dir name
166
+ // E:/logs/archive ‹wJalrXUtnFEMI/K7MDENG/bPxRfi…› a path, then a separate credential field
167
+ //
168
+ // Every knob on that lookback was measured, including narrowings PR #3373 did not have (single-space gaps
169
+ // only, so column-aligned output cannot exempt the next column; `:` excluded from the segment charset, so
170
+ // `file:line:` and `KEY=value` shapes break the span). Bounding the lookback to ONE gap defeats Sol's
171
+ // first counterexample (`E:/logs/archive contains leak <key>` — prose rides in as the final segment) but
172
+ // then no longer fixes `git worktree list` at all, because that path needs two gaps to reach its root. At
173
+ // two gaps the path survives and the second counterexample leaks. There is no setting of the knob where
174
+ // the path survives and the credential masks.
175
+ //
176
+ // That second leak is why this is BIGGER than the #3299 bypass above, not the same size: #3299 needs an
177
+ // attacker to place one character next to the secret, whereas plain `<rooted-path> <value>` on one line is
178
+ // ORDINARY tool output. The exemption would stop masking accidental leaks with no attacker present.
179
+ //
180
+ // OWNER RULING 2026-07-22 (#3338 closed, PR #3373 closed unmerged): fix the PATH EMITTERS, leave the
181
+ // backstop untouched. `cli/src/gc.ts` `toNativePath()` converts git's forward slashes to native separators
182
+ // on Windows at the emission boundary. `\` is outside the char class, so runs BREAK at every separator —
183
+ // but that alone does not make a native path safe: a single directory name of 40+ mixed-case chars still
184
+ // forms a run on its own (`E:\src\ThisIsAVeryLongCamelCaseDirectoryName123\index.ts` does, verified). What
185
+ // actually exempts it is the `before === '\\'` arm below — any run that does form inside a native path is
186
+ // preceded by a separator. Reason from that, not from "a native path can never form a run", or you will
187
+ // reason from a false invariant. Where the emitter is not ours (raw `git worktree list` through Bash),
188
+ // the mask is the accepted cost — read the path via `mmi-cli worktree list --json`, which emits native
189
+ // separators. `infra/secret-redact.test.mjs` pins BOTH directions — the accepted mask AND Sol's two
190
+ // counterexamples — so a fourth attempt fails a test instead of shipping.
191
+ export function isPathContext(text, start, end) {
192
+ const before = text[start - 1] ?? '';
193
+ const after = text[end] ?? '';
194
+ // Preceded by `/`, `\` or `.` → we are mid-path or mid-URL (`github.` + `com/mutmutco/…`).
195
+ if (before === '/' || before === '\\' || before === '.') return true;
196
+ // Followed by a separator → the blob continues into more path.
197
+ if (after === '/' || after === '\\') return true;
198
+ return false;
199
+ }
200
+
201
+ // ACCEPTED OVER-REDACTION, deliberately not "fixed" (#2966 security review — do not undo this):
202
+ // the char class also matches a long bare camelCase FILENAME with no path separator to anchor it
203
+ // (`SomeVeryLongCamelCaseModuleFileNameWithoutSlashes.md`), so such a name is masked. That is the safe
204
+ // failure mode and it stays. Two exculpation schemes were built here and both were rejected on review:
205
+ // - exempting a blob followed by `.` + a letter ("it's a file extension") is a secret-smuggling BYPASS:
206
+ // append `.txt` to any secret and it walks through (`leak AbCdEf…789abcd.txt`);
207
+ // - exempting word-structured, low-entropy tokens is ALSO a bypass, because passphrase, diceware and
208
+ // BIP39 mnemonic credentials are word-structured BY DESIGN:
209
+ // `credential: correct-Horse-battery-staple-planet-museum-lantern.md` passes any such gate.
210
+ // Content-based innocence cannot be proven from the token itself. Over-redacting a rare long filename
211
+ // costs one Read; an attacker-controlled exemption costs a credential. Fail closed.
212
+ // #3296: the case-diversity lookaheads are TOKEN-SCOPED. They were `(?=.*[a-z])(?=.*[A-Z])`, and `.`
213
+ // stops only at a newline, so the requirement could be satisfied by text later on the same LINE,
214
+ // outside the token — and because the entropy pass runs per-chunk, by where the input happened to be
215
+ // split. That is not the rule the comment above describes.
216
+ //
217
+ // The cross-vendor security review challenged this, arguing a 40-char one-case hex credential (Datadog
218
+ // app key shape) loses its only cover, since the bare-hex fallback starts at 48. Measured against both
219
+ // regex forms directly, that objection does not hold — the lookaheads scan FORWARD from the match
220
+ // start, so an uppercase key NAME sitting before the value never satisfied them:
221
+ //
222
+ // DATADOG_APP_KEY=<40 hex> loose: NOT matched scoped: NOT matched
223
+ // export DATADOG_APP_KEY=<40 hex> loose: NOT matched scoped: NOT matched
224
+ // <40 hex> SOME TRAILING TEXT loose: matched scoped: NOT matched
225
+ // commit <git sha> Merge PR loose: matched scoped: NOT matched
226
+ //
227
+ // So the loose form never covered the realistic assignment shape. It only fired when uppercase happened
228
+ // to FOLLOW the token — and in that case it also masked git SHA-1s in ordinary `git log` output, which
229
+ // is one of the false positives #3296 was filed about. The looseness bought coincidence, not coverage.
230
+ //
231
+ // The genuine gap it exposed — a one-case 40-char credential has no cover under EITHER form — is real
232
+ // and was pre-existing. It cannot be closed by lowering the bare-hex threshold to 40, because 40 hex
233
+ // chars is exactly a git SHA-1 and every commit hash in every log would be masked. Closed as #3300 by
234
+ // widening SECRET_ASSIGNMENT's name list (APP_KEY / APPLICATION_KEY — a NAME is not the value's
235
+ // content, so it does not reopen #2966), not by loosening anything here.
236
+ const ENTROPY_BLOB_RE =
237
+ /\b(?=[A-Za-z0-9+/\-_]{40,})(?=[A-Za-z0-9+/\-_]*[a-z])(?=[A-Za-z0-9+/\-_]*[A-Z])[A-Za-z0-9+/\-_]{40,}\b/g;
238
+ const ENTROPY_HEURISTIC = {
239
+ re: ENTROPY_BLOB_RE,
240
+ replace: (match, offset, whole) =>
241
+ (isPathContext(whole, offset, offset + match.length) ? match : SHAPE_MARKER),
242
+ };
243
+
244
+ const PATTERNS = [
245
+ { re: PRIVATE_KEY_RE, replace: PRIVATE_KEY_REPLACEMENT },
246
+ { re: /\b([a-z][a-z0-9+.\-]*:\/\/[^\s:@\/]+):[^\s:@\/]+@/gi, replace: `$1:${REDACTION_MARKER}@` },
247
+ { re: /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/\-]+=*/gi, replace: `$1 ${REDACTION_MARKER}` },
248
+ { re: /\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b/g, replace: REDACTION_MARKER },
249
+ { re: /\b(?:AKIA|ASIA|AGPA|AROA|AIDA|ANPA|ANVA|AIPA)[0-9A-Z]{16}\b/g, replace: REDACTION_MARKER },
250
+ { re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, replace: REDACTION_MARKER },
251
+ { re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replace: REDACTION_MARKER },
252
+ { re: /\bAIza[0-9A-Za-z_\-]{35}\b/g, replace: REDACTION_MARKER },
253
+ { re: /\bGOCSPX-[A-Za-z0-9_\-]{20,}\b/g, replace: REDACTION_MARKER },
254
+ { re: /\bsk-[A-Za-z0-9_\-]{20,}\b/g, replace: REDACTION_MARKER },
255
+ { re: /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/g, replace: REDACTION_MARKER },
256
+ { re: /\bpk_(?:live|test)_[A-Za-z0-9]{16,}\b/g, replace: REDACTION_MARKER },
257
+ { re: /\bnpm_[A-Za-z0-9]{36}\b/g, replace: REDACTION_MARKER },
258
+ { re: /\b(?:xox[baprs]|xapp)-[A-Za-z0-9-]{10,}\b/g, replace: REDACTION_MARKER },
259
+ {
260
+ re: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/gi,
261
+ replace: REDACTION_MARKER,
262
+ },
263
+ {
264
+ re: /https:\/\/discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_\-]+/gi,
265
+ replace: REDACTION_MARKER,
266
+ },
267
+ ENTROPY_HEURISTIC,
268
+ // Shape-only, like the entropy blob: a long bare hex run names no provider (#3296).
269
+ { re: /\b[a-fA-F0-9]{48,}\b/g, replace: SHAPE_MARKER },
270
+ ];
271
+
272
+ // Every pattern EXCEPT the high-entropy heuristic is linear and matches a bounded, anchored shape,
273
+ // so it is cheap and correct to apply across the whole text — a chunk edge can never split it.
274
+ const WHOLE_TEXT_PATTERNS = PATTERNS.filter((p) => p !== ENTROPY_HEURISTIC);
275
+
276
+ const SECRET_ASSIGNMENT =
277
+ /\b([A-Za-z0-9_]*(?:API[_-]?KEY|APPLICATION[_-]?KEY|APP[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|ACCESS[_-]?KEY|CREDENTIALS?)[A-Za-z0-9_]*)(\s*["']?\s*[=:]\s*)("[^"\n]{4,}"|'[^'\n]{4,}'|[^\s"']{4,})/gi;
278
+
279
+ // #2942: the NAME group above only requires the secret word to appear as a SUBSTRING, so an ordinary
280
+ // camelCase identifier matched it — `const tokenBefore = "..."` had its value blanked in Grep/Bash output
281
+ // of plain source code. (Worse: the redactor rewrites shell/Grep output but NOT Read/Edit, so an Edit
282
+ // old_string copied from redacted Grep output could not match the real bytes on disk.)
283
+ //
284
+ // A name is a secret name when the secret word is the HEAD NOUN — what the thing IS — not merely a prefix:
285
+ // accessToken, api_key, GITHUB_TOKEN, client_secret, SECRET_KEY, AWS_SECRET_ACCESS_KEY,
286
+ // DATADOG_APP_KEY, appKey, APPLICATION_KEY → yes
287
+ // tokenBefore, tokenExpiry, tokenizer, sortKey, foreignKey, publicKey, projectKey → no
288
+ // So: split the name into word parts (camelCase, snake_case, kebab-case all normalise the same way) and
289
+ // test the TRAILING one or two parts against the term list. This keeps every real config/env shape,
290
+ // including lowercase JSON/YAML keys, which an UPPER_SNAKE-only rule would have dropped.
291
+ //
292
+ // #3300: APP_KEY / APPLICATION_KEY are credential head nouns (Datadog app keys, etc.). Bare KEY and
293
+ // arbitrary *KEY (sortKey, publicKey, …) stay refused — that recreates the #2942 tokenBefore class.
294
+ const SECRET_NAME_TERMS = new Set([
295
+ 'apikey', 'appkey', 'applicationkey', 'secret', 'secretkey', 'token', 'password', 'passwd',
296
+ 'passphrase', 'privatekey', 'clientsecret', 'accesskey', 'credential', 'credentials',
297
+ ]);
298
+
299
+ /** Split an identifier into lowercase word parts: `AWS_SECRET_ACCESS_KEY` / `accessToken` / `api-key`. */
300
+ export function splitNameParts(name) {
301
+ return name
302
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
303
+ .split(/[\s_\-]+/)
304
+ .filter(Boolean)
305
+ .map((p) => p.toLowerCase());
306
+ }
307
+
308
+ /** True when the secret word is the head noun of `name` (see SECRET_NAME_TERMS above). */
309
+ export function isSecretAssignmentName(name) {
310
+ const parts = splitNameParts(name);
311
+ if (!parts.length) return false;
312
+ // The trailing part, or the trailing two joined (`access`+`key` → `accesskey`, `api`+`key` → `apikey`).
313
+ return SECRET_NAME_TERMS.has(parts[parts.length - 1])
314
+ || (parts.length >= 2 && SECRET_NAME_TERMS.has(parts.slice(-2).join('')));
315
+ }
316
+
317
+ /** Apply a list of {re, replace} patterns to text, in order. */
318
+ function applyPatterns(text, patterns) {
319
+ let out = text;
320
+ for (const { re, replace } of patterns) out = out.replace(re, replace);
321
+ return out;
322
+ }
323
+
324
+ /** Redact secret assignments (NAME = "value") — value replaced, name/sep kept. A name whose secret word is
325
+ * only a prefix (`tokenBefore`) is left ALONE: it is ordinary source code, not a credential (#2942). */
326
+ function redactAssignments(text) {
327
+ return text.replace(SECRET_ASSIGNMENT, (match, name, sep) =>
328
+ (isSecretAssignmentName(name) ? `${name}${sep}${REDACTION_MARKER}` : match));
329
+ }
330
+
331
+ // Large tool outputs are scanned in chunks under a per-call time budget. Anything
332
+ // past the budget is truncated and flagged with a visible warning — never silently
333
+ // skipped, since skipping large output is itself an exfil path. #2597
334
+ const CHUNK_SIZE = 64 * 1024; // 64 KB per chunk
335
+ const CHUNK_OVERLAP = 1024; // when chunking the high-entropy pass, prefer to cut at a whitespace
336
+ // boundary within this window so a dense run is less likely to split
337
+ const SCAN_BUDGET_MS = 3000; // per-call wall-clock budget (hook timeout is 5s)
338
+ export const TRUNCATION_WARNING =
339
+ '[mmi-hook] WARNING: tool output exceeded the secret-redaction scan time budget; ' +
340
+ 'the unscanned remainder was truncated to avoid passing unredacted content to model ' +
341
+ 'context. Inspect the source tool output directly for possible secrets.';
342
+
343
+ /** Apply ALL redaction patterns to a single chunk (used for small inputs scanned in one pass). */
344
+ function redactChunk(text) {
345
+ return redactAssignments(applyPatterns(text, PATTERNS));
346
+ }
347
+
348
+ /** Strip secret-shaped substrings from tool output before it enters model context.
349
+ * Small inputs are scanned in one whole pass. Large inputs are handled in two stages so a
350
+ * secret can never be split by a chunk boundary: (1) every bounded/anchored pattern (incl.
351
+ * multi-line PEM) plus secret assignments runs across the WHOLE text — these are linear and
352
+ * cannot be split; (2) only the O(n^2), unbounded-length high-entropy heuristic is scanned in
353
+ * chunks under a per-call time budget, and any unscanned tail is truncated with a visible
354
+ * warning so it can never pass through silently (#2597, #2821). */
355
+ export function redactSecrets(text, opts = {}) {
356
+ if (typeof text !== 'string') text = String(text ?? '');
357
+ const chunkSize = opts.chunkSize ?? CHUNK_SIZE;
358
+ const budgetMs = opts.budgetMs ?? SCAN_BUDGET_MS;
359
+ const overlap = Math.min(opts.overlap ?? CHUNK_OVERLAP, chunkSize);
360
+ if (text.length <= chunkSize) return redactChunk(text);
361
+
362
+ // Stage 1 — boundary-free whole-text pass for every bounded/anchored pattern + assignments.
363
+ // A chunk edge can never split any of these, so dense whitespace-free tokens (AKIA, gh*, JWT,
364
+ // hex, PEM, …) are caught regardless of where they fall (#2821 chunk-boundary defect).
365
+ text = redactAssignments(applyPatterns(text, WHOLE_TEXT_PATTERNS));
366
+
367
+ // Stage 2 — only the high-entropy heuristic remains; scan it in chunks under the time budget.
368
+ const start = Date.now();
369
+ let out = '';
370
+ let i = 0;
371
+ while (i < text.length) {
372
+ let end = Math.min(i + chunkSize, text.length);
373
+ if (end < text.length) {
374
+ // prefer a whitespace cut within the overlap window so a dense run is less likely to split
375
+ const floor = Math.max(i + 1, end - overlap);
376
+ let sp = end;
377
+ while (sp > floor && !/\s/.test(text[sp - 1])) sp--;
378
+ if (sp > floor) end = sp;
379
+ }
380
+ out += text.slice(i, end).replace(ENTROPY_HEURISTIC.re, ENTROPY_HEURISTIC.replace);
381
+ i = end;
382
+ if (i < text.length && Date.now() - start >= budgetMs) {
383
+ out += `\n${TRUNCATION_WARNING}`;
384
+ break;
385
+ }
386
+ }
387
+ return out;
388
+ }
389
+
390
+ /** Deep-redact every string field of a tool response, preserving structure and
391
+ * non-string fields. Scans stdout AND stderr AND text/content — no field skipped,
392
+ * and an empty stdout no longer short-circuits the scan. */
393
+ export function redactToolResponse(resp) {
394
+ if (typeof resp === 'string') return redactSecrets(resp);
395
+ if (Array.isArray(resp)) return resp.map(redactToolResponse);
396
+ if (resp && typeof resp === 'object') {
397
+ const out = {};
398
+ for (const [k, v] of Object.entries(resp)) out[k] = redactToolResponse(v);
399
+ return out;
400
+ }
401
+ return resp;
402
+ }
403
+
404
+ export function toolResponseToString(resp) {
405
+ if (resp == null) return '';
406
+ if (typeof resp === 'string') return resp;
407
+ if (typeof resp === 'object') {
408
+ const parts = [];
409
+ if (typeof resp.text === 'string') parts.push(resp.text);
410
+ if (typeof resp.content === 'string') parts.push(resp.content);
411
+ if (typeof resp.stdout === 'string') parts.push(resp.stdout);
412
+ if (typeof resp.stderr === 'string') parts.push(resp.stderr);
413
+ if (Array.isArray(resp.content)) {
414
+ const fromArray = resp.content
415
+ .map((b) => (typeof b === 'string' ? b : typeof b?.text === 'string' ? b.text : ''))
416
+ .filter(Boolean)
417
+ .join('\n');
418
+ if (fromArray) parts.push(fromArray);
419
+ }
420
+ if (parts.length) return parts.join('\n');
421
+ try {
422
+ return JSON.stringify(resp);
423
+ } catch {
424
+ return String(resp);
425
+ }
426
+ }
427
+ return String(resp);
428
+ }
429
+
430
+ /** @returns {null | { hookSpecificOutput: object|null, redactable: boolean, toolName: string }} */
431
+ export function postToolUseRedactDecision(input) {
432
+ const resp = input?.tool_response;
433
+ if (resp == null) return null;
434
+ const redacted = redactToolResponse(resp);
435
+ if (JSON.stringify(redacted) === JSON.stringify(resp)) return null; // clean
436
+ const toolName = typeof input?.tool_name === 'string' ? input.tool_name : '';
437
+ if (UPDATABLE_TOOLS.has(toolName)) {
438
+ return {
439
+ toolName,
440
+ redactable: true,
441
+ hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: redacted },
442
+ };
443
+ }
444
+ return { toolName, redactable: false, hookSpecificOutput: null };
445
+ }
446
+
447
+ /** The tool name the HOST actually fired, for the trace. hook-run.mjs rewrites a host's shell spelling
448
+ * into the Claude vocabulary UPDATABLE_TOOLS is written in (#4118) and stamps the original beside it;
449
+ * logging the rewritten name recorded Codex `shell` and `local_shell` as identical "PowerShell" rows. */
450
+ function tracedTool(input) {
451
+ return typeof input?.mmi_host_tool_name === 'string' ? input.mmi_host_tool_name : input?.tool_name;
452
+ }
453
+
454
+ /** Shared entry (#4118): awaitable and exit-free so hook-run.mjs can import it in-process instead of
455
+ * booting a second node. `input` is the buffered payload when the runner already drained stdin. */
456
+ export async function runHookGate({ input: buffered } = {}) {
457
+ let input;
458
+ try {
459
+ const { readHookInput } = await import('./hook-io.mjs');
460
+ input = await readHookInput(buffered);
461
+ } catch {
462
+ appendHookActivity({
463
+ event: 'PostToolUse',
464
+ script: 'secret-redact',
465
+ outcome: 'failed',
466
+ action: 'could not read hook input',
467
+ });
468
+ return;
469
+ }
470
+
471
+ const raw = JSON.stringify(input);
472
+ if (raw.length <= PRE_FILTER_BUDGET && !CANDIDATE_PREFIXES.some((p) => raw.includes(p)) && !hasCiKeyword(raw) && !ENTROPY_PREFILTER_RE.test(raw)) {
473
+ appendHookActivity({
474
+ event: 'PostToolUse',
475
+ script: 'secret-redact',
476
+ outcome: 'ran',
477
+ action: 'clean (pre-filter)',
478
+ tool: tracedTool(input),
479
+ });
480
+ return;
481
+ }
482
+
483
+ try {
484
+ const decision = postToolUseRedactDecision(input);
485
+ // #3563/#3920: `updatedToolOutput` is a Claude Code capability. Codex's PostToolUse contract supports
486
+ // `{decision, reason}` only, Kimi is observation-only, and Cursor can replace MCP output only, so
487
+ // on those hosts the rewrite is DROPPED and ordinary tool output stays visible. Emitting it there
488
+ // and logging `heal` recorded a masking that never happened — false assurance in the one place a
489
+ // leak would be audited from (cross-vendor check, openai-sol, kimi-k3).
490
+ const canRewriteOutput = !['codex', 'kimi', 'cursor'].includes(process.env.MMI_HOOK_SURFACE || 'claude');
491
+ if (decision?.hookSpecificOutput && canRewriteOutput) {
492
+ process.stdout.write(`${JSON.stringify({ hookSpecificOutput: decision.hookSpecificOutput })}\n`);
493
+ }
494
+ const healed = Boolean(decision?.redactable) && canRewriteOutput;
495
+ const outcome = !decision ? 'ran' : healed ? 'heal' : 'observe';
496
+ // #4015/#4021: every operator- and log-facing string names the tool the HOST fired, never
497
+ // `decision.toolName` — that one is the name hook-run.mjs rewrote into the Claude vocabulary so it can
498
+ // be tested against UPDATABLE_TOOLS, and it collapsed Codex `shell` and `local_shell` into "PowerShell".
499
+ // #4012 fixed this call's `tool` FIELD and #4015 the stderr arms; the `action` TEXT below was converted
500
+ // by neither, so one row read `{"tool":"shell","action":"secret detected in PowerShell output …"}` and
501
+ // disagreed with itself — in the log doctor, the Stop summary and Archive/scrooge.md's detection counts read.
502
+ const alarmTool = tracedTool(input) || decision?.toolName || 'tool';
503
+ const action = !decision
504
+ ? 'clean'
505
+ : healed
506
+ ? 'redacted secrets from tool output'
507
+ : decision.redactable
508
+ ? `secret detected in ${alarmTool} output and NOT masked — this host cannot rewrite tool output (no updatedToolOutput channel on this surface)`
509
+ : `secret detected in ${alarmTool} output but PostToolUse cannot redact this tool (harness limitation)`;
510
+ // Detection without masking must be audible, not just logged: on Codex the value is still on screen.
511
+ if (decision?.redactable && !canRewriteOutput) {
512
+ process.stderr.write(`[mmi-hook] secret-redact: a secret-shaped value was detected in ${alarmTool} output and could NOT be masked on this host — treat the transcript as exposed.\n`);
513
+ }
514
+ // #3630: same audibility for the non-redactable-TOOL arm. A detection in Read/WebFetch/Agent/mcp__*
515
+ // output landed only in the trace file — silent on the one surface where the value is still visible.
516
+ if (decision && !decision.redactable) {
517
+ process.stderr.write(`[mmi-hook] secret-redact: a secret-shaped value was detected in ${alarmTool} output and cannot be masked for this tool (no updatedToolOutput channel) — treat the transcript as exposed.\n`);
518
+ }
519
+ appendHookActivity({ event: 'PostToolUse', script: 'secret-redact', outcome, action, tool: tracedTool(input) });
520
+ } catch (err) {
521
+ // #2610: the redactor stays fail-open (PostToolUse — the bytes already left the tool, blocking buys
522
+ // nothing; see #2598 rescope), but its death must be LOUD. Record a greppable crash marker so a
523
+ // recurring silent failure is never invisible forever — doctor + the Stop summary read this line.
524
+ appendHookActivity({
525
+ event: 'PostToolUse',
526
+ script: 'secret-redact',
527
+ outcome: 'failed',
528
+ action: 'redactor crashed during scan',
529
+ error: err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err),
530
+ tool: tracedTool(input),
531
+ });
532
+ }
533
+ }
534
+
535
+ if (
536
+ process.argv[1] &&
537
+ (process.argv[1].endsWith('secret-redact.mjs') ||
538
+ process.argv[1].replace(/\\/g, '/').endsWith('scripts/secret-redact.mjs'))
539
+ ) {
540
+ // #2610: a rejection from runHookGate() itself (e.g. the dynamic import blew up before the inner try) must
541
+ // not be swallowed silently either — record a crash marker, then keep the fail-open exit-0 contract.
542
+ runHookGate().then(() => process.exit(0)).catch((err) => {
543
+ appendHookActivity({
544
+ event: 'PostToolUse',
545
+ script: 'secret-redact',
546
+ outcome: 'failed',
547
+ action: 'redactor crashed before scan',
548
+ error: err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err),
549
+ });
550
+ process.exit(0);
551
+ });
552
+ }