@mutmutco/codex-plugin 3.139.0 → 3.139.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/hooks/codex-hooks.json +1 -12
- package/package.json +2 -1
- package/prompts/soul.md +71 -0
- package/scripts/command-ladder-core.mjs +2 -2
- package/scripts/hook-policy.mjs +19 -14
- package/scripts/hook-run.mjs +6 -27
- package/scripts/pretooluse-shell-gates.mjs +155 -15
- package/scripts/secret-echo-lint.mjs +1 -1
- package/skills/bootstrap/SKILL.md +2 -2
- package/skills/mmi/SKILL.md +11 -19
- package/skills/mmi-doctor/SKILL.md +9 -13
- package/skills/stage/SKILL.md +1 -1
- package/scripts/secret-redact.mjs +0 -552
- package/scripts/validate-hook.mjs +0 -156
- package/skills/worktree/SKILL.md +0 -151
|
@@ -1,552 +0,0 @@
|
|
|
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
|
-
}
|