SLOPSHOPPER

secret-guard

Holds back a prompt that looks like it carries a secret (API keys, tokens, access-key pairs, private keys, passwords) before it reaches the transcript, puts it…

newbandcommandtoastprompt
v0.1.0no licenseupdated 2026-10-08joeldg/claude-mods/secret-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /secrets ⎿ secret-guard: secret-guard is on: prompts that look like they hold a secret are held back. ⎿ secret-guard: Usage: /secrets test <text> shows what secret-guard would catch in the text (masked) ⎿ secret-guard: /secrets off | on turns the check off or back on for this session ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.

Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, memory_pressure, ioreg).

git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods

dev-servers

A pane of your project's running dev servers, so you don't have to ask Claude to restart them.

  • /servers opens the Servers pane:
  • running servers whose working folder is in this repo: name, port, pid and uptime, with Restart, Stop and Log
  • known start commands that aren't running, with Start: package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile
  • a count of other listeners on the Mac
  • Only button presses start or stop anything:
  • Start runs the command detached, logging to ~/.claude/dev-servers/<project>/.
  • Stop sends SIGTERM. If the server ignores it, pressing again within 10s force-stops it.
  • Restart stops the server, waits for the port to free up, then starts it.
  • Before any signal, it checks the pid still runs the same command.
  • When a command fails with "address already in use", Claude gets a note (and you a toast) naming the holder, e.g. Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).
  • Status line: servers: :4000 :5173.
  • Makes no model calls: lsof and ps every 15s.

downloads-drop

Puts files you just downloaded into your prompt with one click.

  • Watches ~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].
  • Attach puts @"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files.
  • Waits until a file has finished downloading (skips partial downloads and files still growing), and ignores hidden and zero-byte files.
  • /downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.
  • Stacks with other mods' bands (repo-brief, standing-orders, secret-guard) instead of hiding them.
  • Makes no model calls.

Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).

effort-router

Sets effort per message, so you don't have to switch it by hand.

  • Git chores ("merged", "#219 merged", "commit and push", "push it", "open a PR", "close the issue") run at low effort and come back faster.
  • Deep asks (audit, review, plan, design, investigate, root cause, "why does…", "figure out") run at max.
  • Everything else, including approvals like "yes", "go ahead" and "continue" and anything that starts new work ("merged 219, go ahead with #214"), keeps your session's own effort.
  • Prompt cache: changing effort makes the whole conversation get cached again. So it never switches mid-turn, raises effort at once, and over a large, warm cache lowers it only after 2 routine turns in a row. In small contexts, or once the cache has lapsed, it switches right away.
  • Model guard: set avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included.
  • /route shows the last decision and the session's counts. /route off and /route on toggle it; /route deep and /route routine force the next turn.
  • Status line while a turn is routed: effort: low (routine).
  • Makes no model calls.

Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).

job-watch

A Jobs pane for long-running work: training runs, downloads, extractions.

  • Picks up background Bash tasks and detached nohup … > log & launches by itself.
  • /watch <log> [label] adds any other log file.
  • Shows progress, ETA and the last log line, and flags a job as stalled when its log goes quiet.
  • Shows free space on / and /Volumes/* (the NAS).
  • Toasts when a job finishes or stalls. The status line shows jobs: 2 running · 1 stalled.
  • /jobs opens the pane, /unwatch <label|done|all> removes jobs.
  • Makes no model calls: it reads logs with tail, checks processes with ps, and runs df.

Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min), auto-open (on), which disks to show.

machine-guard

Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.

  • Status line: RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%.
  • It refuses heavy jobs (training, inference, rendering, extraction, Blender, Docker, ffmpeg) when:
  • macOS reports critical memory pressure, or
  • the Mac is reserved with /busy.

When memory is only tight, the job runs and Claude gets a note to start one heavy job at a time.

  • /busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``
  • /guard shows what it sees. /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.
  • Remote runs (modal run, ssh), tests (pytest) and installs are never treated as heavy.
  • Add your own heavy commands with the "Also heavy" setting (a regex), e.g. overnight_|nightly_run\.sh.

mod-monitor

Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.

  • Failures: any mod hook that throws, times out or rejects, read from the hook chain's results (next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast.
  • What each mod did: its toasts ("#219 merged → …", "Blocked: …"), status-line changes, the mod commands you used (never their arguments), and failed subprocesses (a burst of 5 in 10 minutes raises a toast). Git checks run outside a repository are logged as expected, not as failures. It also records model calls (the only usage the mods cost: /second-opinion, /recall ask) and file writes (folders only, never contents).
  • /mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it.
  • /mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read.
  • /mods failures [7d]: failures and failed subprocesses only.
  • Logs: ~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.
  • Makes no model calls and adds no measurable latency.
  • Error lines mods log themselves ($.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged.
  • Transcript-row hooks (secret-guard masks /secrets records there) are watched for failures and slow runs, but not counted per run.

Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).

modal-meter

Keeps an eye on Modal so idle GPU containers don't burn credits.

  • Status line while containers run: Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.
  • A toast when an app has had containers up longer than alertMinutes (30), repeated at most every 30 minutes.
  • /modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.
  • Shows today's spend and alerts on a budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.
  • Finds the CLI as modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, and stays silent when Modal isn't set up.
  • Makes no model calls: only the Modal CLI, every 60s.

pr-autopilot

Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.

  • Watches your open PRs in the session's repo: it adopts them at session start, and picks up every gh pr create Claude runs. It polls gh pr view every 60s.
  • Status line: PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.
  • When a PR merges, it cleans up with plain local git, then toasts the outcome and suggests carrying on (Tab to accept):
  • git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.
  • Never with uncommitted changes, never --force, never other branches.
  • Deletes the local branch only if it points at exactly the commit GitHub merged, so nothing local is lost. It also leaves a branch checked out in another worktree alone.
  • A merge seen mid-turn is cleaned up when the turn ends, so git never races Claude.
  • When you mention failing CI ("#258 is failing", "CI failed, fix it"), your message goes to Claude with gh pr checks and the tail of the failed log attached, so you don't paste it.
  • /prs lists watched PRs. /prs watch <n|url> and /prs forget <n|all> add and remove them.
  • Makes no model calls: only gh and git, at about one GitHub API call per open PR per minute.

Settings:

  • pollSeconds (60)
  • attachCiLogs (on)
  • logLines (120)
  • deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.

It never closes issues; put "Closes #N" in PR bodies for that.

recall

Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.

  • What it searches: Claude Code sessions, Codex sessions, subagent and workflow runs, Claude's memory files, standing orders, second-opinion reviews, and your /remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.
  • What it keeps: prompts, answers, compaction summaries, session titles, commands, files touched, commits, PRs, issues, URLs, tasks and decisions (what you approved or ruled out). Read-only look-ups like grep and cat are kept but ranked low.
  • History survives cleanup: extracts stay searchable after Claude Code deletes old transcripts.
  • Claude searches it itself with four read-only tools, search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled.
  • Commands:
  • /recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>.
  • /recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it.
  • /recall timeline [7d|30d|90d] [all]
  • /recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]
  • /recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model.
  • /recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help.
  • /remember <fact>, /remember list, /remember forget <ref>.
  • Bands:
  • Once per session: Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].
  • When a prompt mentions #214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.
  • Query syntax: words must all match. OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.
  • Privacy:
  • The index lives at ~/.claude/recall/index.db, readable only by you, and never goes in a repo.
  • Secrets are masked before anything is stored: known token shapes, labelled values ("password: …"), the values of secret-named exports in ~/.zshrc, ~/.zprofile, ~/.bashrc and ~/.bash_profile, and any literal strings you list in ~/.claude/recall/redact.txt (one per line). Editing that list re-masks the existing index on the next update.
  • Cost: no model calls except /recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally (about 1s) at session start and every 10 minutes.
  • Requires macOS's /usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.

Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), relatedBand (on), lastSessionBand (on), maxResults (8), askModel (claude-haiku-4-5-20251001).

repo-brief

Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".

  • Gathers in the background at session start:
  • branch, ahead/behind and uncommitted files
  • the last 8 commits
  • open PRs with CI ✓/✗/…
  • issues labelled owner, todo, P0 or blocked
  • stale branches (merged, or upstream gone)
  • A one-line band above the prompt, e.g. main ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it.
  • Claude gets the same summary once, in its first message, so the prompt cache stays warm. It refreshes after compaction.
  • /brief re-gathers now and prints the full summary.
  • Makes no model calls: only git and gh. The band refreshes after a turn at most every 2 minutes.

Settings: focus labels, refresh minutes, and whether to brief Claude.

routine-watch

Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.

  • Knows a session is a routine from its scheduled-task prompt, and does nothing in your other sessions.
  • When a routine stops to wait for your OK on a permission prompt or an AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.
  • When a turn ends in an error, or the routine finishes, you get a notification: Routine daily-report finished after 23m · waited on you 2 times.
  • Phone push (optional): notifyCommand runs a command on the same events, e.g. curl -s -d {message} ntfy.sh/your-topic. {title} and {message} are filled in as single arguments, never through a shell.
  • allowWebReads (off by default): lets routines use WebFetch and WebSearch without asking. It only replaces a prompt; your deny rules still apply, and nothing else is ever auto-allowed.
  • /routine shows the routine's name, how long it has run, its waits, and the settings.
  • Makes no model calls.

second-opinion

A Fable review in the background, without switching your session's model. Each run is one Fable call against your usage.

  • /second-opinion: reviews recent work. On a feature branch that's the branch against the default branch; otherwise the last 12 commits, plus the diff and git status, capped at 60k characters.
  • Other forms:
  • /second-opinion commits 5
  • /second-opinion diff (uncommitted changes)
  • /second-opinion file docs/ADR-007.md
  • /second-opinion <question>: adds a question for Fable to answer first.
  • The command returns at once, and the status line shows second opinion: reviewing…. When the review is ready you get a toast, and a pane opens with it, ranked: wrong assumptions, bugs and risks, what's missing, what to do next.
  • Send to Claude attaches the review to your next prompt (once) and drafts "What do you agree with, and what would you act on?".
  • Reviews are saved in ~/.claude/second-opinions/<project>/. /second-opinion list lists them, and /second-opinion show [n] reopens one.

Settings: model (claude-fable-5-1), effort (high), maxContextChars (60000).

standing-orders

Keeps your "always / never / don't / from now on" instructions alive across compaction.

  • When you write an instruction like "never open bambu with full spectrum files", a band asks: Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.
  • Project orders live in ~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session.
  • Claude gets them at the start of every conversation and again after each compaction or /clear, so the prompt cache isn't disturbed. A newly saved order also rides along once with your next message.
  • /orders lists them. /orders add [project|session] <text>, /orders forget <n>, /orders clear session|project, and /orders export (a Markdown block for CLAUDE.md).
  • Makes no model calls.

secret-guard

Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.

  • Catches known token shapes: AWS, GitHub, Anthropic, OpenAI, Slack, Google, Hugging Face, GitLab, npm, Stripe, private keys and bearer tokens.
  • Also catches labelled values ("password: …", "api key = …", "the wifi password is …") and the two-line "Access Key ID / Secret Access Key" paste.
  • Leaves alone $NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.
  • On a hit, the prompt isn't sent and goes back in the box. A band shows the secret masked (…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:
  • Save as env var appends export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME.
  • Send anyway lets exactly that text through once.
  • Edit dismisses the band.
  • The value is never shown in toasts, status, state or the transcript, and /secrets test <text> output is masked too.
  • /secrets test <text> shows what would be caught. /secrets off and /secrets on toggle it for the session.
  • Makes no model calls.

Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).

slicer-handoff

Makes Claude's open commands hand 3D files to the right slicer.

  • Full-spectrum files go to Snapmaker Orca. Bambu Studio and OrcaSlicer can't open them. A file counts as full-spectrum when:
  • its name or folder matches full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], or
  • its 3MF names a Full Spectrum filament profile.

An open -a BambuStudio … for one becomes open -b com.snapmaker.snapmaker-orca …, with the rest of the command untouched. You get a toast, and Claude gets a note so it doesn't try again.

  • Earlier windows close first. Before opening a file, it asks the running slicer to quit (a normal quit, never forced), so windows don't pile up. If one won't close, for example because it's waiting on a save prompt, it stops trying and tells Claude to leave it alone.
  • /slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.
  • Recognizes open -a <app>, open -a /Applications/X.app and open -b <bundle id>, including variables set earlier in the command (S=… && open -a BambuStudio "$S/x.3mf") and files copied in the same command.
  • Makes no model calls.

Settings:

  • closePrevious (on): turn it off if you keep your own slicer window open, since the quit request reaches your windows too.
  • fullSpectrumPattern (the regex above)
  • checkContents (on)

The quit request goes out when Claude issues the command, before any permission prompt for it.

Loading

  • One session from a terminal: pass --plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot
  • Every session, including the desktop app: add to ~/.claude/settings.json. Put mod-monitor first so it sees the others; CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop sessions pick up edits and show mod failures: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1", "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-mods/mod-monitor:~/Projects/claude-mods/job-watch:~/Projects/claude-mods/machine-guard:~/Projects/claude-mods/repo-brief:~/Projects/claude-mods/slicer-handoff:~/Projects/claude-mods/pr-autopilot:~/Projects/claude-mods/routine-watch:~/Projects/claude-mods/modal-meter:~/Projects/claude-mods/second-opinion:~/Projects/claude-mods/downloads-drop:~/Projects/claude-mods/dev-servers:~/Projects/claude-mods/standing-orders:~/Projects/claude-mods/effort-router:~/Projects/claude-mods/secret-guard:~/Projects/claude-mods/recall" } } ``

Checking

Run with Claude Code 2.1.289 or newer; older CLIs ignore per-test settings, so a few tests fall back to defaults.

claude plugin validate job-watch && claude plugin test job-watch
claude plugin validate machine-guard && claude plugin test machine-guard
claude plugin validate repo-brief && claude plugin test repo-brief
claude plugin validate slicer-handoff && claude plugin test slicer-handoff
claude plugin validate pr-autopilot && claude plugin test pr-autopilot
claude plugin validate routine-watch && claude plugin test routine-watch
claude plugin validate modal-meter && claude plugin test modal-meter
claude plugin validate second-opinion && claude plugin test second-opinion
claude plugin validate downloads-drop && claude plugin test downloads-drop
claude plugin validate dev-servers && claude plugin test dev-servers
claude plugin validate standing-orders && claude plugin test standing-orders
claude plugin validate effort-router && claude plugin test effort-router
claude plugin validate secret-guard && claude plugin test secret-guard
claude plugin validate recall && claude plugin test recall
claude plugin validate mod-monitor && claude plugin test mod-monitor
(cd recall/engine && /usr/bin/python3 -m unittest)
Source 4 files
hooks/register.tsx 348 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BandItem, BandView, Finding } from '../types'
5import { describeFindings, detect, hashText, isBlankTree, mask, testReport, toEnvName, typedName } from './detect'
6import { planExports, replaceWithRefs, resolveRcPath, saveToast } from './zshrc'
7
8type Engine = EngineInterface
9
10const band = atom({ plugin: 'secret-guard', key: 'band' } as const, null)
11const isOff = atom({ plugin: 'secret-guard', key: 'isOff' } as const, false)
12
13/**
14 * Prompts the person sent: Enter at the terminal, Remote Control, and a host app's own box (the desktop
15 * app's Code tab submits through the SDK). Plugins', schedules', peers' and notifications' are left alone.
16 */
17const PERSONAL = new Set(['composer', 'bridge', 'sdk'])
18/** How long Send anyway lets the same text through. */
19const ALLOW_MS = 5 * 60_000
20/** How many name fields the band draws; secrets past them are saved under their suggested names. */
21const SHOWN = 3
22/** A `/secrets` command's record in the transcript, which `/secrets test` would otherwise leave a secret in. */
23const SECRETS_COMMAND = /(?:^|[\s>])\/secrets(?![\w-])/
24
25const USAGE = [
26  'Usage: /secrets test <text>  shows what secret-guard would catch in the text (masked)',
27  '       /secrets off | on     turns the check off or back on for this session',
28].join('\n')
29
30type Config = { enabled: boolean; extra: RegExp | null; extraError: string | null; zshrcPath: string }
31
32let config: Config = { enabled: true, extra: null, extraError: null, zshrcPath: '~/.zshrc' }
33
34/** The prompt held back last, secrets and all: kept in this module's memory only, never in state or on screen. */
35type Held = { id: number; text: string; findings: Finding[] }
36
37let held: Held | null = null
38/** The fingerprint of the one text Send anyway lets through, and until when. */
39let allowance: { hash: string; until: number } | null = null
40let isSaving = false
41let lastId = 0
42
43/** The band's rows for a held prompt: labels, masked tails and suggested names; never a value. */
44function itemsOf(findings: readonly Finding[]): BandItem[] {
45  return findings.map(finding => ({ label: finding.label, masked: mask(finding.value), name: finding.name, suggested: finding.name }))
46}
47
48/** `Looks like a secret: Secret Access Key …vxrm (masked)`. */
49function headline(items: readonly BandItem[]): string {
50  const listed = items.map(item => `${item.label} ${item.masked}`).join(', ')
51  return `Looks like ${items.length === 1 ? 'a secret' : `${items.length} secrets`}: ${listed} (masked)`
52}
53
54/** The field for the secret at `index`: `name`, then `name-2`, `name-3`. */
55function fieldKey(index: number): string {
56  return index === 0 ? 'name' : `name-${index + 1}`
57}
58
59/** What the person sees where the prompt would have entered: why, and what to do; never a value. */
60function dropReason(findings: readonly Finding[], isRefilled: boolean): string {
61  const what = describeFindings(findings.map(finding => finding.label))
62  const where = isRefilled ? 'It is back in the box: above' : 'Above'
63  return `secret-guard held this prompt back, unsent: it looks like it holds ${what}. ${where} the prompt, choose Save as env var, Send anyway or Edit.`
64}
65
66/** Holds a prompt back: keeps it here, puts its text back in the box, and raises the band and a toast. */
67async function hold($: Engine, text: string, findings: Finding[]): Promise<boolean> {
68  lastId += 1
69  const id = lastId
70  held = { id, text, findings }
71  const filled = await $.prompt.fill({ text, mode: 'replace' }).catch(() => null)
72  const isRefilled = filled?.isFilled === true
73  const view: BandView = { id, items: itemsOf(findings), isRefilled }
74  await update($, band, () => view)
75  $.ui.toast(`secret-guard held your prompt back: it looks like it holds ${describeFindings(findings.map(f => f.label))}.`, {
76    timeoutMs: 8_000,
77  })
78  return isRefilled
79}
80
81/** Takes the band down and forgets the held prompt. */
82async function dismiss($: Engine) {
83  held = null
84  await update($, band, () => null)
85}
86
87/** The name field changed: kept as typed, uppercased, other characters as `_`. */
88async function rename($: Engine, index: number, value: string) {
89  await update($, band, view =>
90    view ? { ...view, items: view.items.map((item, i) => (i === index ? { ...item, name: typedName(value) } : item)) } : null,
91  )
92}
93
94/**
95 * Save as env var: appends `export NAME='value'` for each secret to the shell file (an existing NAME with
96 * the same value is reused, with another value the name gets `_2`), puts the prompt back with `$NAME` in
97 * each secret's place, and says so in a toast that names the variables, never their values.
98 */
99async function save($: Engine) {
100  const current = held
101  const view = await read($, band)
102  if (current === null || view === null || view.id !== current.id || isSaving) {
103    return
104  }
105  isSaving = true
106  try {
107    const where = config.zshrcPath.trim() || '~/.zshrc'
108    const path = resolveRcPath(where, await $.env.get('HOME'))
109    if (path === null) {
110      $.ui.toast(`secret-guard: HOME is not set, so ${where} could not be found. Nothing was saved.`)
111      return
112    }
113    const exists = await $.fs.exists(path).catch(() => false)
114    const before = exists ? await $.fs.read(path).catch(() => null) : ''
115    if (typeof before !== 'string') {
116      $.ui.toast(`secret-guard could not read ${where}, so nothing was saved; the prompt is still in the box.`)
117      return
118    }
119    const wanted = current.findings.map((finding, index) => toEnvName(view.items[index]?.name ?? '') || finding.name)
120    const unique = wanted.map((name, index) => (wanted.indexOf(name) === index ? name : `${name}_${index + 1}`))
121    const { text, plans } = planExports(
122      before,
123      current.findings.map((finding, index) => ({ name: unique[index] ?? finding.name, value: finding.value })),
124    )
125    if (text !== before) {
126      const isWritten = await $.fs.write(path, text).then(
127        () => true,
128        () => false,
129      )
130      if (!isWritten) {
131        $.ui.toast(`secret-guard could not write ${where}, so nothing was saved; the prompt is still in the box.`)
132        return
133      }
134    }
135    const names = plans.map(plan => plan.name)
136    await $.prompt.fill({ text: replaceWithRefs(current.text, current.findings, names), mode: 'replace' }).catch(() => null)
137    await dismiss($)
138    $.ui.toast(saveToast(plans, where), { timeoutMs: 15_000 })
139  } finally {
140    isSaving = false
141  }
142}
143
144/** The name field's Enter: keep the name typed, then save. */
145async function renameAndSave($: Engine, index: number, value: string) {
146  await rename($, index, value)
147  await save($)
148}
149
150/** Send anyway: the next submission of exactly this text, within five minutes, goes through once. */
151async function sendAnyway($: Engine) {
152  const current = held
153  if (current === null) {
154    return
155  }
156  allowance = { hash: hashText(current.text), until: (await $.clock.now()) + ALLOW_MS }
157  await $.prompt.fill({ text: current.text, mode: 'replace' }).catch(() => null)
158  await dismiss($)
159  $.ui.toast('Press Enter to send it as is: secret-guard lets this exact text through once, within 5 minutes.')
160}
161
162/** Whether this text is the one Send anyway allowed, still in time; the allowance is used up either way it matches. */
163async function isAllowed($: Engine, text: string): Promise<boolean> {
164  if (allowance === null) {
165    return false
166  }
167  if ((await $.clock.now()) > allowance.until) {
168    allowance = null
169    return false
170  }
171  if (allowance.hash !== hashText(text)) {
172    return false
173  }
174  allowance = null
175  return true
176}
177
178/** The text with each secret detected in it shown masked: `[masked …vxrm]`. */
179function masked(text: string): string {
180  const findings = detect(text, { extra: config.extra })
181  let out = text
182  for (const finding of [...findings].sort((a, b) => b.start - a.start)) {
183    out = `${out.slice(0, finding.start)}[masked ${mask(finding.value)}]${out.slice(finding.end)}`
184  }
185  return out
186}
187
188/** What `/secrets` alone answers. */
189async function status($: Engine): Promise<string> {
190  const off = await read($, isOff)
191  const state = !config.enabled
192    ? 'secret-guard is turned off in /config (Check prompts for secrets).'
193    : off
194      ? 'secret-guard is off for this session: prompts are sent unchecked.'
195      : 'secret-guard is on: prompts that look like they hold a secret are held back.'
196  const extra = config.extraError ? `\nThe "Also a secret" pattern is not a valid regex and is ignored: ${config.extraError}` : ''
197  return `${state}${extra}\n${USAGE}`
198}
199
200/** What a /secrets record shows when it could not be masked. */
201const WITHHELD = '/secrets … (withheld: secret-guard could not mask this record)'
202
203export const register: Register = (on, options) => {
204  let extra: RegExp | null = null
205  let extraError: string | null = null
206  const source = String(options.extraPatterns ?? '').trim()
207  if (source !== '') {
208    try {
209      extra = new RegExp(source, 'g')
210    } catch (error) {
211      extraError = error instanceof Error ? error.message : String(error)
212    }
213  }
214  config = {
215    enabled: options.enabled !== false,
216    extra,
217    extraError,
218    zshrcPath: String(options.zshrcPath ?? '~/.zshrc'),
219  }
220  held = null
221  allowance = null
222  isSaving = false
223
224  on('session.start', async ($, e, next) => {
225    await $.command.register({
226      name: 'secrets',
227      description: 'Show what secret-guard would catch in a text, or turn it off or on for this session',
228      argumentHint: 'test <text> | off | on',
229      immediate: true,
230    })
231    return next(e)
232  })
233
234  on('prompt.submit', async ($, e, next) => {
235    if (!config.enabled || !PERSONAL.has(e.origin.kind) || (await read($, isOff))) {
236      return next(e)
237    }
238    if (await isAllowed($, e.text)) {
239      await dismiss($)
240      return next(e)
241    }
242    const findings = detect(e.text, { extra: config.extra })
243    if (findings.length === 0) {
244      if (held !== null) {
245        await dismiss($)
246      }
247      return next(e)
248    }
249    const isRefilled = await hold($, e.text, findings)
250    return { drop: dropReason(findings, isRefilled) }
251  })
252
253  // `/secrets test <text>` would leave the text in the command's record: the record keeps it masked.
254  on('session.append', { door: 'command' }, async ($, e, next) => {
255    let isChanged = false
256    const isSecretsText = (block: (typeof e.message.content)[number]) =>
257      block.type === 'text' && typeof block.text === 'string' && SECRETS_COMMAND.test(block.text)
258    let content: typeof e.message.content
259    try {
260      content = e.message.content.map(block => {
261        if (block.type !== 'text' || typeof block.text !== 'string' || !SECRETS_COMMAND.test(block.text)) {
262          return block
263        }
264        const text = masked(block.text)
265        isChanged ||= text !== block.text
266        return { ...block, text }
267      })
268    } catch (error) {
269      // Fail closed: a record that could not be masked keeps no text, rather than the secret it may hold.
270      const message = error instanceof Error ? error.message : String(error)
271      $.ui.log(`secret-guard: masking a /secrets record failed, so its text was withheld: ${message.slice(0, 200)}`, { to: 'debug' })
272      content = e.message.content.map(block => (isSecretsText(block) && block.type === 'text' ? { ...block, text: WITHHELD } : block))
273      isChanged = true
274    }
275    return next(isChanged ? { ...e, message: { ...e.message, content } } : e)
276  })
277
278  on('command.run', { command: 'secrets' }, async ($, e) => {
279    const args = e.args.trim()
280    const verb = (/^\S*/.exec(args)?.[0] ?? '').toLowerCase()
281    if (verb === 'test') {
282      const text = args.slice(verb.length).trim()
283      return { text: text === '' ? USAGE : testReport(detect(text, { extra: config.extra })) }
284    }
285    if (verb === 'off') {
286      await update($, isOff, () => true)
287      await dismiss($)
288      return { text: 'secret-guard is off for this session: prompts are sent unchecked. /secrets on turns it back on.' }
289    }
290    if (verb === 'on') {
291      await update($, isOff, () => false)
292      return {
293        text: config.enabled
294          ? 'secret-guard is on: prompts that look like they hold a secret are held back.'
295          : 'secret-guard is turned off in /config (Check prompts for secrets); turn it on there.',
296      }
297    }
298    return { text: await status($) }
299  })
300
301  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
302    const view = await read($, band)
303    if (e.props.hasSurvey || view === null || held?.id !== view.id || (e.surface !== 'terminal' && e.surface !== 'desktop')) {
304      return next(e)
305    }
306    const { Box, Text, Button, Input } = $.ui.resolve(e)
307    const fields = view.items.slice(0, SHOWN)
308    const rest = view.items.length - fields.length
309
310    const guard = (
311      <Box flexDirection="column">
312        <Box flexDirection="row" gap={1}>
313          <Box flexShrink={1}>
314            <Text wrap="truncate-end">{headline(view.items)}</Text>
315          </Box>
316          <Box flexDirection="row" gap={1} flexShrink={0}>
317            <Button key="save" label="Save as env var" variant="primary" onPress={() => save($)} />
318            <Button key="send-anyway" label="Send anyway" onPress={() => sendAnyway($)} />
319            <Button key="edit" label="Edit" onPress={() => dismiss($)} />
320          </Box>
321        </Box>
322        {fields.map((item, index) => (
323          <Input
324            key={fieldKey(index)}
325            label={view.items.length === 1 ? 'Env var name: $' : `${item.label} as $`}
326            value={item.name}
327            placeholder={item.suggested}
328            submitLabel="save"
329            onInput={value => rename($, index, value)}
330            onSubmit={value => renameAndSave($, index, value)}
331          />
332        ))}
333        {rest > 0 ? <Text dimColor>{`+${rest} more, saved under their suggested names`}</Text> : null}
334      </Box>
335    )
336    // Another plugin's band beneath stays, under this one.
337    const below = await next(e)
338    return isBlankTree(below) ? (
339      guard
340    ) : (
341      <Box flexDirection="column">
342        {guard}
343        {below}
344      </Box>
345    )
346  })
347}
348
hooks/detect.ts 613 lines
1import type { RenderElement } from 'claude-code'
2
3import type { Finding, Separator } from '../types'
4
5/** A known secret shape: what it looks like, what the band calls it, and the env var it suggests. */
6type Shape = {
7  kind: string
8  label: string
9  name: string
10  pattern: RegExp
11  /** The capture group holding the secret, when the match carries more than the secret. */
12  group?: number
13  /** Whether the secret must also pass the value checks a labelled one does (mixed characters, no placeholder). */
14  isLoose?: boolean
15}
16
17/** Shapes the vendors document. Each pattern is global; its boundaries keep it off longer words. */
18const SHAPES: readonly Shape[] = [
19  {
20    kind: 'private-key',
21    label: 'Private key',
22    name: 'PRIVATE_KEY',
23    pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|$)/g,
24  },
25  {
26    kind: 'aws-access-key-id',
27    label: 'AWS access key ID',
28    name: 'AWS_ACCESS_KEY_ID',
29    pattern: /(?<![A-Za-z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Za-z0-9])/g,
30  },
31  {
32    kind: 'github-token',
33    label: 'GitHub token',
34    name: 'GITHUB_TOKEN',
35    pattern: /(?<![A-Za-z0-9_])gh[pousr]_[A-Za-z0-9]{36}(?![A-Za-z0-9_])/g,
36  },
37  {
38    kind: 'github-token',
39    label: 'GitHub token',
40    name: 'GITHUB_TOKEN',
41    pattern: /(?<![A-Za-z0-9_])github_pat_[A-Za-z0-9_]{22,}/g,
42  },
43  {
44    kind: 'anthropic-key',
45    label: 'Anthropic API key',
46    name: 'ANTHROPIC_API_KEY',
47    pattern: /(?<![A-Za-z0-9_-])sk-ant-[A-Za-z0-9_-]{20,}/g,
48  },
49  {
50    kind: 'openai-key',
51    label: 'OpenAI API key',
52    name: 'OPENAI_API_KEY',
53    pattern: /(?<![A-Za-z0-9_-])sk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{32,}/g,
54  },
55  {
56    kind: 'slack-token',
57    label: 'Slack token',
58    name: 'SLACK_TOKEN',
59    pattern: /(?<![A-Za-z0-9_-])xox[abprs]-[A-Za-z0-9-]{10,}/g,
60  },
61  {
62    kind: 'google-api-key',
63    label: 'Google API key',
64    name: 'GOOGLE_API_KEY',
65    pattern: /(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g,
66  },
67  {
68    kind: 'huggingface-token',
69    label: 'Hugging Face token',
70    name: 'HF_TOKEN',
71    pattern: /(?<![A-Za-z0-9_])hf_[A-Za-z0-9]{30,}(?![A-Za-z0-9_])/g,
72  },
73  {
74    kind: 'gitlab-token',
75    label: 'GitLab token',
76    name: 'GITLAB_TOKEN',
77    pattern: /(?<![A-Za-z0-9_-])glpat-[A-Za-z0-9_-]{20,}/g,
78  },
79  {
80    kind: 'npm-token',
81    label: 'npm token',
82    name: 'NPM_TOKEN',
83    pattern: /(?<![A-Za-z0-9_])npm_[A-Za-z0-9]{36}(?![A-Za-z0-9_])/g,
84  },
85  {
86    kind: 'stripe-key',
87    label: 'Stripe live key',
88    name: 'STRIPE_SECRET_KEY',
89    pattern: /(?<![A-Za-z0-9_])(?:sk|rk)_live_[A-Za-z0-9]{20,}/g,
90  },
91  {
92    kind: 'bearer-token',
93    label: 'Bearer token',
94    name: 'BEARER_TOKEN',
95    pattern: /(?<![A-Za-z0-9])Bearer[ \t]+([A-Za-z0-9._~+/-]{20,}=*)/g,
96    group: 1,
97    isLoose: true,
98  },
99]
100
101/** A password inside a URL (`scheme://user:password@host`); a URL without one is never a secret. */
102const URL_PASSWORD = /(?<![A-Za-z0-9+.-])([a-z][a-z0-9+.-]*):\/\/[^\s:/@]+:([^\s/@]+)@([^\s/:?#]+)/gi
103
104/** The 40-character secret that sits beside an AWS access key ID. */
105const AWS_SECRET = /(?<![A-Za-z0-9/+=])[A-Za-z0-9/+]{40}(?![A-Za-z0-9/+=])/g
106
107/** How far from an AWS access key ID its secret is looked for, and how far back a "for <service>" is. */
108const AWS_SECRET_REACH = 300
109const CONTEXT_REACH = 300
110
111/**
112 * The labels a secret is typed after. Longer ones first, so `Secret Access Key` is one label and not
113 * `secret` then `Access Key`. `_` and `-` may join a label to more of an identifier (`DB_PASSWORD`).
114 */
115const LABEL =
116  /(?<![A-Za-z0-9])(wi-?fi[ _-]?passw(?:or)?d|client[ _-]?secret|secret(?:[ _-]?access)?[ _-]?key|access[ _-]?key(?:[ _-]?id)?|api[ _-]?key|passw(?:or)?d|pass|secret|token)(?![A-Za-z0-9])/gi
117
118/** What may stand between a label and its separator: `for the guest network`, `of the router` (group 1). */
119const QUALIFIER = String.raw`(?:[ \t]+(?:for|of|on|at|in|to)[ \t]+((?:[^\s:=.,;!?]+[ \t]+){0,3}?[^\s:=.,;!?]+))?`
120
121/** `label: value`, `label = value`, `"label": "value"`, `label := value`, `label => value`. */
122const SAME_LINE = new RegExp(`${QUALIFIER}["'\`]?[ \\t]*(?:\\*\\*|__)?[ \\t]*(?::=|=>|:(?!:)|=(?!=))[ \\t]*(?:\\*\\*|__)?[ \\t]*(\\S+)`, 'iy')
123
124/** `label is value`, `label for the guest network is value`. */
125const IS_FORM = new RegExp(`${QUALIFIER}[ \\t]+(?:is|was)[ \\t]*:?[ \\t]+(\\S+)`, 'iy')
126
127/** The label ending its line (`for <what>` and a colon allowed) and the value alone on the next one: the two-line paste. */
128const NEXT_LINE = new RegExp(`${QUALIFIER}["'\`]?[ \\t]*:?[ \\t]*\\r?\\n[ \\t]*(\\S+)(?=[ \\t]*(?:\\r?\\n|$))`, 'iy')
129
130/** The last part of an identifier label that says it names something about a secret, not the secret. */
131const NOT_A_SECRET_SUFFIX = new Set([
132  'length', 'len', 'min', 'max', 'count', 'type', 'url', 'uri', 'endpoint', 'path', 'file', 'dir', 'name',
133  'field', 'label', 'hint', 'policy', 'expiry', 'expires', 'expiration', 'ttl', 'timeout', 'header', 'prefix',
134  'regex', 'pattern', 'rule', 'rules', 'limit', 'limits', 'size', 'format', 'mode', 'enabled', 'required',
135  'hash', 'env', 'var', 'variable', 'location', 'store', 'manager', 'provider', 'source', 'version',
136])
137
138/** The env var name each label stands for. */
139const LABEL_NAMES: readonly [RegExp, string][] = [
140  [/^wi-?fi[ _-]?passw(?:or)?d$/i, 'WIFI_PASSWORD'],
141  [/^client[ _-]?secret$/i, 'CLIENT_SECRET'],
142  [/^secret[ _-]?access[ _-]?key$/i, 'SECRET_ACCESS_KEY'],
143  [/^secret[ _-]?key$/i, 'SECRET_KEY'],
144  [/^access[ _-]?key[ _-]?id$/i, 'ACCESS_KEY_ID'],
145  [/^access[ _-]?key$/i, 'ACCESS_KEY'],
146  [/^api[ _-]?key$/i, 'API_KEY'],
147  [/^passw(?:or)?d$|^pass$/i, 'PASSWORD'],
148  [/^secret$/i, 'SECRET'],
149  [/^token$/i, 'TOKEN'],
150]
151
152/** Words that never name a service: "my", "the", "for now", "at least", ... */
153const STOP_WORDS = new Set([
154  'a', 'an', 'the', 'my', 'your', 'our', 'their', 'his', 'her', 'its', 'this', 'that', 'these', 'those', 'new',
155  'old', 'current', 'same', 'other', 'and', 'or', 'with', 'use', 'using', 'here', 'there', 'is', 'are', 'was',
156  'be', 'for', 'from', 'on', 'at', 'to', 'in', 'of', 'it', 'me', 'you', 'us', 'them', 'now', 'later', 'also',
157  'both', 'all', 'each', 'any', 'some', 'following', 'below', 'above', 'example', 'instance', 'least', 'most',
158  'once', 'temporary', 'temp', 'default', 'personal', 'secret', 'token', 'password', 'key', 'keys', 'api',
159  'access', 'please', 'set', 'get', 'got', 'just', 'then', 'what', 'which', 'whose', 'one', 'two', 'it\'s',
160  'login', 'log', 'sign', 'signing', 'free', 'real', 'actual', 'correct', 'right', 'wrong', 'today',
161])
162
163/** Second-level parts of a domain that are not the service's name. */
164const DOMAIN_NOISE = new Set(['www', 'api', 'app', 'apps', 'console', 'platform', 'dashboard', 'portal', 'co', 'com', 'org', 'net', 'ac', 'gov', 'edu', 'io'])
165
166const PASSWORD_LABEL = /passw(?:or)?d|^pass$|[ _-]pass$/i
167
168type Candidate = Finding & {
169  /** 2: named by an identifier (`DB_PASSWORD=`); 1: by a label with a service before it; 0: by a bare label or a shape. */
170  strength: number
171  isShape: boolean
172}
173
174/** What `detect` can be given beside the text: a person's own extra pattern. */
175export type DetectOptions = { extra?: RegExp | null }
176
177/** The secret's character classes present: lowercase, uppercase, digits, anything else. */
178export function characterClasses(value: string): number {
179  return [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter(re => re.test(value)).length
180}
181
182/** `$NAME`, `${NAME}`, `$(cmd)`, `%NAME%`, `{{ secrets.X }}`, `process.env.X`, `os.environ[...]`, `getenv(...)`. */
183export function isReference(value: string): boolean {
184  return (
185    value.startsWith('$') ||
186    /^%[A-Za-z_][A-Za-z0-9_]*%/.test(value) ||
187    /^\{\{.*\}\}$/.test(value) ||
188    /(?:^|[^A-Za-z0-9_])(?:process\.env|import\.meta\.env|os\.environ|os\.getenv|getenv|System\.getenv|ENV\[|env\(|secrets\.)/.test(value)
189  )
190}
191
192/** `xxxx`, `***`, `...`, `<your-key>`, `[token]`, `your-key-here`, `changeme`, `aaaaaaaa`. */
193export function isPlaceholder(value: string): boolean {
194  return (
195    /xxxx|XXXX|\*{3,}|\.{3,}|…|•{3,}/.test(value) ||
196    /^<[^>]*>$|^\[[^\]]*\]$|^\{[^}]*\}$/.test(value) ||
197    /your[-_ ]?(?:own[-_ ]?)?(?:api[-_ ]?|access[-_ ]?|secret[-_ ]?)?(?:key|token|secret|pass(?:word)?)/i.test(value) ||
198    /(?:key|token|secret|password)[-_]?here|change[-_]?me|placeholder|redacted|replace[-_]?me|insert[-_]?your/i.test(value) ||
199    new Set(value).size <= 2
200  )
201}
202
203/** A URL: an address, never a secret by itself (a password inside one is found as its own shape). */
204export function isUrl(value: string): boolean {
205  return /^[a-z][a-z0-9+.-]*:\/\//i.test(value) || /^www\./i.test(value)
206}
207
208/** `/etc/x`, `~/.ssh/id_rsa`, `./secrets.json`, `C:\x`, `config/secrets.yml`; a base64 value has `+` or `=` far more often. */
209export function isPath(value: string): boolean {
210  if (/^[\w.-]+(?:\/[\w.-]+)+\.[A-Za-z0-9]{1,6}$/.test(value)) {
211    return true
212  }
213  return /^(?:~|\.{1,2})?\/|^[A-Za-z]:[\\/]/.test(value) && !/[+=]/.test(value)
214}
215
216/** Code, not a value: `getToken()`, `config.apiKey`, `access_token`, `accessToken`, `API_KEY`, `Required`. */
217export function isCodeLike(value: string): boolean {
218  return (
219    /^[A-Za-z_$][\w$.]*[ \t]*[([{]/.test(value) ||
220    /^(?:true|false|null|none|nil|undefined)$/i.test(value) ||
221    /^[a-z]+(?:_[a-z]+)+$/.test(value) ||
222    /^[a-z]+(?:[A-Z][a-z]+)+$/.test(value) ||
223    /^(?:[A-Z][a-z]+)+$/.test(value) ||
224    /^[A-Z]+(?:_[A-Z0-9]+)*$/.test(value) ||
225    (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(value) && value.split('.').every(part => part.length <= 24))
226  )
227}
228
229/**
230 * Whether a value typed after a label reads as a secret rather than prose, code or a pointer to one:
231 * at least 8 characters, no reference, placeholder, URL, path or code, and mixed characters (digits
232 * alone only after a password label).
233 */
234export function isSecretValue(value: string, label = ''): boolean {
235  if (value.length < 8 || /\s/.test(value)) {
236    return false
237  }
238  if (isReference(value) || isPlaceholder(value) || isUrl(value) || isPath(value) || isCodeLike(value)) {
239    return false
240  }
241  if (/^\d+$/.test(value)) {
242    return PASSWORD_LABEL.test(label)
243  }
244  return characterClasses(value) >= 2
245}
246
247/** `FOO bar-baz.qux` → `FOO_BAR_BAZ_QUX`: uppercase, `[A-Z][A-Z0-9_]*`; `''` when nothing is left. */
248export function toEnvName(text: string): string {
249  const name = text
250    .toUpperCase()
251    .replace(/[^A-Z0-9]+/g, '_')
252    .replace(/^_+|_+$/g, '')
253  if (name === '') {
254    return ''
255  }
256  return /^[A-Z]/.test(name) ? name : `SECRET_${name}`
257}
258
259/** The name field as the person types it: uppercased, other characters as `_`, nothing trimmed yet. */
260export function typedName(text: string): string {
261  return text.toUpperCase().replace(/[^A-Z0-9_]/g, '_')
262}
263
264/** `opendatalab.com` → `opendatalab`, `api.openai.com` → `openai`; a plain word is kept. */
265export function serviceOf(word: string): string {
266  const parts = word.split('.').filter(Boolean)
267  if (parts.length < 2) {
268    return word
269  }
270  const core = parts.slice(0, -1).filter(part => !DOMAIN_NOISE.has(part.toLowerCase()))
271  return core.at(-1) ?? parts[0] ?? word
272}
273
274/** The env var name part a label stands for: `Access Key ID` → `ACCESS_KEY_ID`. */
275export function labelName(label: string): string {
276  return LABEL_NAMES.find(([re]) => re.test(label))?.[1] ?? toEnvName(label)
277}
278
279/** The service word just before a label on its line (`KIT api key`, `OpenDataLab Access Key ID`), or null. */
280function wordBefore(text: string, at: number): string | null {
281  const lineStart = Math.max(text.lastIndexOf('\n', at - 1) + 1, at - 80)
282  const match = /([A-Za-z][A-Za-z0-9.-]*[A-Za-z0-9])(?:'s)?[ \t]+$/.exec(text.slice(lineStart, at))
283  const word = match?.[1]
284  return word && !STOP_WORDS.has(word.toLowerCase()) ? word : null
285}
286
287/** The service a nearby "for opendatalab", "from huggingface", "on staging", "at example.com" names, or null. */
288function contextWord(text: string, at: number): string | null {
289  const window = text.slice(Math.max(0, at - CONTEXT_REACH), at)
290  const words = [...window.matchAll(/(?<![A-Za-z0-9])(?:for|from|on|at)[ \t]+(?:(?:the|my|our|your)[ \t]+)?([A-Za-z][A-Za-z0-9.-]*[A-Za-z0-9])/gi)]
291    .map(match => match[1] ?? '')
292    .filter(word => word !== '' && !STOP_WORDS.has(word.toLowerCase()))
293  return words.at(-1) ?? null
294}
295
296/**
297 * The service prefix for a secret whose label starts at `at`: the word before the label, else the label's own
298 * `for <what>`, else a "for <service>" up to 300 characters back; `''` for none.
299 */
300export function servicePrefix(text: string, at: number, qualifier: string | null = null): string {
301  const word = wordBefore(text, at) ?? qualifier ?? contextWord(text, at)
302  return word ? toEnvName(serviceOf(word)) : ''
303}
304
305/** Joins a service prefix to a name part, not repeating it: `OPENDATALAB` + `ACCESS_KEY_ID`. */
306function prefixed(prefix: string, name: string): string {
307  return prefix === '' || name === prefix || name.startsWith(`${prefix}_`) ? name : `${prefix}_${name}`
308}
309
310/** The ranges of fenced code blocks (``` or ~~~), each from its opening fence to its closing one or the end. */
311export function fencedRanges(text: string): [number, number][] {
312  const ranges: [number, number][] = []
313  let open: { at: number; fence: string } | null = null
314  let at = 0
315  for (const line of text.split('\n')) {
316    const fence = /^[ \t]*(`{3,}|~{3,})/.exec(line)?.[1]
317    if (fence && open === null) {
318      open = { at, fence: fence.slice(0, 3) }
319    } else if (fence && open !== null && fence.startsWith(open.fence)) {
320      ranges.push([open.at, at + line.length])
321      open = null
322    }
323    at += line.length + 1
324  }
325  if (open !== null) {
326    ranges.push([open.at, text.length])
327  }
328  return ranges
329}
330
331/** Quotes, a wrapping tag and trailing punctuation around a value: `"abc",` and `abc</td>` → `abc`; the trimmed offsets. */
332function trimValue(text: string, start: number, end: number): [number, number] {
333  let s = start
334  let e = end
335  for (let isTrimming = true; isTrimming; ) {
336    isTrimming = false
337    while (s < e && /["'`]/.test(text[s] ?? '')) {
338      s += 1
339    }
340    const tag = /<\/?[A-Za-z][\w-]*>$/.exec(text.slice(s, e))
341    if (tag && tag.index > 0) {
342      e -= tag[0].length
343      isTrimming = true
344    }
345    while (e > s && /[.,;:!?)\]}"'`]/.test(text[e - 1] ?? '')) {
346      e -= 1
347      isTrimming = true
348    }
349  }
350  return [s, e]
351}
352
353/** The token around a label joined by `_` or `-` (`DB_PASSWORD`, `x-api-key`): its offsets. */
354function identifierAround(text: string, start: number, end: number): [number, number] {
355  let s = start
356  let e = end
357  while (s > 0 && /[A-Za-z0-9_-]/.test(text[s - 1] ?? '')) {
358    s -= 1
359  }
360  while (e < text.length && /[A-Za-z0-9_-]/.test(text[e] ?? '')) {
361    e += 1
362  }
363  return [s, e]
364}
365
366/** The value a label ending at `end` is tied to, by `:`/`=`, `is`, or the next line, with any `for <what>` between; or null. */
367function valueAfter(
368  text: string,
369  end: number,
370): { start: number; end: number; separator: Separator; qualifier: string } | null {
371  const forms: [RegExp, Separator][] = [
372    [SAME_LINE, 'colon'],
373    [IS_FORM, 'is'],
374    [NEXT_LINE, 'newline'],
375  ]
376  for (const [re, separator] of forms) {
377    re.lastIndex = end
378    const match = re.exec(text)
379    const value = match?.[2]
380    if (match && value !== undefined) {
381      const stop = match.index + match[0].length
382      return { start: stop - value.length, end: stop, separator, qualifier: match[1] ?? '' }
383    }
384  }
385  return null
386}
387
388/** The first word of a `for the guest network` qualifier that can name a service (`guest`), or null. */
389function qualifierWord(qualifier: string): string | null {
390  return qualifier.split(/[ \t]+/).find(word => /^[A-Za-z][A-Za-z0-9.-]*[A-Za-z0-9]$/.test(word) && !STOP_WORDS.has(word.toLowerCase())) ?? null
391}
392
393/** Secrets typed after a label: `password: …`, `Secret Access Key\n…`, `the wifi password is …`, `DB_PASSWORD=…`. */
394function labelled(text: string): Candidate[] {
395  const found: Candidate[] = []
396  for (const match of text.matchAll(LABEL)) {
397    const label = match[1] ?? ''
398    const labelStart = match.index ?? 0
399    const [idStart, idEnd] = identifierAround(text, labelStart, labelStart + match[0].length)
400    const identifier = text.slice(idStart, idEnd)
401    const isIdentifier = identifier !== label
402    if (isIdentifier) {
403      const last = identifier.split(/[_-]/).at(-1)?.toLowerCase() ?? ''
404      if (NOT_A_SECRET_SUFFIX.has(last) && !LABEL_NAMES.some(([re]) => re.test(last))) {
405        continue
406      }
407    }
408    const tied = valueAfter(text, idEnd)
409    if (!tied) {
410      continue
411    }
412    const [start, end] = trimValue(text, tied.start, tied.end)
413    const value = text.slice(start, end)
414    if (!isSecretValue(value, isIdentifier ? identifier : label)) {
415      continue
416    }
417    if (isIdentifier) {
418      const name = toEnvName(identifier)
419      found.push({ kind: 'labelled', label: identifier, value, start, end, name, strength: 2, isShape: false })
420      continue
421    }
422    const prefix = servicePrefix(text, labelStart, qualifierWord(tied.qualifier))
423    const name = prefixed(prefix, labelName(label))
424    found.push({ kind: 'labelled', label, value, start, end, name, strength: prefix ? 1 : 0, isShape: false })
425  }
426  return found
427}
428
429/** The env var a URL's password suggests: `POSTGRES_PASSWORD`, or the host's name for http(s). */
430function urlPasswordName(scheme: string, host: string): string {
431  const base = /^(?:https?|ftp|sftp|ssh|wss?)$/i.test(scheme) ? serviceOf(host) : scheme.replace(/ql$/i, '').replace(/\+srv$/i, '')
432  return prefixed(toEnvName(base), 'PASSWORD')
433}
434
435/** Secrets of a known shape, the AWS secret beside an access key ID, and passwords inside URLs. */
436function shaped(text: string): Candidate[] {
437  const found: Candidate[] = []
438  for (const shape of SHAPES) {
439    for (const match of text.matchAll(shape.pattern)) {
440      const value = shape.group === undefined ? match[0] : (match[shape.group] ?? '')
441      const start = (match.index ?? 0) + (shape.group === undefined ? 0 : match[0].lastIndexOf(value))
442      if (value === '' || isPlaceholder(value.replace(/^[A-Za-z]+[_-]/, '')) || (shape.isLoose && !isSecretValue(value))) {
443        continue
444      }
445      found.push({ kind: shape.kind, label: shape.label, value, start, end: start + value.length, name: shape.name, strength: 0, isShape: true })
446    }
447  }
448  for (const key of found.filter(one => one.kind === 'aws-access-key-id')) {
449    const from = Math.max(0, key.start - AWS_SECRET_REACH)
450    const window = text.slice(from, key.end + AWS_SECRET_REACH)
451    for (const match of window.matchAll(AWS_SECRET)) {
452      const value = match[0]
453      const start = from + (match.index ?? 0)
454      const isKey = start < key.end && start + value.length > key.start
455      if (isKey || /^[0-9a-f]{40}$/i.test(value) || !/[a-z]/.test(value) || !/[A-Z]/.test(value) || isPlaceholder(value)) {
456        continue
457      }
458      found.push({
459        kind: 'aws-secret-access-key',
460        label: 'AWS secret access key',
461        value,
462        start,
463        end: start + value.length,
464        name: 'AWS_SECRET_ACCESS_KEY',
465        strength: 0,
466        isShape: true,
467      })
468    }
469  }
470  for (const match of text.matchAll(URL_PASSWORD)) {
471    const value = match[2] ?? ''
472    const start = (match.index ?? 0) + match[0].lastIndexOf(`:${value}@`) + 1
473    if (!isSecretValue(value, 'password')) {
474      continue
475    }
476    const name = urlPasswordName(match[1] ?? '', match[3] ?? '')
477    found.push({ kind: 'url-password', label: 'Password in a URL', value, start, end: start + value.length, name, strength: 0, isShape: true })
478  }
479  return found
480}
481
482/** Matches of the person's own pattern (group 1 when it has one), named by a nearby service when one is. */
483function custom(text: string, extra: RegExp): Candidate[] {
484  const found: Candidate[] = []
485  const pattern = new RegExp(extra.source, `${extra.flags.replace(/[gy]/g, '')}g`)
486  for (const match of text.matchAll(pattern)) {
487    const value = match[1] ?? match[0]
488    if (value.length < 4 || isReference(value) || isPlaceholder(value)) {
489      continue
490    }
491    const start = (match.index ?? 0) + Math.max(0, match[0].indexOf(value))
492    const prefix = servicePrefix(text, match.index ?? 0)
493    const name = prefix ? `${prefix}_SECRET` : ''
494    found.push({ kind: 'custom', label: 'Custom pattern', value, start, end: start + value.length, name, strength: prefix ? 1 : 0, isShape: true })
495  }
496  return found
497}
498
499/** One finding per stretch of text: a shape's range and kind, a label's naming when it names more. */
500function merge(candidates: Candidate[]): Candidate[] {
501  const sorted = [...candidates].sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start))
502  const kept: Candidate[] = []
503  for (const next of sorted) {
504    const index = kept.findIndex(one => next.start < one.end && one.start < next.end)
505    const prior = index === -1 ? undefined : kept[index]
506    if (prior === undefined) {
507      kept.push(next)
508      continue
509    }
510    const shape = prior.isShape ? prior : next.isShape ? next : prior.end - prior.start >= next.end - next.start ? prior : next
511    const namer = [prior, next].sort((a, b) => b.strength - a.strength)[0] ?? prior
512    const isNamedByLabel = namer.strength > 0
513    kept[index] = {
514      ...shape,
515      label: isNamedByLabel ? namer.label : shape.label,
516      name: isNamedByLabel ? namer.name : shape.name || namer.name,
517      strength: Math.max(prior.strength, next.strength),
518    }
519  }
520  return kept
521}
522
523/** Unnamed findings become `SECRET_1`, `SECRET_2`, ...; a name used twice gets `_2`, `_3`. */
524function nameAll(findings: Candidate[]): Finding[] {
525  const used = new Set<string>()
526  let unnamed = 0
527  return findings.map(({ strength: _strength, isShape: _isShape, ...finding }) => {
528    let base = finding.name
529    if (base === '') {
530      unnamed += 1
531      base = `SECRET_${unnamed}`
532    }
533    let name = base
534    for (let n = 2; used.has(name); n += 1) {
535      name = `${base}_${n}`
536    }
537    used.add(name)
538    return { ...finding, name }
539  })
540}
541
542/**
543 * The secrets in a prompt, in order: known shapes, values typed after a label, the person's own pattern.
544 * Values in fenced code that say `EXAMPLE` are examples, not secrets.
545 */
546export function detect(text: string, options: DetectOptions = {}): Finding[] {
547  const fences = fencedRanges(text)
548  const candidates = [...shaped(text), ...labelled(text), ...(options.extra ? custom(text, options.extra) : [])]
549  const real = candidates.filter(
550    one => !(one.value.includes('EXAMPLE') && fences.some(([from, to]) => one.start >= from && one.end <= to)),
551  )
552  return nameAll(merge(real))
553}
554
555/** `…vxrm`: the last characters of a secret, never more than a fifth of it; a private key shows none. */
556export function mask(value: string): string {
557  if (value.startsWith('-----BEGIN')) {
558    return '(key block)'
559  }
560  const shown = Math.min(4, Math.floor(value.length / 5))
561  return shown > 0 ? `…${value.slice(-shown)}` : '…'
562}
563
564/** `Secret Access Key`, `Access Key ID and Secret Access Key`, `3 secrets`: never a value. */
565export function describeFindings(labels: readonly string[]): string {
566  if (labels.length === 1) {
567    return `a secret (${labels[0]})`
568  }
569  if (labels.length === 2) {
570    return `2 secrets (${labels[0]} and ${labels[1]})`
571  }
572  return `${labels.length} secrets`
573}
574
575/** What `/secrets test` answers: what would be caught, masked, and the names it would suggest. */
576export function testReport(findings: readonly Finding[]): string {
577  if (findings.length === 0) {
578    return 'secret-guard finds no secret in that text: it would be sent as is.'
579  }
580  const count = findings.length === 1 ? '1 secret' : `${findings.length} secrets`
581  return [
582    `secret-guard would hold that prompt back: ${count}.`,
583    ...findings.map(finding => `  ${finding.label} ${mask(finding.value)} → $${finding.name}`),
584  ].join('\n')
585}
586
587/** Zero-width and other invisible characters, CRLF and the ends trimmed: what a refilled box sends back. */
588function normalized(text: string): string {
589  return text.replace(/\p{Cf}/gu, '').replace(/\r\n/g, '\n').trim()
590}
591
592/** A fingerprint of a prompt's text (two 32-bit FNV-style hashes and the length), so the text itself need not be kept. */
593export function hashText(text: string): string {
594  const s = normalized(text)
595  let a = 0x811c9dc5
596  let b = 0x9747b28c
597  for (let i = 0; i < s.length; i += 1) {
598    const c = s.charCodeAt(i)
599    a = Math.imul(a ^ c, 0x01000193) >>> 0
600    b = Math.imul(b ^ c, 0x5bd1e995) >>> 0
601    b = (b ^ (b >>> 13)) >>> 0
602  }
603  return `${s.length}:${a.toString(16)}:${b.toString(16)}`
604}
605
606/** True for what the engine draws when no plugin draws the band, or an empty Box. */
607export function isBlankTree(tree: RenderElement): boolean {
608  if (tree.type === 'engine') {
609    return true
610  }
611  return tree.type === 'Box' && (tree.children ?? []).length === 0
612}
613
hooks/zshrc.ts 148 lines
1/** A value in single quotes for zsh: every `'` closed, escaped and reopened (`it's` → `'it'\''s'`). */
2export function shellQuote(value: string): string {
3  return `'${value.replace(/'/g, `'\\''`)}'`
4}
5
6/** The line Save appends: `export NAME='value'`. */
7export function exportLine(name: string, value: string): string {
8  return `export ${name}=${shellQuote(value)}`
9}
10
11/**
12 * One shell word from `at`, its quoting undone: `'…'` as written, `"…"` with `\"`, `\\`, `\$` and
13 * `` \` `` unescaped, a bare `\x` as `x`; it ends at unquoted whitespace, `;`, `&` or `|`.
14 */
15export function readShellWord(text: string, at: number): string {
16  let out = ''
17  let i = at
18  while (i < text.length) {
19    const c = text[i] ?? ''
20    if (c === "'") {
21      const close = text.indexOf("'", i + 1)
22      out += close === -1 ? text.slice(i + 1) : text.slice(i + 1, close)
23      i = close === -1 ? text.length : close + 1
24    } else if (c === '"') {
25      i += 1
26      while (i < text.length && text[i] !== '"') {
27        const d = text[i] ?? ''
28        const after = text[i + 1] ?? ''
29        if (d === '\\' && '"\\$`\n'.includes(after) && after !== '') {
30          out += after === '\n' ? '' : after
31          i += 2
32        } else {
33          out += d
34          i += 1
35        }
36      }
37      i += 1
38    } else if (c === '\\') {
39      const after = text[i + 1] ?? ''
40      out += after === '\n' ? '' : after
41      i += 2
42    } else if (/[\s;&|]/.test(c)) {
43      break
44    } else {
45      out += c
46      i += 1
47    }
48  }
49  return out
50}
51
52/** The value `export NAME=…` gives NAME in a shell file (the last such line wins), or undefined when none does. */
53export function exportedValue(rc: string, name: string): string | undefined {
54  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
55  const line = new RegExp(`^[ \\t]*(?:export|typeset[ \\t]+-x|declare[ \\t]+-x)[ \\t]+${escaped}=`, 'gm')
56  let value: string | undefined
57  for (const match of rc.matchAll(line)) {
58    value = readShellWord(rc, (match.index ?? 0) + match[0].length)
59  }
60  return value
61}
62
63/** What saving one secret does to the shell file: its new text, the name used, and whether it was there already. */
64export type ExportPlan = { text: string; name: string; isReused: boolean }
65
66/**
67 * Saves `value` as `name` in the shell file's text: reused when `name` already exports that value,
68 * appended as `export NAME='value'` when `name` is not exported, and as `NAME_2` (`_3`, …) when `name`
69 * already holds a different value.
70 */
71export function planExport(rc: string, name: string, value: string): ExportPlan {
72  for (let n = 1; n < 100; n += 1) {
73    const candidate = n === 1 ? name : `${name}_${n}`
74    const existing = exportedValue(rc, candidate)
75    if (existing === value) {
76      return { text: rc, name: candidate, isReused: true }
77    }
78    if (existing === undefined) {
79      const lead = rc === '' || rc.endsWith('\n') ? rc : `${rc}\n`
80      return { text: `${lead}${exportLine(candidate, value)}\n`, name: candidate, isReused: false }
81    }
82  }
83  throw new Error(`${name} and its _2 to _99 are all taken in the shell file`)
84}
85
86/** Several secrets saved in turn: the final text, and the name each one ended up under. */
87export function planExports(rc: string, secrets: readonly { name: string; value: string }[]): { text: string; plans: ExportPlan[] } {
88  let text = rc
89  const plans: ExportPlan[] = []
90  for (const secret of secrets) {
91    const plan = planExport(text, secret.name, secret.value)
92    text = plan.text
93    plans.push(plan)
94  }
95  return { text, plans }
96}
97
98/** The shell file's absolute path: `~` and a relative path are under HOME; null when HOME is needed and unset. */
99export function resolveRcPath(configured: string, home: string | undefined): string | null {
100  const path = configured.trim() || '~/.zshrc'
101  if (path.startsWith('/')) {
102    return path
103  }
104  if (!home) {
105    return null
106  }
107  const base = home.replace(/\/+$/, '')
108  if (path === '~') {
109    return base
110  }
111  return path.startsWith('~/') ? `${base}/${path.slice(2)}` : `${base}/${path}`
112}
113
114/** The prompt with each secret replaced by `$NAME` (`${NAME}` when a word character follows). */
115export function replaceWithRefs(text: string, spans: readonly { start: number; end: number }[], names: readonly string[]): string {
116  const order = spans.map((span, index) => ({ ...span, name: names[index] ?? '' })).sort((a, b) => b.start - a.start)
117  let out = text
118  for (const { start, end, name } of order) {
119    const ref = /[A-Za-z0-9_]/.test(out[end] ?? '') ? `\${${name}}` : `$${name}`
120    out = out.slice(0, start) + ref + out.slice(end)
121  }
122  return out
123}
124
125/** `A`, `A and B`, `A, B and C`. */
126function listed(items: readonly string[]): string {
127  return items.length <= 1 ? (items[0] ?? '') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`
128}
129
130/**
131 * The toast after Save: what was saved or already there, what the prompt now says, and how to check.
132 * Names only, never a value.
133 */
134export function saveToast(plans: readonly ExportPlan[], where: string): string {
135  const saved = plans.filter(plan => !plan.isReused).map(plan => plan.name)
136  const reused = plans.filter(plan => plan.isReused).map(plan => plan.name)
137  const names = plans.map(plan => plan.name)
138  const parts = [
139    ...(saved.length > 0 ? [`Saved ${listed(saved)} to ${where}`] : []),
140    ...(reused.length > 0 ? [`${listed(reused)} ${reused.length === 1 ? 'was' : 'were'} already in ${where}`] : []),
141  ]
142  const first = names[0] ?? 'NAME'
143  return (
144    `${parts.join('; ')}; the prompt now says ${listed(names.map(name => `$${name}`))}. ` +
145    `New shells see ${names.length === 1 ? 'it' : 'them'} (Claude can run: zsh -ic 'echo \${#${first}}' to check).`
146  )
147}
148
types/index.d.ts 44 lines
1/** How a labelled value was tied to its label: `:` or `=`, ` is `, or the value alone on the next line. */
2export type Separator = 'colon' | 'is' | 'newline'
3
4/** One secret found in a prompt: where it sits, what it is, and the env var name suggested for it. */
5export type Finding = {
6  /** A known shape's id (`aws-access-key-id`, `github-token`, ...), `labelled` or `custom`. */
7  kind: string
8  /** What the band calls it: the label as typed (`Secret Access Key`) or the shape's name (`GitHub token`). */
9  label: string
10  /** The secret itself. Kept in the module's memory only: never in state, a toast, a log or the status line. */
11  value: string
12  /** Offsets of `value` in the prompt's text. */
13  start: number
14  end: number
15  /** The env var name suggested for it, `[A-Z][A-Z0-9_]*`; unique within one prompt. */
16  name: string
17}
18
19/** One secret as the band shows it: its label, its masked tail and the name to save it under; never its value. */
20export type BandItem = {
21  label: string
22  /** `…vxrm`: the last few characters, at most a fifth of the value. */
23  masked: string
24  /** The name in the band's field, as the person is typing it. */
25  name: string
26  /** The suggested name, used when the field is left empty. */
27  suggested: string
28}
29
30/** What the band above the prompt shows for the prompt held back last. */
31export type BandView = {
32  /** Which held prompt this is; the band draws only while the module still holds that prompt. */
33  id: number
34  items: BandItem[]
35  /** Whether the box took the text back; false where no box could (headless). */
36  isRefilled: boolean
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    'secret-guard': { band: BandView | null; isOff: boolean }
42  }
43}
44