SLOPSHOPPER

secret-box

Keeps secret values out of the chat: write {{secret:NAME}} in a Bash command and the value is filled in at execution time from the OS keyring, and any known…

newguardcommandtoastprocess
★ 1v0.1.1MITupdated 2026-10-15iuliandita/mods/mods/secret-box/claude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-box
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ secret-box │ ⏺ Read(src/auth.ts) │ secret-box: scrubbing is off, could not │ ⎿ Read 6 lines │ load secrets │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /secret-box ⎿ secret-box: secret-box: mode on ⎿ secret-box: secrets: could not be loaded (check secret-tool and that the keyring is unlocked) ⎿ secret-box: wrapper: /plugins/secret-box/bin/secret-box ⎿ secret-box: add one with: /plugins/secret-box/bin/secret-box set NAME (in a terminal; use {{secret:NAME}} in commands) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

mods

Claude Code mods you can install one at a time. guard-rules, secret-box, prompt-gate and skill-pick also run on Codex and OpenCode, and statusline and delegation-board on OpenCode; what works on which host, and why.

Mods are plugins built on the hooks-module API. They run with your permissions and are not sandboxed: read a mod before installing it. Each mod lists what leaves your machine (egress) in its README and in mods.json.

Install

claude plugin marketplace add iuliandita/mods claude plugin install <mod>@iuliandita-mods

Run it in a terminal. It works for Claude Code and loads in Claude Desktop sessions too.

These calls install from the GitHub marketplace. From a clone, scripts/mods install <mod> registers that clone as the marketplace instead (or --source <owner/repo>).

For Codex and OpenCode, see the mod's README and INSTALL.md. OpenCode hook plugins use opencode.json; its terminal UI plugins use the global cli.json instead. Prompt advisers make model calls even in their default dry mode; check the host's costs and privacy limits before installing them.

Catalog

  • guard-rules: blocks AI attribution in commits and PRs, direct commits to protected branches, commits under the wrong git identity, and mutating kubectl, helm and terraform calls on protected or unclassified environments.
  • secret-box: keeps secret values out of the chat; a secret placeholder in a Bash command is filled in at execution time from the OS keyring.
  • prompt-gate: before the main model spends a turn on a vague prompt, a small model says so and asks one short question. With a TypeSafe API key in the environment the prompt goes to a third party (Jev); jevPaths restricts where.
  • skill-pick: names the 1 to 5 installed skills that fit each prompt. With a TypeSafe API key the prompt and skill descriptions go to a third party (Jev); jevPaths restricts where.
  • delegation-board: a live pane of the session's subagents and teammates: type, model, effort, state, elapsed time and tokens; in Claude Desktop, a compact band above the prompt.
  • statusline: a two-row status line above the prompt in the terminal and Claude Desktop: model and effort, git state, context against your auto-compact window, 5h and 7d limits with pace, cache, cost and time. In a git repo with a GitHub or GitLab remote it runs gh or glab for PR and issue counts; forge=off turns that off.

mods.json has the full catalog: egress, modes, requirements, and the install command per mod. All six mods have recorded Claude terminal checks; the compatibility page lists the tested behavior and versions. Other Claude surfaces remain unverified.

Private mods

Put your own mods in local/mods/<name>/ (gitignored) and install them with scripts/mods install <name> --local.

License

MIT

Project and support

Made by iuliandita. Other projects.

See installation and recovery, contributing, documentation, and private security reporting.

agents provides global prompts and role definitions; skills provides individually installed task guidance. Installing one collection does not install the others.

Source 2 files
hooks/register.ts 267 lines
1import type { EngineInterface as Engine, Register } from 'claude-code'
2
3import { makeScrubber, MIN_LEN, placeholderNames, quote, rewriteCommand, scrubBlocks, scrubToolAnswer, withhold, type Block, type Scrubber } from './scrub'
4
5type Mode = 'off' | 'dry' | 'on'
6
7// An unreadable option counts as on, so a typo cannot switch the box off.
8const parseMode = (v: unknown): Mode => (v === 'off' || v === 'dry' ? v : 'on')
9
10function wrapperOf($: Engine): string {
11  return `${$.plugin.root}/bin/secret-box`
12}
13
14const LOAD_FAILED = (wrapper: string) =>
15  `secret-box: the stored secrets could not be loaded, so the command was blocked before it ran (fail closed). Check that secret-tool works and the keyring is unlocked (run "${quote(wrapper)} names" in a terminal), or set mode to off or dry in the plugin options. The placeholders were left as written and no value was read.`
16
17const RETRY_MS = 30_000
18// The engine rejects an $.fs.read over 4 MiB.
19const MAX_SAVED_BYTES = 4 * 1024 * 1024
20
21type State = {
22  mode: Mode
23  known: Map<string, string> | null
24  scrubber: Scrubber | null
25  inflight: Promise<boolean> | null
26  failedAt: number | null
27  warned: boolean
28  fileWarned: boolean
29}
30
31async function nowMs($: Engine): Promise<number> {
32  try {
33    return await $.clock.now()
34  } catch {
35    return Date.now()
36  }
37}
38
39// Reads every stored value through the wrapper. Never throws and never puts a value in a message.
40async function fetchValues($: Engine, s: State): Promise<boolean> {
41  try {
42    const r = await $.process.run([wrapperOf($), 'values'], { timeoutMs: 10_000 })
43    if (r.exitCode !== 0 || r.isStdoutTruncated) return false
44    const raw: unknown = JSON.parse(r.stdout)
45    if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return false
46    const values = new Map<string, string>()
47    for (const [name, value] of Object.entries(raw)) if (typeof value === 'string') values.set(name, value)
48    s.known = values
49    s.scrubber = makeScrubber(values)
50    return true
51  } catch {
52    return false
53  }
54}
55
56function load($: Engine, s: State): Promise<boolean> {
57  s.inflight ??= (async () => {
58    try {
59      const ok = await fetchValues($, s)
60      s.failedAt = ok ? null : await nowMs($)
61      if (!ok && s.known === null && !s.warned) {
62        s.warned = true
63        $.ui.toast('secret-box: scrubbing is off, could not load secrets')
64      }
65      return ok
66    } finally {
67      s.inflight = null
68    }
69  })()
70  return s.inflight
71}
72
73// Waits for a load in flight; after a failed load, tries again at most once per RETRY_MS.
74async function scrubberFor($: Engine, s: State): Promise<Scrubber | null> {
75  if (s.known !== null) return s.scrubber
76  if (s.inflight) await s.inflight
77  else if (s.failedAt === null || (await nowMs($)) - s.failedAt >= RETRY_MS) await load($, s)
78  return s.scrubber
79}
80
81// The engine saves a long output as a direct child of a directory named tool-results.
82function inToolResults(p: string | undefined): boolean {
83  if (typeof p !== 'string' || !p.startsWith('/')) return false
84  const parts = p.slice(1).split('/')
85  if (parts.some((x) => x === '' || x === '.' || x === '..')) return false
86  return parts.length >= 2 && parts[parts.length - 2] === 'tool-results'
87}
88
89async function warnFile($: Engine, s: State, reason: string): Promise<void> {
90  if (s.fileWarned) return
91  s.fileWarned = true
92  try {
93    await $.ui.toast(`secret-box: could not scrub the saved output file (${reason}); it may contain a secret`)
94  } catch {
95    // a toast must never surface as an error
96  }
97}
98
99// Rewrites the file a Bash result points at (the saved full output) with known values replaced.
100// Never throws and never puts a path or text in a message; a failure leaves the file as it is and
101// toasts once per session.
102async function scrubSavedFile($: Engine, s: State, scrub: Scrubber, answer: unknown): Promise<void> {
103  try {
104    const rec = (answer as { result?: unknown } | null)?.result
105    if (rec === null || typeof rec !== 'object') return
106    const paths = new Set<string>()
107    for (const k of ['persistedOutputPath', 'rawOutputPath']) {
108      const p = (rec as Record<string, unknown>)[k]
109      if (typeof p === 'string' && p !== '') paths.add(p)
110    }
111    for (const path of paths) {
112      let reason = 'unreadable'
113      try {
114        const st = await $.fs.stat(path, { resolve: true })
115        if (st.kind !== 'file' || st.isLink || !inToolResults(path) || !inToolResults(st.realPath)) {
116          reason = 'unexpected-path'
117          throw new Error(reason)
118        }
119        if (st.size > MAX_SAVED_BYTES) {
120          reason = 'too-large'
121          throw new Error(reason)
122        }
123        const text: unknown = await $.fs.read(path)
124        if (typeof text !== 'string') throw new Error('binary')
125        reason = 'scrub-failed'
126        const r = scrub(text)
127        if (r.text === text) continue
128        reason = 'write-failed'
129        await $.fs.write(path, r.text)
130      } catch {
131        await warnFile($, s, reason)
132      }
133    }
134  } catch {
135    // the file step must never throw to the engine
136  }
137}
138
139// The values a command ran with stay in its own scrub even when the reload after it dropped or changed them.
140function scrubberAfter(s: State, before: ReadonlyMap<string, string> | null, names: string[]): Scrubber | null {
141  const stale = names.flatMap((n) => {
142    const v = before?.get(n)
143    return v !== undefined && s.known?.get(n) !== v ? [[n, v] as const] : []
144  })
145  return stale.length === 0 ? s.scrubber : makeScrubber([...(s.known ?? []), ...stale])
146}
147
148function status($: Engine, s: State, reloaded: boolean | null): string {
149  const wrapper = wrapperOf($)
150  const known = s.known
151  const names = known ? [...known.keys()].sort() : []
152  const short = known ? names.filter((n) => (known.get(n) as string).length < MIN_LEN) : []
153  const lines = [`secret-box: mode ${s.mode}`]
154  if (s.mode === 'off') lines.push('secrets: not loaded (mode is off)')
155  else if (known === null) lines.push('secrets: could not be loaded (check secret-tool and that the keyring is unlocked)')
156  else lines.push(`secrets: ${names.length ? names.join(', ') : 'none stored'}${reloaded === false ? ' (reload failed, showing the last load)' : ''}`)
157  if (short.length) lines.push(`not scrubbed (shorter than ${MIN_LEN} characters): ${short.join(', ')}`)
158  lines.push(`wrapper: ${wrapper}`, `add one with: ${quote(wrapper)} set NAME   (in a terminal; use {{secret:NAME}} in commands)`)
159  return lines.join('\n')
160}
161
162export const register: Register = (on, options) => {
163  const s: State = { mode: parseMode(options.mode), known: null, scrubber: null, inflight: null, failedAt: null, warned: false, fileWarned: false }
164
165  on('session.start', async ($, e, next) => {
166    await $.command.register({
167      name: 'secret-box',
168      description: 'Show secret-box status, or reload the stored secrets.',
169      argumentHint: '[reload]',
170    })
171    if (s.mode !== 'off') await load($, s)
172    return next(e)
173  })
174
175  on('command.run', { command: 'secret-box' }, async ($, e) => {
176    const arg = e.args.trim()
177    if (arg !== '' && arg !== 'reload') return { text: 'secret-box: usage is /secret-box or /secret-box reload.' }
178    const reloaded = arg === 'reload' && s.mode !== 'off' ? await load($, s) : null
179    return { text: status($, s, reloaded) }
180  }).catch(() => ({ text: 'secret-box: the command failed. Check the debug log.' }))
181
182  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
183    if (s.mode === 'off') return next(e)
184    const names = placeholderNames(e.command)
185    if (names.length === 0) {
186      if (s.mode !== 'on') return next(e)
187      const scrub = await scrubberFor($, s)
188      const answer = await next(e)
189      if (!scrub) return answer
190      const clean = scrubToolAnswer(answer, scrub)
191      await scrubSavedFile($, s, scrub, clean)
192      return clean
193    }
194    if (s.mode === 'dry') {
195      $.ui.toast(`secret-box (dry run): would resolve ${names.join(', ')} at execution time`)
196      return next(e)
197    }
198    const wrapper = wrapperOf($)
199    try {
200      await $.fs.stat(wrapper)
201    } catch {
202      return { deny: `secret-box: the helper ${wrapper} was not found, so the command was blocked (fail closed). Reinstall the plugin.` }
203    }
204    const known = s.known
205    if ((known === null || names.some((n) => !known.has(n))) && !(await load($, s))) {
206      return { deny: LOAD_FAILED(wrapper) }
207    }
208    const before = s.known
209    const result = await next({ ...e, command: rewriteCommand(wrapper, e.command) })
210    await load($, s)
211    const scrub = scrubberAfter(s, before, names)
212    if (!scrub) return result
213    const clean = scrubToolAnswer(result, scrub)
214    await scrubSavedFile($, s, scrub, clean)
215    return clean
216  }).catch(async ($, e, next) => {
217    if (next.called) {
218      const answer = await next(e)
219      return s.mode === 'on' && s.scrubber ? scrubToolAnswer(answer, s.scrubber) : answer
220    }
221    if (s.mode !== 'off' && e.tool === 'Bash' && placeholderNames((e as { command?: string }).command ?? '').length > 0) {
222      return { deny: 'secret-box: the hook failed, so the command was blocked before it ran (fail closed). Check the debug log, or set mode to off or dry in the plugin options. The placeholders were left as written and no value was read.' }
223    }
224    return next(e)
225  })
226
227  // Every door: any row kind can carry a value (an @-mentioned file, a diff, another agent's message).
228  on('session.append', async ($, e, next) => {
229    if (s.mode === 'off') return next(e)
230    const scrub = await scrubberFor($, s)
231    if (!scrub) return next(e)
232    const r = scrubBlocks(e.message.content, scrub)
233    if (r.names.length === 0) return next(e)
234    if (s.mode === 'dry') {
235      $.ui.toast(`secret-box (dry run): would replace ${r.names.join(', ')} in a ${e.door} row`)
236      return next(e)
237    }
238    return next({ ...e, message: { ...e.message, content: r.content } })
239  }).catch((_$, e, next) => {
240    if (next.called || s.mode !== 'on' || !s.scrubber) return next(e)
241    let content: Block[]
242    try {
243      content = scrubBlocks(e.message.content, s.scrubber).content
244    } catch {
245      content = withhold(Array.isArray(e.message.content) ? e.message.content : [])
246    }
247    return next({ ...e, message: { ...e.message, content } })
248  })
249
250  on('session.send', async ($, e, next) => {
251    if (s.mode === 'off') return next(e)
252    const scrub = await scrubberFor($, s)
253    if (!scrub) return next(e)
254    const r = scrub(e.text)
255    if (r.names.length === 0) return next(e)
256    if (s.mode === 'dry') {
257      $.ui.toast(`secret-box (dry run): would replace ${r.names.join(', ')} in a sent message`)
258      return next(e)
259    }
260    return next({ ...e, text: r.text })
261  }).catch(($, e, next) =>
262    next.called || s.mode !== 'on'
263      ? next(e)
264      : { isDelivered: false as const, reason: 'secret-box: the message could not be checked for secrets, so it was not sent (fail closed). Check the debug log.' },
265  )
266}
267
hooks/scrub.ts 219 lines
1// Pure helpers: placeholder detection, shell quoting, and value scrubbing. No engine calls here.
2
3export const MIN_LEN = 6
4
5const PLACEHOLDER = /\{\{secret:([A-Za-z0-9._-]+)\}\}/g
6
7export type Block = { type: string; [field: string]: unknown }
8export type Scrubber = (text: string) => { text: string; names: string[] }
9
10export function placeholderNames(command: string): string[] {
11  return [...new Set([...command.matchAll(PLACEHOLDER)].map((m) => m[1] as string))]
12}
13
14// POSIX single quoting; leaves a plain word bare, like shlex.quote.
15export function quote(s: string): string {
16  if (s !== '' && /^[A-Za-z0-9_@%+=:,./-]+$/.test(s)) return s
17  return `'${s.replaceAll("'", "'\\''")}'`
18}
19
20// `scrub` asks the helper to filter known values out of the command's own output, for hosts whose hooks cannot.
21export function rewriteCommand(wrapper: string, command: string, scrub = false): string {
22  return `${quote(wrapper)} exec ${scrub ? '--scrub ' : ''}-- ${quote(command)}`
23}
24
25function utf8(s: string): number[] {
26  const out: number[] = []
27  for (const ch of s) {
28    const c = ch.codePointAt(0) as number
29    if (c < 0x80) out.push(c)
30    else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63))
31    else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63))
32    else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63))
33  }
34  return out
35}
36
37const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
38
39// The module has no Buffer or guaranteed btoa, so encode by hand.
40function base64Bytes(b: number[]): string {
41  let out = ''
42  for (let i = 0; i < b.length; i += 3) {
43    const n = ((b[i] as number) << 16) | ((b[i + 1] ?? 0) << 8) | (b[i + 2] ?? 0)
44    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
45    out += i + 1 < b.length ? B64[(n >> 6) & 63]! : '='
46    out += i + 2 < b.length ? B64[n & 63]! : '='
47  }
48  return out
49}
50
51export const base64 = (s: string): string => base64Bytes(utf8(s))
52
53// The value alone, plus the part of its base64 that stays the same when 1 or 2 bytes come before it
54// (as in `user:<value>`): the leading chars that mix in those bytes and the trailing partial group
55// are cut, in both the standard and the URL-safe alphabet.
56function base64Forms(base: string): string[] {
57  const bytes = utf8(base)
58  const whole = base64Bytes(bytes)
59  const out = [whole, whole.replace(/=+$/, '')]
60  for (const k of [0, 1, 2]) {
61    const raw = base64Bytes([...[0, 0].slice(0, k), ...bytes]).replace(/=+$/, '')
62    out.push(raw.slice([0, 2, 3][k] as number, Math.floor(((bytes.length + k) * 8) / 6)))
63  }
64  return [...out, ...out.map((f) => f.replaceAll('+', '-').replaceAll('/', '_'))]
65}
66
67const lowerHex = (s: string) => s.replace(/%[0-9A-F]{2}/g, (m) => m.toLowerCase())
68const strictUrl = (s: string) => s.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
69
70// Exported so the Python helper's port of this list can be checked against it.
71export function forms(value: string): string[] {
72  const out = new Set<string>()
73  for (const base of new Set([value, value.trim()])) {
74    if (base.length < MIN_LEN) continue
75    out.add(base)
76    const json = JSON.stringify(base).slice(1, -1)
77    out.add(json)
78    // ensure_ascii encoders (python json.dumps) write each non-ASCII UTF-16 unit as \uXXXX
79    if (/[^\x00-\x7f]/.test(base)) {
80      for (const hex of [(n: number) => n.toString(16), (n: number) => n.toString(16).toUpperCase()]) {
81        out.add(json.replace(/[^\x00-\x7f]/g, (c) => `\\u${hex(c.charCodeAt(0)).padStart(4, '0')}`))
82      }
83    }
84    try {
85      const url = encodeURIComponent(base)
86      for (const u of [url, strictUrl(url)]) for (const f of [u, u.replaceAll('%20', '+')]) out.add(f).add(lowerHex(f))
87    } catch {
88      // a lone surrogate has no URL form
89    }
90    for (const f of base64Forms(base)) out.add(f)
91  }
92  return [...out]
93}
94
95const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
96
97// One pass with the longest form first, so a replacement is never scanned again.
98// Takes name/value pairs; a name may appear more than once (an old and a new value).
99export function makeScrubber(values: Iterable<readonly [string, string]>): Scrubber | null {
100  const byForm = new Map<string, string>()
101  for (const [name, value] of [...values].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
102    for (const f of forms(value)) if (!byForm.has(f)) byForm.set(f, name)
103  }
104  if (byForm.size === 0) return null
105  const re = new RegExp([...byForm.keys()].sort((a, b) => b.length - a.length || (a < b ? -1 : 1)).map(escape).join('|'), 'g')
106  return (text) => {
107    const names = new Set<string>()
108    const out = text.replace(re, (hit) => {
109      const name = byForm.get(hit) as string
110      names.add(name)
111      return `{{secret:${name}}}`
112    })
113    return { text: out, names: [...names] }
114  }
115}
116
117// Rewrites text blocks and a tool_result's content; every other block stays as it was.
118export function scrubBlocks(blocks: readonly Block[], scrub: Scrubber): { content: Block[]; names: string[] } {
119  const names = new Set<string>()
120  const text = (s: string) => {
121    const r = scrub(s)
122    r.names.forEach((n) => names.add(n))
123    return r.text
124  }
125  const walk = (b: Block): Block => {
126    if (b.type === 'text' && typeof b.text === 'string') {
127      const t = text(b.text)
128      return t === b.text ? b : { ...b, text: t }
129    }
130    if (b.type === 'tool_result') {
131      if (typeof b.content === 'string') {
132        const t = text(b.content)
133        return t === b.content ? b : { ...b, content: t }
134      }
135      if (Array.isArray(b.content)) {
136        const inner = (b.content as Block[]).map(walk)
137        return inner.some((x, i) => x !== (b.content as Block[])[i]) ? { ...b, content: inner } : b
138      }
139    }
140    return b
141  }
142  return { content: blocks.map(walk), names: [...names] }
143}
144
145const WITHHELD = '[secret-box: this content was withheld because scrubbing failed]'
146
147// Last resort when scrubbing itself failed: keep the row's shape, drop what it said.
148export function withhold(blocks: readonly Block[]): Block[] {
149  return blocks.map((b) => {
150    if (b.type === 'text') return { ...b, text: WITHHELD }
151    if (b.type === 'tool_result') return { ...b, content: WITHHELD }
152    return b
153  })
154}
155
156const PATH_FIELDS = new Set(['persistedOutputPath', 'rawOutputPath'])
157// Kept as they are even when scrubbing failed: a backgrounded command needs its id, a block its type.
158const KEEP_ON_FAILURE = new Set([...PATH_FIELDS, 'backgroundTaskId', 'type'])
159const MAX_DEPTH = 12
160
161type KeyFn = (k: string, depth: number) => string
162
163// Walks strings, arrays and objects; a subtree deeper than MAX_DEPTH or one that loops back on itself
164// is replaced by the notice, never returned as it was. Keys in `keep` are not touched. Other keys go
165// through `fk`; one that then collides with a key already there gets a `#2`, `#3`... suffix, so no
166// entry is lost. Key order stays.
167function scrubDeep(v: unknown, f: (s: string) => string, fk: KeyFn, keep: ReadonlySet<string>, mark: () => void, depth = 0, path = new Set<object>()): unknown {
168  if (typeof v === 'string') return f(v)
169  if (v === null || typeof v !== 'object') return v
170  if (depth > MAX_DEPTH || path.has(v)) {
171    mark()
172    return WITHHELD
173  }
174  path.add(v)
175  try {
176    if (Array.isArray(v)) return v.map((x) => scrubDeep(x, f, fk, keep, mark, depth + 1, path))
177    const entries = Object.entries(v)
178    const used = new Set(entries.map(([k]) => k).filter((k) => keep.has(k)))
179    return Object.fromEntries(
180      entries.map(([k, x]) => {
181        if (keep.has(k)) return [k, x]
182        const base = fk(k, depth)
183        let key = base
184        for (let n = 2; used.has(key); n++) key = `${base}#${n}`
185        used.add(key)
186        return [key, scrubDeep(x, f, fk, keep, mark, depth + 1, path)]
187      }),
188    )
189  } finally {
190    path.delete(v)
191  }
192}
193
194// A Bash tool.call answer carries the output in `result` (the structured record the transcript keeps,
195// or the error text as a string) and in `text`, with more text in hints, blocks and `context`.
196// Every string under the answer is scrubbed, and so are the keys of the data the tool produced (below
197// the top two levels); the engine's own keys of the answer and its record (the top two levels) are
198// never renamed, or the record would stop validating. The path fields (`persistedOutputPath`,
199// `rawOutputPath`) are never altered; the file they name is the caller's to scrub. An answer
200// with nothing to replace comes back as the same object. If scrubbing throws, the strings are replaced
201// by a notice rather than returned raw; the background task id, `type` keys and paths stay, and so do
202// the engine's own keys of the answer and its record (the top two levels); deeper keys are withheld.
203export function scrubToolAnswer<T>(answer: T, scrub: Scrubber): T {
204  const a = answer as { deny?: unknown } | null
205  if (a === null || typeof a !== 'object' || a.deny !== undefined) return answer
206  try {
207    let changed = false
208    const f = (s: string) => {
209      const r = scrub(s)
210      if (r.names.length > 0) changed = true
211      return r.text
212    }
213    const out = scrubDeep(a, f, (k, depth) => (depth <= 1 ? k : f(k)), PATH_FIELDS, () => (changed = true))
214    return (changed ? out : answer) as T
215  } catch {
216    return scrubDeep(a, () => WITHHELD, (k, depth) => (depth <= 1 ? k : WITHHELD), KEEP_ON_FAILURE, () => {}) as T
217  }
218}
219