Masks WordPress usernames and Application Passwords in prompts with session-scoped placeholders, and restores them into Bash commands at execution time.

Keeps a WordPress username and Application Password out of the model's context while still letting curl receive the real bytes.
You paste a credential into the conversation because the work needs it. The model reads a placeholder instead. When the model writes a Bash command using that placeholder, the shell gets the real value back.
By Nathan Onn
On the way into the model's context, a WordPress username or Application Password is replaced with a session-scoped placeholder — [WP-USER-3c4d], [WP-PASS-9a1f]. On the way into a Bash tool call, the placeholder is replaced with the real value, shell-quoted for the context it lands in.
You type:
username: exampleuser app password: AAAA BBBB CCCC DDDD EEEE FFFF
The model reads:
username: [WP-USER-3c4d] app password: [WP-PASS-9a1f]
The model writes a Bash call, using the placeholder verbatim:
curl -u [WP-USER-3c4d]:"[WP-PASS-9a1f]" https://example.test/wp-json/wp/v2/posts
The shell is handed:
curl -u exampleuser:"AAAA BBBB CCCC DDDD EEEE FFFF" https://example.test/wp-json/wp/v2/posts
The vault holding the mapping is plain module memory. It dies with the session, and nothing ever writes a real credential to disk. The digest is salted per session, so the same credential gets a different placeholder next time and two leaked transcripts cannot be correlated. A placeholder the vault has never held denies the Bash call rather than running it with the literal text.
Three routes. The first two register the hooks; the third does not on its own, and that distinction matters more here than it would for a skill — see step 3 either way.
This repository is a Claude Code marketplace. From inside a session:
/plugin marketplace add nathanonn/agent-skills
/plugin install wp-credential-guard@nathanonn-agent-skills
Read the install summary. If it says the plugin is now active, the hooks are already loaded. If it asks you to reload, run /reload-plugins — or just start a new session, which loads it anyway.
--plugin-dir points at the plugin folder itself, not a directory of plugins, and it lasts only for that session:
claude --plugin-dir /path/to/wp-credential-guard
Repeat the flag to load several plugins. A plugin loaded this way takes precedence over an installed copy of the same name, which is what makes it useful for testing a change.
Copying the plugin into .claude/plugins/ does not register its hooks. The plugin shows up as present, nothing warns you, and every credential passes through verbatim. For a skill, copying is enough; for hooks it is not. If you install this way you must also name it in your settings — ~/.claude/settings.json for every project, or .claude/settings.json for one:
{
"enabledPlugins": ["wp-credential-guard"]
}
Restart the session afterwards. If you would rather not hand-edit settings, point a local marketplace at your checkout instead and install from it — that path runs the normal install flow and enables the plugin for you:
/plugin marketplace add /path/to/agent-skills
/plugin install wp-credential-guard@agent-skills
Do this once, whichever route you used. "Installed" and "working" are different states here, and nothing announces the gap.
Check registration — run /hooks and look for prompt.submit, skill.prompt, tool.call and ui.render. All four should be listed, sourced from this plugin.
Check it fires — send a prompt carrying a dummy credential:
username: exampleuser app password: AAAA BBBB CCCC DDDD EEEE FFFF
You should see a dim line reading wp-credential-guard: masked 1 username and 1 password (session-only), and the message row on screen should show [WP-USER-xxxx] and [WP-PASS-xxxx] rather than what you typed. If the line is absent on a prompt you know contained a credential, the hooks are not running — go back to step 3.
Never use a real credential for this check. A plugin you are still verifying is, by definition, one you cannot yet rely on.
Read this before using it. The plugin covers one path well and several not at all.
A plugin that is present but not loaded registers no hooks, and fails silently. This is the sharpest trap here. The folder sitting on disk reports as installed while every credential passes through verbatim — there is no warning, because nothing is running to warn you.
Copying the folder into a plugins directory is the usual way to land in this state — it is enough for a skill and not enough for hooks. Install above has the fix and the two checks that settle it. Run them before you paste anything real. One caveat on the second check: a prompt containing no credential logs nothing either, so silence on ordinary traffic proves neither way — you have to send something that should be masked.
The on-screen rewrite is cosmetic only. The ui.render hook keeps the credential off your screen. The stored session JSONL is untouched and still holds whatever the engine persisted. It hides a leak; it does not fix one. No hook in the event set can reach a message the engine has already written — not the user message, not the expansion, not the last-prompt record. The plugin therefore never attempts to scrub or rewrite a transcript.
Slash commands are refused, not masked. A typed /name args carries its text to the model twice: once as the expansion the skill computes, and once in a <command-args> envelope beside it that no hook can rewrite. Masking one half leaves the other half in the clear in the same message, so the plugin returns { drop } and the prompt never runs. To carry on, paste the credential as an ordinary message (no leading slash) to mint its placeholder, then re-run the slash command with the placeholder in place of the value:
/your-command [WP-USER-3c4d] [WP-PASS-9a1f]
A slash command carrying only placeholders is not refused — placeholder spans are reserved and nothing is minted from inside them.
What a refusal actually buys is narrower than it looks. That the command did not run and the model never read the value follows structurally from dropping the prompt. That the prompt also stayed out of the session transcript was measured once, on one engine build, where a refused submission left no record at all. The engine's own type definitions describe prompt.submit as running after the input became a user message, which reads like the opposite. Treat the on-disk question as open on your build, and treat your terminal scrollback as holding the value regardless.
A false positive now costs the whole prompt. Before the refusal existed, an over-eager rule mangled an argument; now it drops a submission. The NOT_A_VALUE stop list is what stands between ordinary prose and a refused prompt, and its false-positive rate over a long working session is unmeasured.
One shape of this has been measured and fixed, and it explains the design. login: failed from a pasted log line and login: exampleuser from a credential are the same shape, and no rule tells them apart reliably. So the plugin makes masking and remembering two separate decisions, because they have very different costs.
Masking a word in place is a one-off: one prompt reads slightly wrong. Remembering it is not — the name enters the session-wide sweep, every later mention of that word is rewritten for the rest of the session, and a slash command containing it is refused outright. So the bar for masking stays low, and the bar for remembering is higher: the value must survive the NOT_A_VALUE stop list, must not sit behind a determiner (the user: … is about users in general), and must be at least four characters and outside the GENERIC_USER set. A guess that fails those is still masked where it was found; it just never teaches the session a word.
The stop list is hand-built and, as the code says, never finishes. It is the first gate, not the only one.
The username is only partly protected. A generic account name — admin, root, editor, wordpress, and the rest of the GENERIC_USER set — is masked where a rule finds it, but is never swept for afterwards, because those are words before they are names. A distinctive name is swept only after a structural form identified it once; a bare mention before that is left alone. A username shorter than four characters is never swept at all — bob, ann, kim are masked where a rule finds them and silently missed everywhere else, because three letters match too much ordinary text to hunt for. Put plainly: a site URL and a username are the pair most likely to still be sitting in a transcript. The Application Password is the part this plugin defends properly.
Untested surfaces. None of the following was investigated, and each would need its own event or its own measurement: subagent preload, compaction, /resume, session export, another plugin loaded alongside this one and rewriting text after it, and any host other than an interactive terminal (--print, the SDK host, a resumed session each queue prompts differently). Whether a live WordPress accepts an expanded credential is also out of scope — the round trip proves byte fidelity through bash, not that curl authenticates.
| Form | Example |
|---|---|
-u user:pass, unquoted | curl -u exampleuser:AAAABBBBCCCCDDDDEEEEFFFF … |
-u 'user:pass' / -u "user:pass", whole pair quoted | curl -u 'exampleuser:AAAA BBBB CCCC DDDD EEEE FFFF' … |
-u user:'pass' / -u user:"pass", quote opening after the colon | curl -u exampleuser:"AAAA BBBB CCCC DDDD EEEE FFFF" … |
--user= in any of the above quotings | curl --user=exampleuser:"AAAA BBBB …" … |
| URL userinfo | https://exampleuser:AAAABBBBCCCCDDDDEEEEFFFF@example.test/wp-json |
| Labelled, with a separator | app password: "AAAA BBBB …", password='…', wp_user = exampleuser |
| Labelled, without a separator | using user exampleuser and app password AAAA BBBB CCCC DDDD EEEE FFFF |
| A bare six-group Application Password | AAAA BBBB CCCC DDDD EEEE FFFF, anywhere, no label needed |
| The space-stripped 24-character form | AbCdEfGhIjKlMnOpQrStUvWx — mixed case required |
| A bare mention of an already-pinned username | log in as exampleuser — after a separator, a -u pair or a URL identified it |
Inside a double-quoted -u half, backslash escapes are counted the way the shell counts them, so a password containing ", $, ` ` or \` is masked whole rather than up to its first backslash, and the vault stores the bytes the shell would have handed the program.
What it deliberately declines:
$WP_APP_PASSWORD, "$WP_APP_PASSWORD", ${pass} name a secret rather than being one, and pass through untouched — including inside a quoted -u half._ or - puts the run outside the boundary, which is what keeps the engine's own toolu_… tool ids from being masked.[WP-PASS-xxxx] quoted back from an earlier turn is never re-minted, and nothing is minted from inside it.exampleuser is pinned, exampleuser_dev and exampleuser-ci stay readable — one word is not a mention.| Hook | What it buys |
|---|---|
prompt.submit | Masks credentials in what you type before the model sees it, logs the count, and staples a note telling the model how placeholders work. Refuses the submission outright when it is a slash command. |
skill.prompt | Masks the prompt text a skill computes. Covers the Skill-tool and preload paths, which have no composer submission behind them and so cannot be refused. |
tool.call (Skill) | Masks credentials in the arguments the model passes to the Skill tool — the model-invoked path, distinct from a typed /name. |
ui.render (UserMessage) | Rewrites the message row on screen. Cosmetic; see above. |
tool.call (Bash) | Substitutes real values back in, quoted for the bare/single/double context each placeholder sits in. Denies the call, naming only the unknown placeholders, when the vault has no value for one. |
From the plugin root:
node tests/hooks.test.mjs
No dependencies, no build step, no package.json — the harness strips the TypeScript types itself and evaluates hooks/hooks.ts as a module, so the plugin file is never edited to be testable. Round-trip cases shell out to real bash and compare the bytes a program is handed.
Current result: 99 passed, 0 failed, 1 known limitation.
A limitation is a case the plugin is known to get wrong. It is asserted and reported like a test, but never counted as a pass, and the run exits non-zero if the recorded behaviour has moved. The one on the page is an empty password (-u user:''), which the guard now declines rather than minting.
MIT — see the LICENSE file at the repository root.
hooks/hooks.ts 661 lines1import type { Register } from 'claude-code'
2
3// ---------------------------------------------------------------------------
4// The vault
5//
6// Module-level on purpose. `$.store` outlives the session; these two Maps do
7// not. They die when the module is dropped — session end, or a reload under
8// `--plugin-dir` — and nothing ever writes a real credential to disk.
9// ---------------------------------------------------------------------------
10
11type Kind = 'USER' | 'PASS'
12
13/** `${kind}:${value}` -> placeholder, so one credential keeps one token. */
14const tokenOf = new Map<string, string>()
15/** placeholder -> the real value, read only on the way into Bash. */
16const valueOf = new Map<string, string>()
17
18/**
19 * Usernames a labelled or structural form already pinned. A name is a secret
20 * only once something said so; after that every bare mention of it is one too.
21 */
22const knownUsers = new Set<string>()
23
24/**
25 * Salts the digest below. Same credential, same placeholder for as long as the
26 * session lives; a new session gives it a different one, so a leaked transcript
27 * cannot be correlated against another.
28 */
29const salt = (() => {
30 const bytes = new Uint8Array(16)
31 crypto.getRandomValues(bytes)
32 return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
33})()
34
35const PLACEHOLDER = /\[WP-(?:USER|PASS)-[0-9a-f]{4}\]/
36const PLACEHOLDER_G = /\[WP-(?:USER|PASS)-[0-9a-f]{4}\]/g
37
38/** Mints (or recalls) the placeholder standing in for `value`. */
39function mint(kind: Kind, value: string): string {
40 const key = `${kind}:${value}`
41 const known = tokenOf.get(key)
42 if (known) return known
43
44 // FNV-1a over the salt and the value, truncated to four hex digits.
45 let h = 0x811c9dc5
46 for (const ch of `${salt}:${key}`) {
47 h ^= ch.charCodeAt(0)
48 h = Math.imul(h, 0x01000193) >>> 0
49 }
50
51 let token = ''
52 for (let bump = 0; ; bump++) {
53 const digest = (((h + bump * 0x9e3779b9) >>> 0) & 0xffff).toString(16).padStart(4, '0')
54 token = `[WP-${kind}-${digest}]`
55 if (!valueOf.has(token)) break
56 }
57
58 tokenOf.set(key, token)
59 valueOf.set(token, value)
60 return token
61}
62
63// ---------------------------------------------------------------------------
64// Finding credentials
65// ---------------------------------------------------------------------------
66
67/**
68 * A WordPress Application Password as WP itself prints it: six groups of four
69 * alphanumerics. Distinctive enough to catch on its own, anywhere in a prompt.
70 */
71const APP_PASSWORD = String.raw`[A-Za-z0-9]{4}(?: [A-Za-z0-9]{4}){5}`
72
73/**
74 * The same secret with the spaces taken out, which is what
75 * `wp user application-password create --porcelain` hands back.
76 *
77 * `_` and `-` belong in the boundaries even though the secret never contains
78 * one: an identifier that merely ends in twenty-four alphanumerics is a
79 * different thing from a bare password, and the engine's own `toolu_<24>` tool
80 * ids are exactly that shape. Observed masking them in a live session.
81 */
82const APP_PASSWORD_BARE = String.raw`(?<![A-Za-z0-9_-])[A-Za-z0-9]{24}(?![A-Za-z0-9_-])`
83
84/** Names a credential field: `password:`, `wp_user =`, `App Password:`. */
85const USER_LABEL = String.raw`(?:wp[-_ ]?)?(?:user(?:[-_ ]?name)?|login)`
86const PASS_LABEL = String.raw`(?:wp[-_ ]?)?(?:app[-_ ]?(?:password|pass)|password|passwd|pass)`
87
88/**
89 * The pass labels that survive losing their colon. Bare `pass` is a verb far
90 * more often than it is a field, so it only counts with a separator.
91 */
92const PASS_LABEL_LOOSE = String.raw`(?:wp[-_ ]?)?(?:app[-_ ]?(?:password|pass)|password|passwd)`
93
94/**
95 * A value as a labelled form without a separator may write it: an identifier,
96 * two characters or more, punctuation allowed inside but never at the edge.
97 */
98const USER_VALUE = String.raw`[A-Za-z0-9](?:[A-Za-z0-9._@-]*[A-Za-z0-9])`
99/** Same, but six characters or more: below that a password is almost always prose. */
100const PASS_VALUE = String.raw`[A-Za-z0-9][A-Za-z0-9._@!#$%^&*+=-]{4,}[A-Za-z0-9]`
101
102/**
103 * What the word after a separator-less label cannot be.
104 *
105 * `password: hunter2` names a secret; `password reset` names a feature, and
106 * once the colon is gone the two are the same shape. The only thing left that
107 * tells them apart is the vocabulary: prose puts a function word or the other
108 * half of a compound noun there, and a credential never does.
109 */
110const NOT_A_VALUE = new Set(
111 (
112 'the a an this that these those it its their his her our your my ' +
113 'is isn are aren was wasn were been be being has have had having ' +
114 'will would can cannot could should shall may might must does did do done ' +
115 'and or but nor so then than if when while because although though ' +
116 'of in on at to for from with without into onto over under above below ' +
117 'by via as per about after before again also just only still never not ' +
118 'who whom whose which what where why how here there now ' +
119 'said says say tell tells told ask asks asked want wants wanted need needs ' +
120 'see saw seen get gets got give gives gave send sends sent make makes made ' +
121 'run runs ran use uses used type types typed click clicks clicked ' +
122 'seems looks looked keeps kept goes went comes came tries tried ' +
123 'experience experiences interface interfaces input inputs output outputs ' +
124 'data story stories journey journeys agent agents account accounts base ' +
125 'group groups role roles permission permissions session sessions id ids ' +
126 'list lists table tables model models setting settings profile profiles ' +
127 'feedback flow flows testing research guide guides manual docs ' +
128 'documentation error errors message messages name names email emails ' +
129 'record records count counts level levels access management behaviour ' +
130 'behavior preference preferences content facing friendly land space page ' +
131 'pages form forms field fields value values object objects entity entities ' +
132 'reset resets manager managers policy policies strength hash hashing ' +
133 'expired expires expiry incorrect invalid wrong required missing changed ' +
134 // What a log line puts after `login:` or `user:`. These are the words a
135 // pasted stack trace or audit line would otherwise teach the sweep.
136 'failed fails failure failures succeeded succeeds success unknown ' +
137 'denied granted allowed blocked locked unlocked attempt attempts retry ' +
138 'timeout timed created deleted updated removed added modified disabled ' +
139 'enabled anonymous unauthenticated unauthorized authenticated authorized ' +
140 'scope scopes context contexts directory folder home root-level ' +
141 'change changes rotation rotate protection protected authentication auth ' +
142 'prompt box entry length complexity requirements rules rule generator ' +
143 'above below both either neither each every any some no none such ' +
144 // Common nouns that sit right after `user` or `login` in ordinary prose.
145 'path paths file files folder folders directory directories url urls ' +
146 'config configs script scripts command commands link links option options ' +
147 'button buttons screen screens window windows menu menus tab tabs ' +
148 'action actions state states event events request requests response ' +
149 'responses query queries token tokens key keys secret secrets ' +
150 // WP-CLI puts its own verbs exactly where a username would go
151 // (`wp user create smashed`), and they are not usernames.
152 'create list add delete remove update import export generate meta term ' +
153 'application application-password app-password session signup spam ' +
154 'unspam check reset-password set-role add-role remove-role recount'
155 ).split(' '),
156)
157
158/**
159 * A determiner in front of the label means the sentence is about users in
160 * general, not about one named account: `the user said`, `a user of the API`.
161 */
162const PROSE_LEAD = /(?:^|[^A-Za-z0-9])(?:the|a|an|this|that|these|those|each|every|any|some|no|my|your|our|their|his|her|its|one|another|per|power|end|super|multiple|several|many|few|other|first|last|new|old|same)\s+$/i
163
164/**
165 * A slice of the prompt that holds a credential. `value` is the credential the
166 * slice stands for when the two differ — a double-quoted shell word writes
167 * `ab\"cd` for the four characters `ab"cd`, and it is the four the vault has to
168 * hold, or the value handed back to curl is not the one the user typed.
169 */
170type Span = { start: number; end: number; kind: Kind; value?: string; pin?: boolean }
171
172/**
173 * One way a credential shows up in text. `spans` reads the match's group
174 * offsets (the `d` flag) and says which slices are secret and what they are.
175 */
176type Rule = { re: RegExp; spans: (m: RegExpExecArray) => Span[] }
177
178/**
179 * Turns a capture group index into a span, or nothing when it did not match.
180 *
181 * `pin` marks a username the surrounding text identified **structurally** — a
182 * separator put it in a credential field, or a `-u` pair or a URL's userinfo
183 * put it in the one place a username can go. Only those are remembered for the
184 * later sweep; see `sweepable`.
185 */
186function group(m: RegExpExecArray, index: number, kind: Kind, pin = false): Span[] {
187 const at = m.indices?.[index]
188 if (!at) return []
189 const value = m[index]
190 if (!usable(value)) return []
191 return [{ start: at[0], end: at[1], kind, pin }]
192}
193
194/**
195 * One character of a double-quoted shell word: anything but the quote, or a
196 * backslash and whatever it escapes. Written out because the two rules that
197 * read a `-u` argument both need it, and because the earlier `[^"\\]*` — which
198 * gave up at the first backslash — is precisely how a password came to be half
199 * masked and half in the clear.
200 */
201const DQ_CHAR = String.raw`(?:[^"\\]|\\[\s\S])`
202
203/**
204 * The characters that word writes as an escape. Inside double quotes bash
205 * unescapes a backslash only before one of these; `\b` stays two characters,
206 * and unescaping it would put a password in the vault that nothing can log in
207 * with. Everything else passes through exactly as typed.
208 */
209function unescapeDq(value: string): string {
210 return value.replace(/\\(["\\$`])/g, '$1')
211}
212
213/** `group`, for a slice that sat inside double quotes and may carry escapes. */
214function dqGroup(m: RegExpExecArray, index: number, kind: Kind, pin = false): Span[] {
215 return group(m, index, kind, pin).map((s) => ({ ...s, value: unescapeDq(m[index]) }))
216}
217
218/**
219 * Whether a captured slice is worth masking. Placeholders (a prompt quoting an
220 * earlier turn) and shell or template references (`$WP_PASS`, `${pass}`) name a
221 * secret rather than being one, so they pass through untouched.
222 *
223 * The test looks inside a wrapping quote pair, because a half the quote-aware
224 * rules decline comes straight back here wearing its quotes: the unquoted `-u`
225 * rule claims whatever they let go. Without that, `-u user:"$WP_PASS"` is minted
226 * quotes and all — the vault swallows a reference, and the shell loses its own.
227 */
228function usable(value: string | undefined): value is string {
229 if (!value) return false
230 const inner = value.replace(/^(['"])([\s\S]*)\1$/, '$2')
231 if (!inner) return false
232 if (PLACEHOLDER.test(inner)) return false
233 return !/^[$%{]/.test(inner)
234}
235
236/** Whether a label with only whitespace after it is naming a value or writing English. */
237function labelled(m: RegExpExecArray, index: number): boolean {
238 const value = m[index]
239 if (!value || NOT_A_VALUE.has(value.toLowerCase())) return false
240 return !PROSE_LEAD.test(m.input.slice(0, m.index))
241}
242
243/**
244 * Whether a run of 24 alphanumerics is plausibly the secret and not an id.
245 *
246 * WP mints these from the full alphanumeric alphabet, so a real one is
247 * overwhelmingly mixed-case; an ObjectId, a truncated digest or a slug is not.
248 * The cost of the test is roughly one missed password in 250,000.
249 */
250function mixedCase(value: string): boolean {
251 return /[a-z]/.test(value) && /[A-Z]/.test(value)
252}
253
254/**
255 * Whether a word after a separator-less pass label is a secret and not an
256 * adverb. `password immediately` and `password hunter2` are the same shape and
257 * no stop list ever finishes; what a password has and English prose does not is
258 * a digit, a punctuation mark, or a capital somewhere other than the front.
259 *
260 * The WP-shaped forms never reach this test — the two shape rules find those
261 * whatever the label says — so the cost of being strict here is a missed
262 * all-lowercase passphrase, not a missed Application Password.
263 */
264function secretish(value: string): boolean {
265 return /[0-9]/.test(value) || /[^A-Za-z0-9]/.test(value) || /.[A-Z]/.test(value)
266}
267
268// Order is priority: an earlier rule wins any slice a later one also claims, so
269// the rules that know which half is the username come before the bare ones.
270const RULES: Rule[] = [
271 // curl -u 'admin:abcd EFGH 1234 ijkl MNOP 5678' (quoted, so spaces survive)
272 {
273 re: new RegExp(
274 String.raw`(?:^|\s)(?:-u|--user)[= ]\s*(?:'([^':]+):([^']*)'|"((?:[^":\\]|\\[\s\S])+):(${DQ_CHAR}*)")`,
275 'gd',
276 ),
277 spans: (m) => [
278 ...group(m, 1, 'USER', true),
279 ...group(m, 2, 'PASS'),
280 ...dqGroup(m, 3, 'USER', true),
281 ...dqGroup(m, 4, 'PASS'),
282 ],
283 },
284 // curl -u admin:'abcd EFGH ...' — the quote opens after the colon, so the
285 // unquoted rule below would stop at the first space and leave the rest bare.
286 // The double-quoted half counts escapes, so the closing quote it finds is the
287 // one the shell would find: a password holding `"`, `$` or `\` is masked
288 // whole rather than up to its first backslash.
289 {
290 re: new RegExp(
291 String.raw`(?:^|\s)(?:-u|--user)[= ]\s*([^\s:'"]+):(?:'([^']*)'|"(${DQ_CHAR}*)")`,
292 'gd',
293 ),
294 spans: (m) => [...group(m, 1, 'USER', true), ...group(m, 2, 'PASS'), ...dqGroup(m, 3, 'PASS')],
295 },
296 // curl -u admin:xxxx (unquoted)
297 {
298 re: /(?:^|\s)(?:-u|--user)[= ]\s*([^\s:'"]+):(\S+)/gd,
299 spans: (m) => [...group(m, 1, 'USER', true), ...group(m, 2, 'PASS')],
300 },
301 // https://admin:xxxx@example.com/wp-json/...
302 {
303 re: /https?:\/\/([^\s:/@'"]+):([^\s@'"]+)@/gd,
304 spans: (m) => [...group(m, 1, 'USER', true), ...group(m, 2, 'PASS')],
305 },
306 // App Password: "abcd EFGH ..." / password='xxxx'
307 {
308 re: new RegExp(String.raw`\b${PASS_LABEL}\s*[:=]\s*(?:'([^']+)'|"([^"]+)")`, 'gid'),
309 spans: (m) => [...group(m, 1, 'PASS'), ...group(m, 2, 'PASS')],
310 },
311 // App Password: abcd EFGH 1234 ijkl MNOP 5678 / password = xxxx
312 {
313 re: new RegExp(String.raw`\b${PASS_LABEL}\s*[:=]\s*(${APP_PASSWORD}|[^\s'"]+)`, 'gid'),
314 spans: (m) => group(m, 1, 'PASS'),
315 },
316 // app password abcd EFGH 1234 ijkl MNOP 5678 — dictated, so no colon survived.
317 {
318 re: new RegExp(
319 String.raw`(?<![A-Za-z0-9_-])${PASS_LABEL_LOOSE}\s+(${APP_PASSWORD}|${PASS_VALUE})(?![A-Za-z0-9])`,
320 'gid',
321 ),
322 spans: (m) =>
323 labelled(m, 1) && (m[1].includes(' ') || secretish(m[1])) ? group(m, 1, 'PASS') : [],
324 },
325 // username: "admin" / wp_user = admin
326 {
327 re: new RegExp(String.raw`\b${USER_LABEL}\s*[:=]\s*(?:'([^']+)'|"([^"]+)"|([^\s'",;]+))`, 'gid'),
328 // A separator is good evidence that the word beside it is a value, and it
329 // is enough to mask on. It is not enough to *pin* on: a pasted log line
330 // (`login: failed`) and a YAML fragment (`user: someone`) have the same
331 // shape as `username: smashed`, and a wrong pin is not wrong once — it
332 // rewrites that word on every later mention and refuses any slash command
333 // carrying it. So the mask is unconditional and the pin runs the same stop
334 // list and prose test the separator-less rule has to pass.
335 spans: (m) => [
336 ...group(m, 1, 'USER', labelled(m, 1)),
337 ...group(m, 2, 'USER', labelled(m, 2)),
338 ...group(m, 3, 'USER', labelled(m, 3)),
339 ],
340 },
341 // using user smashed and ... — same, for the username. Deliberately not a
342 // pin: this rule is the one that guesses, and a guess that poisons the sweep
343 // rewrites every later mention of an ordinary word.
344 {
345 re: new RegExp(
346 String.raw`(?<![A-Za-z0-9_-])${USER_LABEL}\s+(${USER_VALUE})(?![A-Za-z0-9._@-])`,
347 'gid',
348 ),
349 spans: (m) => (labelled(m, 1) ? group(m, 1, 'USER') : []),
350 },
351 // A bare Application Password, no label in sight.
352 {
353 re: new RegExp(String.raw`(?<![A-Za-z0-9])${APP_PASSWORD}(?![A-Za-z0-9])`, 'gd'),
354 spans: (m) => (usable(m[0]) ? [{ start: m.index, end: m.index + m[0].length, kind: 'PASS' }] : []),
355 },
356 // The same, space-stripped. Last, because 24 alphanumerics is a shape many
357 // innocent identifiers share and every rule above knows more than it does.
358 {
359 re: new RegExp(APP_PASSWORD_BARE, 'gd'),
360 spans: (m) =>
361 usable(m[0]) && mixedCase(m[0])
362 ? [{ start: m.index, end: m.index + m[0].length, kind: 'PASS' }]
363 : [],
364 },
365]
366
367/**
368 * Generic accounts and two-letter handles are words before they are names, so
369 * sweeping every bare mention would rewrite the prompt rather than protect it.
370 * They stay masked wherever a rule actually found them; they are just not
371 * hunted for afterwards.
372 */
373const GENERIC_USER = new Set([
374 'admin', 'administrator', 'root', 'user', 'users', 'test', 'tester', 'demo',
375 'guest', 'wordpress', 'wpadmin', 'editor', 'author', 'subscriber', 'owner',
376 'contributor', 'none', 'null', 'www', 'api', 'dev', 'staging', 'prod',
377 'production', 'local', 'localhost', 'example', 'site', 'blog', 'main',
378])
379
380/**
381 * Whether a pinned username is distinctive enough to hunt for in later prose.
382 * Only reached for a structurally pinned name — see the `pin` flag on `Span`.
383 */
384function sweepable(name: string): boolean {
385 return name.length >= 4 && !GENERIC_USER.has(name.toLowerCase())
386}
387
388function escapeRe(s: string): string {
389 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
390}
391
392function overlapping(spans: readonly Span[], s: Span): boolean {
393 return spans.some((c) => s.start < c.end && c.start < s.end)
394}
395
396/** Replaces every credential in `text` with its placeholder. */
397function redact(text: string): { text: string; users: number; passes: number } {
398 const claimed: Span[] = []
399
400 // A placeholder quoted back from an earlier turn names a secret; nothing may
401 // be minted out of its insides.
402 const reserved: Span[] = []
403 PLACEHOLDER_G.lastIndex = 0
404 for (let m = PLACEHOLDER_G.exec(text); m; m = PLACEHOLDER_G.exec(text)) {
405 reserved.push({ start: m.index, end: m.index + m[0].length, kind: 'PASS' })
406 }
407
408 for (const rule of RULES) {
409 rule.re.lastIndex = 0
410 for (let m = rule.re.exec(text); m; m = rule.re.exec(text)) {
411 for (const span of rule.spans(m)) {
412 if (overlapping(claimed, span) || overlapping(reserved, span)) continue
413 claimed.push(span)
414 }
415 // A zero-width match would spin forever; nudge past it.
416 if (m[0] === '') rule.re.lastIndex++
417 }
418 }
419
420 // A structural form knew which slice was a username because a separator or a
421 // `-u` pair put it somewhere only a username goes. Remember those, and the
422 // plain `log in as smashed` two turns later is a credential too.
423 //
424 // The separator-less rule is excluded on purpose. It reads `login path` the
425 // same way it reads `login smashed`, and a name it guessed wrong does not
426 // stay wrong once: the sweep would rewrite every later mention of an ordinary
427 // word for the rest of the session, and a slash command carrying that word
428 // would be refused outright.
429 for (const span of claimed) {
430 if (span.kind !== 'USER' || !span.pin) continue
431 const name = span.value ?? text.slice(span.start, span.end)
432 if (sweepable(name)) knownUsers.add(name)
433 }
434
435 for (const name of knownUsers) {
436 const re = new RegExp(String.raw`(?<![A-Za-z0-9_-])${escapeRe(name)}(?![A-Za-z0-9_-])`, 'g')
437 for (let m = re.exec(text); m; m = re.exec(text)) {
438 const span: Span = { start: m.index, end: m.index + name.length, kind: 'USER' }
439 if (overlapping(claimed, span) || overlapping(reserved, span)) continue
440 claimed.push(span)
441 }
442 }
443
444 let users = 0
445 let passes = 0
446 let out = text
447 for (const span of claimed.sort((a, b) => b.start - a.start)) {
448 const token = mint(span.kind, span.value ?? text.slice(span.start, span.end))
449 if (span.kind === 'USER') users++
450 else passes++
451 out = out.slice(0, span.start) + token + out.slice(span.end)
452 }
453
454 return { text: out, users, passes }
455}
456
457// ---------------------------------------------------------------------------
458// Putting them back, for Bash only
459// ---------------------------------------------------------------------------
460
461/**
462 * The quote each offset of `s` sits inside: `'`, `"`, or `''` for bare. An
463 * Application Password carries spaces, so a substitution has to know whether
464 * the shell will already be holding the word together.
465 */
466function quoteStates(s: string): string[] {
467 const states: string[] = new Array(s.length).fill('')
468 let quote = ''
469 for (let i = 0; i < s.length; i++) {
470 states[i] = quote
471 const c = s[i]
472 if ((quote === '' || quote === '"') && c === '\\') {
473 i++
474 if (i < s.length) states[i] = quote
475 continue
476 }
477 if (quote === '') {
478 if (c === "'" || c === '"') quote = c
479 } else if (c === quote) {
480 quote = ''
481 }
482 }
483 return states
484}
485
486const BARE_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/
487
488/** Renders `value` so the shell reads it as one word in the given context. */
489function forShell(value: string, quote: string): string {
490 if (quote === "'") return value.replace(/'/g, `'\\''`)
491 if (quote === '"') return value.replace(/[\\"$`]/g, (c) => `\\${c}`)
492 if (BARE_SAFE.test(value)) return value
493 return `'${value.replace(/'/g, `'\\''`)}'`
494}
495
496/** Swaps placeholders back into a command; names any it has no value for. */
497function expand(command: string): { command: string; missing: string[] } {
498 const states = quoteStates(command)
499 const missing: string[] = []
500
501 const out = command.replace(PLACEHOLDER_G, (token, at: number) => {
502 const value = valueOf.get(token)
503 if (value === undefined) {
504 if (!missing.includes(token)) missing.push(token)
505 return token
506 }
507 return forShell(value, states[at] ?? '')
508 })
509
510 return { command: out, missing }
511}
512
513const NOTE =
514 'wp-credential-guard: WordPress credentials in that prompt were replaced with ' +
515 '[WP-USER-xxxx] / [WP-PASS-xxxx] placeholders. Use the placeholders verbatim in ' +
516 'Bash commands (quoting them as you would the real value) — they are substituted ' +
517 'for the real credentials at execution and shell-quoted for you. They only work ' +
518 'in Bash, only in this session, and you cannot read the values behind them.'
519
520/**
521 * What the engine does with a typed `/name args`: it hands the text to the
522 * model in two places — the expansion `skill.prompt` computes, and a
523 * `<command-args>` envelope beside it that no event in the set can rewrite —
524 * and it stores the text the user typed rather than the text this chain
525 * returned. Rewriting cannot win here; refusing can.
526 */
527const BURNED =
528 'wp-credential-guard: SLASH COMMAND — a credential in a slash-command ' +
529 'argument reaches the model in an envelope no hook can rewrite, so this ' +
530 'prompt was refused rather than masked.'
531
532/**
533 * Shown in place of the refused prompt.
534 *
535 * Two claims of very different strength live here, and the wording keeps them
536 * apart. That the command did not run and the model never read the value
537 * follows from returning `{ drop }` — it is structural. That the prompt also
538 * stayed off disk is an observation, not a guarantee: the engine's own types
539 * describe this event as running "after the input became a user message",
540 * which reads like the opposite, and a single measurement against a live
541 * transcript found a refused submission leaving no `user` record and no
542 * `last-prompt` record while the same command allowed through left both. One
543 * build, one day. The message claims only what it can.
544 */
545const REFUSED =
546 'wp-credential-guard: REFUSED — a WordPress credential was typed as a ' +
547 'slash-command argument.\n\n' +
548 'The command did not run and the model never read the value. Whether the ' +
549 'prompt also stayed out of the session transcript depends on the engine ' +
550 'build: on the one this was measured against a refused submission left no ' +
551 'record at all, but that has not been verified across versions or hosts. ' +
552 'Either way the value is in your terminal scrollback, so revoke it if this ' +
553 'terminal is recorded or shared — or if you want certainty.\n\n' +
554 'To carry on: paste the credential as an ordinary message (no leading slash) ' +
555 'to get its placeholder, then re-run the slash command with the ' +
556 '[WP-PASS-xxxx] placeholder in place of the value.'
557
558/**
559 * Whether the submission is a slash command. `/` still leading the text is
560 * proof; its absence is not proof of the opposite, because it is unsettled
561 * whether the engine hands the prefix to this event at all — so the caller
562 * treats a false here as "unknown" and still says something.
563 */
564function looksLikeSlash(text: string): boolean {
565 return /^\s*\/[A-Za-z0-9]/.test(text)
566}
567
568export const register: Register = (on) => {
569 on('prompt.submit', async ($, e, next) => {
570 const found = redact(e.text)
571 if (found.users === 0 && found.passes === 0) return next(e)
572
573 const parts = [
574 found.users ? `${found.users} username${found.users > 1 ? 's' : ''}` : '',
575 found.passes ? `${found.passes} password${found.passes > 1 ? 's' : ''}` : '',
576 ].filter(Boolean)
577 $.ui.log(`wp-credential-guard: masked ${parts.join(' and ')} (session-only)`)
578
579 // A typed `/name args` carries the credential to the model twice over, and
580 // this chain's rewrite reaches neither copy. `skill.prompt` masks the
581 // expansion it computes; the `<command-args>` envelope beside it is not
582 // that text, and no event in the set can rewrite it. Observed live: one half
583 // of the message reached the model as [WP-PASS-xxxx] and the other half as
584 // the real password. Refusing the prompt is the only thing left that keeps
585 // the value out of the model's context.
586 //
587 // `$.ui.notice` needs an open tool dialog's id and there is none here, so
588 // the loud surfaces at this event are the pinned status line and the toast.
589 if (looksLikeSlash(e.text)) {
590 $.ui.log(BURNED)
591 $.ui.toast('wp-credential-guard: prompt refused — paste the credential as a plain message', {
592 timeoutMs: 20000,
593 })
594 $.ui.status('wp-credential-guard: slash-command credential refused — treat it as exposed')
595 return { drop: REFUSED }
596 }
597
598 // `looksLikeSlash` returning false is not proof the prompt was not one, so
599 // a stripped prefix still gets a line rather than silence.
600 $.ui.log(
601 'wp-credential-guard: if that was a slash command, the engine stored the ' +
602 'text you typed rather than the masked text — revoke the credential.',
603 )
604
605 const r = await next({ ...e, text: found.text })
606 if (r.drop !== undefined) return r
607 return { ...r, context: [...(r.context ?? []), NOTE] }
608 })
609
610 // A typed `/name` no longer reaches here with a credential in it — the
611 // submission is refused above. This covers the two expansions that have no
612 // composer submission behind them and so cannot be refused: the Skill tool's
613 // invocation and a preload. No matcher; any skill can be handed a credential.
614 on('skill.prompt', async ($, e, next) => {
615 const r = await next(e)
616 const found = redact(r.text)
617 if (found.users === 0 && found.passes === 0) return r
618 return { text: found.text }
619 })
620
621 // The model-invoked path. A user-typed `/name` never comes through here.
622 on('tool.call', { tool: 'Skill' }, ($, e, next) => {
623 if (e.tool !== 'Skill') return next(e)
624 if (e.args === undefined) return next(e)
625
626 const found = redact(e.args)
627 if (found.users === 0 && found.passes === 0) return next(e)
628
629 return next({ ...e, args: found.text })
630 })
631
632 // Cosmetic, and only cosmetic: this keeps the credential off the screen. The
633 // stored message is untouched and the file on disk still holds the real
634 // value. It hides the leak; it does not fix it.
635 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
636 const found = redact(e.props.text)
637 if (found.users === 0 && found.passes === 0) return next(e)
638
639 // `props.origin` is read-only — changing or dropping it makes the engine
640 // throw the rewrite away and draw its own row.
641 return next({ ...e, props: { ...e.props, text: found.text } })
642 })
643
644 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
645 if (e.tool !== 'Bash') return next(e)
646
647 const { command, missing } = expand(e.command)
648 if (missing.length > 0) {
649 return {
650 deny:
651 `wp-credential-guard has no value for ${missing.join(', ')} — the vault is ` +
652 `session-scoped and holds nothing under that name. Ask the user to paste the ` +
653 `credential again in this session.`,
654 }
655 }
656 if (command === e.command) return next(e)
657
658 return next({ ...e, command })
659 })
660}
661