SLOPSHOPPER

stiekem-toch

Redacts API keys and tokens from tool results and prompts, and lets the model use stored keys without reading them.

newguardtoastprompttoolnetwork
v0.3.0MITupdated 2026-10-25mitchellvanw/stiekem-toch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stiekem-toch
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ stiekem-toch │ ⏺ Read(src/auth.ts) │ Redacted 2 secrets from Bash: Stripe key, │ ⎿ Read 6 lines │ Stripe key │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ╭───────────────────────────────╮ ⏺ Bash(bun test) │ stiekem-toch │ ⎿ 3 pass, 1 fail │ Redacted 1 secret: Stripe key │ ╰───────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

stiekem-toch

Dutch for "secretly, after all".

A Claude Code mod that keeps API keys and tokens out of what the model reads and what the transcript keeps, and lets the model use keys you store without ever reading them.

When a tool's answer or something you type holds a key in a known format, or a key you stored, the key is replaced with [redacted: <kind>] before the model reads it or the transcript stores it. A Bash command the model writes with a key in it is denied, so it does not run. A key you store goes out only through the mod's own request tool, and only to the hosts you allowed it. The mod writes no files, and makes network requests only when the model calls its request tool.

What it checks

  • Known formats, listed in hooks/patterns.ts: private key blocks, AWS access keys, GitHub tokens, sk- API keys (Anthropic and OpenAI), Stripe keys, Slack tokens, Google API keys, and JSON Web Tokens.
  • Each key you stored, by its value: as is, URL-encoded, base64 (standard and URL-safe) and hex. This catches keys of any format, such as a password.

What it covers

  • A tool's text and its structured record, such as a Bash command's stdout, at any depth.
  • Anything you type in a prompt: what the model reads, the transcript's rows, the prompt history (the up arrow), and the record of a prompt you type while a turn runs. A key in a known format that you paste is kept for the session, so the model can still use it; see Pasted keys.
  • A slash command's arguments. The command runs with the key redacted, so a command that needs a key in its arguments gets the placeholder instead.
  • A Bash command the model writes with a key in it. The command is denied.

Stored secrets

Store a key and the hosts it may go to, and the model can make HTTPS requests with it through the mcp__stiekem-toch__request tool. The model names the key. The mod adds it to the request on the host and removes it from the response before the model reads it. The tool is offered even when nothing is stored, so the model knows this is how a key reaches a request.

Set them in your own terminal, so the key never passes through a session:

claude plugin configure stiekem-toch --values-stdin

Give it a JSON object whose vault holds the secrets, written as one line of JSON:

{"vault": "{\"github\":{\"value\":\"ghp_…\",\"hosts\":[\"api.github.com\"],\"header\":\"Authorization\",\"template\":\"Bearer {}\"}}"}

Each secret has:

  • value: the key, at least 8 characters.
  • hosts: the exact hosts it may be sent to.
  • header: the request header that carries it.
  • template: how the header is spelled, {} standing for the key. Optional; {} by default.

A secret missing any of these is dropped whole, so a typo never widens what it may do. The vault option is marked sensitive, so Claude Code keeps it in secure storage, not in settings.json. The hosts live in the same field on purpose: a model that can edit settings.json cannot add a host. If the field is not valid JSON, the request tool is not offered and a notice says why. Redaction by the known formats still runs.

The request tool refuses:

  • a URL that is not https://<host>/<path>, or that has user info, a port other than 443, or a backslash;
  • a host not on the secret's list;
  • a header of the model's own that would replace the secret's header, and any header holding a line break.

A redirect is followed. One that leaves the origin drops every request header, the secret's included; this was checked against Authorization and a custom X-Api-Key. A response body is cut at 100,000 characters.

The keys live only in the mod's memory for the session: never in its state, its store or a file.

Pasted keys

When a key in a known format is in a prompt you send, the mod keeps it in memory for the session under a name, pasted-1, pasted-2 and so on. The model reads the name in the key's place and is told it can send the key with the request tool. A key you paste twice keeps its first name.

A pasted key has no hosts to start with. The first time the model sends it to a host, you are asked whether to send it there. If you allow it, that host stays allowed for the session. If you refuse, close the dialog, or no one can answer, as in claude -p, the request does not run. A pasted key always goes in Authorization: Bearer <key>. A key that goes in another header, such as an Anthropic x-api-key, has to be stored in the vault instead.

Keys in a slash command's arguments are not kept, only redacted.

What it does not cover

  • The model's own tool calls. The transcript keeps a tool call's input as the model wrote it. A key the model types into a command stays in the transcript, even though the command does not run.
  • Other tools' inputs, such as file writes and edits. Only Bash commands are checked.
  • What the prompt box shows while you type. A key stays on screen until you press Enter, and in your terminal's scrollback after that.
  • Files you mention with @. The transcript keeps a mentioned file's content as read.
  • Transcripts from before the mod was installed.
  • The model's own replies.
  • Keys in formats not on the list that you have not stored, and values that do not look like a key.
  • Images and other non-text content.
  • Other mods. A mod installed beside this one can hook the requests the request tool makes and read their headers. Install only mods you trust beside it.
  • The model acting as you on this machine. The model's Bash commands run as your user, so it could change this mod's installed code or read Claude Code's secure storage. Redaction catches a key printed as is, but not one transformed in a way the mod does not check. To close this, turn on Claude Code's Bash sandbox, so the model can write only inside the project, and deny edits to the mod's folder and your settings.

When the check fails

If a check throws, nothing it was checking passes through unchecked:

  • A tool's result is withheld.
  • A row of the conversation keeps a notice in place of its text.
  • A prompt is not sent.
  • A slash command does not run.
  • A request through the request tool returns nothing.

Where it runs

Per the mods documentation, hooks run in the terminal, the Desktop app's Code tab (not in WSL sessions), the VS Code chat panel, claude -p, the Agent SDK and Remote Control. Notices appear as toasts only where something draws, so claude -p shows none. Cloud sessions run the mod only when the plugin reaches them.

Install

Requires Claude Code 2.1.275 or later.

In Claude Code:

/plugin install stiekem-toch --marketplace mitchellvanw/stiekem-toch

Or from a terminal:

claude plugin install stiekem-toch --marketplace mitchellvanw/stiekem-toch

This installs for you, in user scope. To try it for one session from a clone instead:

claude --plugin-dir /path/to/stiekem-toch

Develop

claude plugin validate .
claude plugin test .
tsc -p .

tsc needs the types Claude Code writes to .claude-plugin/types/ once it has loaded the plugin from this folder, for example with claude --plugin-dir ..

The tests cover:

  • the patterns, and the redaction of structured records;
  • the Bash deny;
  • prompts and slash-command arguments;
  • the vault: its parsing, the host and header checks, and the redaction of each spelling;
  • pasted keys: keeping them, and asking before each new host;
  • the request tool, with the network stood in for.

The rest was checked by hand, with fake keys:

  • the request tool, in a headless run against an echo service;
  • the redirect behaviour, against local servers;
  • the prompt history, the queued-prompt record and a slash command's arguments, in an interactive session.

Version 0.2.0.

Source 3 files
hooks/register.ts 194 lines
1import type { Register } from 'claude-code'
2import { formatMatches, redact, redactBlock, redactDeep, secretKinds, withhold } from './patterns'
3import { checkRequest, hostOf, parseVault, pastedGrant, spellings, type Grant, type Request } from './vault'
4
5const TOOL = 'request'
6const TOOL_NAME = `mcp__stiekem-toch__${TOOL}`
7// Bodies past this are cut before the model reads them.
8const MAX_BODY = 100_000
9const ALLOW = 'Send it'
10
11export const register: Register = (on, options) => {
12  // The stored secrets live here alone, in this module's memory: never in `$.state`, `$.store` or a file.
13  // Keys the person pastes into a prompt join them for the session.
14  const vault = new Map<string, Grant>()
15  let isVaultBroken = false
16  try {
17    for (const [name, grant] of parseVault(options.vault as string | undefined)) vault.set(name, grant)
18  } catch {
19    isVaultBroken = true
20  }
21  let known = spellings(vault)
22
23  const keep = (name: string, grant: Grant) => {
24    vault.set(name, grant)
25    known = spellings(vault)
26  }
27
28  // The tool is offered with an empty vault too, so the model learns how a key reaches a request
29  // instead of reading one from a file into a Bash command. Its `secret` takes no enum: a key
30  // kept mid-session is usable before the tool list is read again, and an unknown name is refused.
31  const toolSpec = () => {
32    const listing = [...vault].map(([name, g]) =>
33      g.pasted === undefined
34        ? `- "${name}": ${g.hosts.join(', ')}`
35        : `- "${name}": a ${g.pasted} the person pasted, sent as a bearer token; the person is asked before it goes to a host they have not allowed${g.hosts.length > 0 ? ` (allowed: ${g.hosts.join(', ')})` : ''}`,
36    )
37    return {
38      name: TOOL,
39      description: [
40        'Makes an HTTPS request with a stored secret attached. You never see the secret: the plugin adds it to the request and removes it from the response.',
41        listing.length > 0 ? `Each secret goes only to its own hosts:\n${listing.join('\n')}` : 'No secret is stored yet.',
42        'The person adds one by pasting it into a prompt, which keeps it for the session, or for good with `claude plugin configure stiekem-toch --values-stdin` in their own terminal.',
43        'Use this instead of putting a key in a Bash command, which is refused, or reading one from a file into a command.',
44      ].join('\n'),
45      inputSchema: {
46        type: 'object',
47        properties: {
48          secret: { type: 'string', description: 'The name of a stored secret' },
49          method: { type: 'string', enum: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] },
50          url: { type: 'string', description: "https://<host>/<path>, the host one of the secret's own" },
51          headers: { type: 'object', additionalProperties: { type: 'string' } },
52          body: { type: 'string' },
53        },
54        required: ['secret', 'url'],
55      },
56      isDeferred: false,
57    }
58  }
59
60  on('session.start', async ($, e, next) => {
61    const started = await next(e)
62
63    if (isVaultBroken) $.ui.toast(`${$.plugin.name}: the vault option is not valid JSON, so no configured secret can be used`)
64    await $.tool.register(toolSpec())
65
66    return started
67  })
68
69  // The request runs here, on the host: the model's call names the secret, never holds it.
70  on('tool.call', { tool: TOOL_NAME }, async ($, e) => {
71    const req = e as unknown as Request
72    const grant = vault.get(req.secret)
73    const host = hostOf(req.url)
74
75    // A pasted key has no hosts of its own: the person allows each one at first use.
76    if (grant?.pasted !== undefined && host !== undefined && !grant.hosts.includes(host)) {
77      const answer = await $.ui
78        .ask(`Send the ${grant.pasted} you pasted to ${host}? It is added as a bearer token, and ${host} stays allowed for this session.`, {
79          options: [ALLOW, "Don't send it"],
80          header: 'Secret',
81        })
82        .catch(() => undefined)
83      if (answer !== ALLOW) return { deny: `${$.plugin.name}: the person did not allow "${req.secret}" to go to ${host}.` }
84
85      keep(req.secret, { ...grant, hosts: [...grant.hosts, host] })
86    }
87
88    const checked = checkRequest(vault, req)
89    if ('deny' in checked) return { deny: `${$.plugin.name}: ${checked.deny}` }
90
91    const res = await $.http.fetch(checked.url, checked.init)
92
93    const kinds: string[] = []
94    const body = res.text.length > MAX_BODY ? `${res.text.slice(0, MAX_BODY)}\n[cut at ${MAX_BODY} characters]` : res.text
95    const result = redactDeep({ status: res.status, headers: res.headers, body }, kinds, known)
96
97    if (kinds.length > 0) $.ui.toast(`Redacted ${kinds.length} secret${kinds.length === 1 ? '' : 's'} from a response: ${kinds.join(', ')}`)
98
99    // A plugin's tool answers text. It is written after the redaction, so JSON escaping cannot hide a secret from it.
100    return { result: JSON.stringify(result, null, 2) }
101  }).catch($ => ({ deny: `${$.plugin.name}: the request failed, so nothing is returned.` }))
102
103  // A tool's answer, before the model reads it and before the transcript keeps its structured record.
104  on('tool.call', async ($, e, next) => {
105    if (e.tool === 'Bash') {
106      const kinds = secretKinds(e.command, known)
107
108      // A command that holds a key would send it somewhere or write it down, so it does not run.
109      if (kinds.length > 0) {
110        $.ui.toast(`Blocked a Bash command holding a ${kinds[0]}`)
111        return {
112          deny: `${$.plugin.name}: the command holds what looks like a ${kinds[0]}, so it did not run. Leave the value out of the command.`,
113        }
114      }
115    }
116
117    const ran = await next(e)
118
119    if (ran.deny !== undefined) return ran
120
121    const kinds: string[] = []
122    const answer = {
123      ...ran,
124      text: redactDeep(ran.text, kinds, known) as typeof ran.text,
125      result: redactDeep(ran.result, kinds, known) as typeof ran.result,
126    }
127
128    if (kinds.length > 0) {
129      $.ui.toast(`Redacted ${kinds.length} secret${kinds.length === 1 ? '' : 's'} from ${e.tool}: ${kinds.join(', ')}`)
130    }
131
132    return answer
133  }).catch($ => ({ deny: `${$.plugin.name}: its check failed, so this call's result is withheld.` }))
134
135  // What the person typed, before the prompt history and the queue record keep it.
136  // A slash command's line takes no rewrite here; its arguments are masked at command.run below.
137  on('prompt.submit', async ($, e, next) => {
138    if (e.command !== undefined) return next(e)
139
140    // A key the person pastes is one they mean the model to use: keep it, so the redaction names it.
141    const pasted = formatMatches(e.text, known)
142    const kept: string[] = []
143    for (const { kind, value } of pasted) {
144      if (known.some(([form]) => form === value)) continue
145      const name = `pasted-${[...vault.values()].filter(g => g.pasted !== undefined).length + 1}`
146      keep(name, pastedGrant(kind, value))
147      kept.push(`"${name}", a ${kind}`)
148    }
149    if (kept.length > 0) await $.tool.register(toolSpec())
150
151    const kinds: string[] = []
152    const text = redact(e.text, kinds, known)
153
154    if (kinds.length === 0) return next(e)
155
156    $.ui.toast(
157      `Redacted ${kinds.length} secret${kinds.length === 1 ? '' : 's'} from your prompt: ${kinds.join(', ')}` +
158        (kept.length > 0 ? `. Kept for this session; you are asked before it goes to a host.` : ''),
159    )
160    if (kept.length === 0) return next({ ...e, text })
161
162    const note = `${$.plugin.name} kept what the person pasted, for this session: ${kept.join('; ')}. Send it with the ${TOOL_NAME} tool, naming it; the person is asked before it goes to each host.`
163    return next({ ...e, text, context: [...(e.context ?? []), note] })
164  }).catch(($, e, next) => (next.called ? next(e) : { drop: `${$.plugin.name}: its check failed, so the prompt was not sent.` }))
165
166  // A slash command's arguments, before the command runs and before its rows and its history line keep them.
167  on('command.run', ($, e, next) => {
168    const kinds: string[] = []
169    const args = redact(e.args, kinds, known)
170
171    if (kinds.length === 0) return next(e)
172
173    $.ui.toast(`Redacted ${kinds.length} secret${kinds.length === 1 ? '' : 's'} from /${e.command}: ${kinds.join(', ')}`)
174    return next({ ...e, args })
175  }).catch(($, e, next) => (next.called ? next(e) : { text: `${$.plugin.name}: its check failed, so /${e.command} did not run.` }))
176
177  // The rows the model reads and the transcript keeps: what the person typed, a command's rows, a queued
178  // prompt's record, and any tool result the hook above did not reach.
179  for (const door of ['tool-result', 'prompt', 'command', 'queue'] as const) {
180    on('session.append', { door }, ($, e, next) => {
181      const kinds: string[] = []
182      const content = e.message.content.map(block => redactBlock(block, kinds, known))
183
184      if (kinds.length === 0) return next(e)
185
186      $.ui.toast(`Redacted ${kinds.length} secret${kinds.length === 1 ? '' : 's'}: ${kinds.join(', ')}`)
187      return next({ ...e, message: { ...e.message, content } })
188    }).catch(($, e, next) =>
189      // The check failed before it could read the row, so the row keeps nothing it did not read.
190      next.called ? next(e) : next({ ...e, message: { ...e.message, content: e.message.content.map(withhold) } }),
191    )
192  }
193}
194
hooks/patterns.ts 92 lines
1import { redactKnown } from './vault'
2
3// Known key and token formats. Each pattern starts at a word boundary, so an
4// ordinary word such as "task-management" does not match.
5const PATTERNS: readonly { kind: string; re: RegExp }[] = [
6  { kind: 'private key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
7  { kind: 'AWS access key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
8  { kind: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
9  { kind: 'GitHub token', re: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g },
10  { kind: 'API key', re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
11  { kind: 'Stripe key', re: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
12  { kind: 'Slack token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
13  { kind: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
14  // A JWT's header, then two to four more parts. Those parts may be empty: an unsigned token ends in '.', and a JWE has five.
15  { kind: 'JSON Web Token', re: /\beyJ[A-Za-z0-9_-]{10,}(?:\.[A-Za-z0-9_-]*){2,4}/g },
16]
17
18export type Block = { type: string; [field: string]: unknown }
19
20const WITHHELD = '[withheld: stiekem-toch could not check this]'
21
22// Each spelling of a stored secret, with the name the model reads in its place.
23export type Known = readonly [string, string][]
24
25// Replaces each secret in `text` with its kind, and records the kind in `kinds`:
26// first the stored secrets by value, then the known formats.
27export function redact(text: string, kinds: string[] = [], known: Known = []): string {
28  return replaceFormats(redactKnown(text, known, kinds), kind => kinds.push(kind))
29}
30
31// Each key in a known format that `text` holds and that is not a stored secret,
32// with its kind, found in the order `redact` replaces them.
33export function formatMatches(text: string, known: Known = []): { kind: string; value: string }[] {
34  const found: { kind: string; value: string }[] = []
35  replaceFormats(redactKnown(text, known), (kind, value) => found.push({ kind, value }))
36  return found
37}
38
39function replaceFormats(text: string, onMatch: (kind: string, value: string) => void): string {
40  return PATTERNS.reduce(
41    (out, { kind, re }) =>
42      out.replace(re, value => {
43        onMatch(kind, value)
44        return `[redacted: ${kind}]`
45      }),
46    text,
47  )
48}
49
50// The kind of each secret `text` holds, one entry per match.
51export function secretKinds(text: string, known: Known = []): string[] {
52  const kinds: string[] = []
53  redact(text, kinds, known)
54  return kinds
55}
56
57// A content block with its text redacted: a text block, or the text of a tool
58// result, whether a string or the text blocks it holds. Other fields pass through.
59export function redactBlock(block: Block, kinds: string[], known: Known = []): Block {
60  if (block.type === 'text' && typeof block.text === 'string') {
61    return { ...block, text: redact(block.text, kinds, known) }
62  }
63
64  if (block.type === 'tool_result') {
65    const { content } = block
66
67    if (typeof content === 'string') return { ...block, content: redact(content, kinds, known) }
68    if (Array.isArray(content)) return { ...block, content: content.map(b => redactBlock(b as Block, kinds, known)) }
69  }
70
71  return block
72}
73
74// Every string in a value, at any depth, redacted: a tool's structured record has no fixed shape.
75export function redactDeep(value: unknown, kinds: string[], known: Known = []): unknown {
76  if (typeof value === 'string') return redact(value, kinds, known)
77  if (Array.isArray(value)) return value.map(v => redactDeep(v, kinds, known))
78  if (value !== null && typeof value === 'object') {
79    return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, redactDeep(v, kinds, known)]))
80  }
81
82  return value
83}
84
85// The stand-in for a block the check could not read: its text, or a tool result's, replaced.
86export function withhold(block: Block): Block {
87  if (block.type === 'text') return { ...block, text: WITHHELD }
88  if (block.type === 'tool_result') return { ...block, content: WITHHELD }
89
90  return block
91}
92
hooks/vault.ts 168 lines
1// Secrets the person stores for the model to use without reading them. Each one
2// is bound to the hosts it may be sent to and the header that carries it.
3export type Grant = {
4  value: string
5  hosts: readonly string[]
6  header: string
7  // How the header's value is spelled, `{}` standing for the secret: `Bearer {}`.
8  template: string
9  // Set on a key the person pasted into a prompt, to its kind. Such a grant starts
10  // with no hosts; each one is added when the person allows it at first use.
11  pasted?: string
12}
13
14export type Vault = ReadonlyMap<string, Grant>
15
16export type Request = {
17  secret: string
18  method?: string
19  url: string
20  headers?: Record<string, string>
21  body?: string
22}
23
24export type Checked = { url: string; init: { method: string; headers: Record<string, string>; body?: string } }
25
26const NAME = /^[a-z0-9_-]{1,32}$/
27const HOST = /^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/
28// https only, a bare host (no user info, no port but 443), a path with no backslash.
29const URL_SHAPE = /^https:\/\/([a-z0-9.-]+)(?::443)?(\/[^\s#\\]*)?$/i
30const METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'])
31
32// The vault from the `vault` option: a JSON object of grants by name. A grant
33// that does not hold its shape is dropped whole, so a typo never widens one.
34export function parseVault(text: string | undefined): Vault {
35  const vault = new Map<string, Grant>()
36  if (text === undefined || text.trim() === '') return vault
37
38  const parsed: unknown = JSON.parse(text)
39  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return vault
40
41  for (const [name, raw] of Object.entries(parsed)) {
42    const g = raw as Partial<Grant>
43    const template = g.template ?? '{}'
44    const isGrant =
45      NAME.test(name) &&
46      typeof g.value === 'string' &&
47      g.value.length >= 8 &&
48      Array.isArray(g.hosts) &&
49      g.hosts.length > 0 &&
50      g.hosts.every(h => typeof h === 'string' && HOST.test(h)) &&
51      typeof g.header === 'string' &&
52      /^[A-Za-z0-9-]+$/.test(g.header) &&
53      typeof template === 'string' &&
54      template.includes('{}')
55
56    if (isGrant) vault.set(name, { value: g.value!, hosts: g.hosts!, header: g.header!, template })
57  }
58
59  return vault
60}
61
62// A key pasted into a prompt, kept for the session. It goes to no host until the
63// person allows one, and then as a bearer token.
64export function pastedGrant(kind: string, value: string): Grant {
65  return { value, hosts: [], header: 'Authorization', template: 'Bearer {}', pasted: kind }
66}
67
68// The host a request's URL names, lowercased, or undefined when the URL is not https://<host>/<path>.
69export function hostOf(url: string): string | undefined {
70  return URL_SHAPE.exec(url)?.[1]?.toLowerCase()
71}
72
73// The fetch a request comes to, or why it does not run. The secret goes in
74// only here, after the host is held to the grant.
75export function checkRequest(vault: Vault, req: Request): Checked | { deny: string } {
76  const grant = vault.get(req.secret)
77  if (grant === undefined) return { deny: `no secret is named "${req.secret}"` }
78
79  const host = hostOf(req.url)
80  if (host === undefined) return { deny: 'the URL must be https://<host>/<path>, with no user info or port' }
81
82  if (!grant.hosts.includes(host)) return { deny: `"${req.secret}" may be sent to ${grant.hosts.join(', ')} only, not ${host}` }
83
84  const method = (req.method ?? 'GET').toUpperCase()
85  if (!METHODS.has(method)) return { deny: `the method ${method} is not allowed` }
86
87  const headers: Record<string, string> = {}
88  for (const [name, value] of Object.entries(req.headers ?? {})) {
89    if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) return { deny: 'a header holds a line break' }
90    // The model may not set the header the secret rides in, nor where it goes.
91    if (name.toLowerCase() === grant.header.toLowerCase() || name.toLowerCase() === 'host') continue
92    headers[name] = value
93  }
94  headers[grant.header] = grant.template.replace('{}', grant.value)
95
96  return { url: req.url, init: { method, headers, ...(req.body === undefined ? {} : { body: req.body }) } }
97}
98
99// Every spelling of each secret a response or output is checked for: the value
100// as is, URL-encoded, base64 (standard and URL-safe, unpadded) and hex.
101// Each pairs with the name the model reads in its place.
102export function spellings(vault: Vault): readonly [string, string][] {
103  const out: [string, string][] = []
104
105  for (const [name, { value }] of vault) {
106    const b64 = base64(value)
107    const forms = new Set([
108      value,
109      encodeURIComponent(value),
110      b64.replace(/=+$/, ''),
111      b64.replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'),
112      hex(value),
113      hex(value).toUpperCase(),
114    ])
115    for (const form of forms) out.push([form, name])
116  }
117
118  // Longest first, so a spelling that holds another is replaced whole.
119  return out.sort((a, b) => b[0].length - a[0].length)
120}
121
122export function redactKnown(text: string, known: readonly [string, string][], kinds: string[] = []): string {
123  return known.reduce((out, [form, name]) => {
124    if (!out.includes(form)) return out
125    kinds.push(`stored secret "${name}"`)
126    return out.split(form).join(`[redacted: stored secret "${name}"]`)
127  }, text)
128}
129
130function utf8(text: string): number[] {
131  return [...encodeURIComponent(text)].reduce<{ bytes: number[]; pct: string }>(
132    (acc, ch) => {
133      if (acc.pct !== '' || ch === '%') {
134        acc.pct += ch
135        if (acc.pct.length === 3) {
136          acc.bytes.push(parseInt(acc.pct.slice(1), 16))
137          acc.pct = ''
138        }
139      } else {
140        acc.bytes.push(ch.charCodeAt(0))
141      }
142      return acc
143    },
144    { bytes: [], pct: '' },
145  ).bytes
146}
147
148function hex(text: string): string {
149  return utf8(text)
150    .map(b => b.toString(16).padStart(2, '0'))
151    .join('')
152}
153
154const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
155
156function base64(text: string): string {
157  const bytes = utf8(text)
158  let out = ''
159  for (let i = 0; i < bytes.length; i += 3) {
160    const [a = 0, b = 0, c = 0] = bytes.slice(i, i + 3)
161    const n = (a << 16) | (b << 8) | c
162    out += B64.charAt((n >> 18) & 63) + B64.charAt((n >> 12) & 63)
163    out += i + 1 < bytes.length ? B64.charAt((n >> 6) & 63) : '='
164    out += i + 2 < bytes.length ? B64.charAt(n & 63) : '='
165  }
166  return out
167}
168