SLOPSHOPPER

WP Credential Guard

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

newrowsguardtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview could not run: ReferenceError: crypto is not defined at <anonymous> (file:///inner.js:52:9) at anonymous (file:///inner.js:54:3) at run (inner.js:848:9) at /Users/raymondxu/slopshopper-mods/scripts/h
README

WP Credential Guard

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


What it does

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.


Install

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.

1. Plugin marketplace (recommended)

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.

2. A local checkout, for development

--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.

3. Copying the folder — read this before you do it

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

Then confirm the hooks are actually live

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.


What this does not protect

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.


What it catches

FormExample
-u user:pass, unquotedcurl -u exampleuser:AAAABBBBCCCCDDDDEEEEFFFF …
-u 'user:pass' / -u "user:pass", whole pair quotedcurl -u 'exampleuser:AAAA BBBB CCCC DDDD EEEE FFFF' …
-u user:'pass' / -u user:"pass", quote opening after the coloncurl -u exampleuser:"AAAA BBBB CCCC DDDD EEEE FFFF" …
--user= in any of the above quotingscurl --user=exampleuser:"AAAA BBBB …" …
URL userinfohttps://exampleuser:AAAABBBBCCCCDDDDEEEEFFFF@example.test/wp-json
Labelled, with a separatorapp password: "AAAA BBBB …", password='…', wp_user = exampleuser
Labelled, without a separatorusing user exampleuser and app password AAAA BBBB CCCC DDDD EEEE FFFF
A bare six-group Application PasswordAAAA BBBB CCCC DDDD EEEE FFFF, anywhere, no label needed
The space-stripped 24-character formAbCdEfGhIjKlMnOpQrStUvWx — mixed case required
A bare mention of an already-pinned usernamelog 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:

  • Shell and template references. $WP_APP_PASSWORD, "$WP_APP_PASSWORD", ${pass} name a secret rather than being one, and pass through untouched — including inside a quoted -u half.
  • Twenty-four characters that are not a password. An all-lowercase slug, a hex ObjectId, an all-uppercase constant: WordPress mints these from the full alphanumeric alphabet, so a real one is overwhelmingly mixed-case. The cost is roughly one missed password in 250,000.
  • Identifiers that merely end in 24 alphanumerics. A leading _ or - puts the run outside the boundary, which is what keeps the engine's own toolu_… tool ids from being masked.
  • Existing placeholders. A [WP-PASS-xxxx] quoted back from an earlier turn is never re-minted, and nothing is minted from inside it.
  • A pinned name inside a longer word. Once exampleuser is pinned, exampleuser_dev and exampleuser-ci stay readable — one word is not a mention.

The five hooks

HookWhat it buys
prompt.submitMasks 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.promptMasks 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.

Running the tests

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.


Requirements and compatibility

  • Claude Code, with plugin hook support. This is a hooks plugin, so it is Claude-Code-only — unlike the skills in this repository, it will not run under Codex, Cursor or GitHub Copilot, which have no equivalent event to hook.
  • Node for the test suite. Nothing else; no install step.

License

MIT — see the LICENSE file at the repository root.

Source 1 files
hooks/hooks.ts 661 lines
1import 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