SLOPSHOPPER

clowk

Catches credentials you paste into an agent chat before they reach the model, files them locally, and records where each one came from. Manage with /clowk.

rowsguardstatuspromptprocess
v0.2.1MITupdated 2026-10-20Aniumbott/clowk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clowk
› 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 ⏺ Grep() ⎿ Denied by clowk: clowk could not check this output for credentials, so it was withheld. Run `clowk setup` ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ clowk: not found on PATH, so messages are held back · install clowk, or start a message with unclowk
README

<img src="https://raw.githubusercontent.com/Aniumbott/clowk/main/assets/logo.svg" alt="" width="96" height="96">

clowk

Catches credentials you paste into Claude Code and swaps them for a name before the model sees them.

tests python license

Paste a database URL, an API key or a token into Claude Code and press Enter. clowk swaps the value for a $NAME before the message is sent, files the value in a local vault, and lets the message go on. The model only ever gets the name, and your agent uses the credential by reference: psql "$(clowk get DATABASE_URL)". You don't repaste anything and nothing is blocked.

❯ check this connects and tell me how many orders we have: $DATABASE_URL
  🔒 clowk  $DATABASE_URL stayed on this machine · the model gets the name, never the value
⏺ Bash(psql "$(clowk get DATABASE_URL)" -Atc "select count(*) from orders;")
  ⎿  1284
⏺ The production database connected, and it has 1,284 orders.

Watch the 3-minute demo on YouTube

▶ Watch the 3-minute demo

Claude Code 2.1.287+, through a Claude Code plugin. Python 3.8+, standard library only. No daemon, nothing to sign up for, and nothing in the guard touches the network.

Codex and Gemini CLI are supported in classic mode: their hooks can only block a prompt, not rewrite it, so the turn is blocked and your message comes back on your clipboard with the $NAME in it.

Why it exists: 113 of my Claude Code transcripts had an API key in them — what I found when I went looking through my own, and what clowk was built to stop.

Contents

Quick startInstall in two commands
In Claude CodeWhat the plugin does, and how it fails
Classic modeCodex, Gemini CLI, and Claude Code before 2.1.287
What it is good forBeyond catching a mistake
Uninstalling · UpdatingThe vault decision, and keeping the skill current
How it worksThe capture path and what gets detected
Using a credential$(clowk get NAME) and the guard around it
CommandsThe full CLI
The tool-deny hookFor leaks you did not type
ArchitectureEverything on one diagram
LimitationsWhat this does not protect you from
ConfigurationPaths and environment variables
False positivesWhy they happen and how to clear them
ContributingHow to help
SecurityReporting a vulnerability
DevelopmentTests, layout, the ruleset

Quick start

uv tool install clowk        # or: pipx install clowk
clowk setup

That's it. clowk setup:

  1. finds the agent CLIs on your machine and registers clowk's hooks in each,
  2. installs the skill that tells the agent what a $NAME is,
  3. on Claude Code 2.1.287+, installs the clowk plugin through claude plugin,
  4. fires a test credential through the hook it just registered, to prove the guard really blocks.

Restart Claude Code afterwards so it loads the plugin.

pip install clowk will not work on most machines, and that is not clowk's doing: Homebrew, Debian, Ubuntu and Fedora Pythons refuse to install into themselves (PEP 668). No uv or pipx? A clone needs no installer at all: git clone https://github.com/Aniumbott/clowk.git && cd clowk && python3 clowk/cli.py setup.

For dotfiles, a Dockerfile or CI, it runs unattended:

clowk setup --hosts claude-code --yes
clowk setup --dry-run                     # print the plan, write nothing

To add the plugin by hand instead, in Claude Code:

/plugin marketplace add Aniumbott/clowk
/plugin install clowk@clowk

A package install already provides clowk on your PATH through an entry point, so nothing extra is written. From a clone there is no entry point, so install writes ~/.local/bin/clowk — and if that directory is not on your PATH it says so and prints the line to add. Either way clowk has to be a real command, because the callers that matter are the plugin and the non-interactive shell your agent runs.

Do not substitute a shell alias. Aliases exist only in interactive shells, and the caller that matters is the non-interactive Bash your agent runs. There $(clowk get NAME) expands to nothing, your command runs without the credential, and the error looks like a bad key rather than a missing tool.

install merges into your existing settings, backs them up first, and refuses a settings file that is not valid UTF-8 JSON. uninstall removes only what clowk wrote, byte for byte. Hooks and launcher hold absolute paths to this clone and to the interpreter you ran install with, so nothing depends on PATH — but move the clone and you re-run install.

In Claude Code

The clowk plugin is a Claude Code mod: code that runs inside Claude Code and can change a prompt on its way out, which a settings hook cannot do. On each prompt it runs clowk rewrite (about 85 ms), so the clowk package has to be installed and on your PATH.

What happensDetail
You paste a credentialIt becomes its $NAME as you press Enter and the message goes on. Your message shows the name in teal, with a note under it that the value stayed on this machine.
The agent needs itIt runs psql "$(clowk get DATABASE_URL)". The value goes to the program, not the model. The model is told what a $NAME is through context it reads but you never see.
A command prints oneWhat a command prints is scrubbed too: a credential in the output of Bash, Grep, WebFetch, WebSearch or an MCP tool reaches the model as its $NAME. Output is held to vendor formats and connection URLs. The two generic shape rules are skipped there, because in output they mostly match hashes and ids.
The agent goes lookingThe tool-deny hook refuses cat .env, private keys, git credential fill and the vault itself.
A subagent reports backTurns Claude Code starts by itself, like a subagent's report or a background task's notice, are not checked. What is in them came from a model or a tool, and blocking them cut agents off mid-task.

Read output is left alone on purpose. An agent edits what it reads, and a file written back with $NAME in place of the value would lose the value. cat file followed by a whole-file write has the same risk; it is rare.

It fails closed. If clowk can't run (missing, an error, a timeout), the message is not sent, and the reason on screen says why. Starting a message with unclowk sends it unchecked.

  • A clowk older than the plugin: the plugin steps aside, the package's own hooks block as before, and a status line under the prompt says to run clowk update.
  • The plugin not running at all (--bare, --safe-mode, a policy that refuses user mods): the hooks clowk setup registered are the backstop. They run beneath the plugin, see only the rewritten text, and pass it. With the plugin off, they block, as in classic mode.

Classic mode: Codex, Gemini CLI, and older Claude Code

Agent CLIs whose hooks can only allow or block a prompt get the original flow. So do Claude Code before 2.1.287 and Claude Code without the plugin. The turn is blocked before anything is sent, the value is filed, and your message comes back on your clipboard with the $NAME in it, ready to repaste:

👀 clowk caught a credential before it reached the model.

   💾  $STRIPE_SECRET_KEY

📋 Paste this — already on your clipboard:

   rotate this key for me: $STRIPE_SECRET_KEY

🤔 Not a credential? Resend starting with  unclowk

The message shows only the lines the credential was on, cut to 40 characters either side, with the name highlighted where the terminal can show it. $(clowk get NAME), the tool-deny hook and the vault work the same as in Claude Code.

HostStatus
Claude Code 2.1.287+full: the plugin rewrites in place
Claude Code before 2.1.287, or without the pluginclassic, verified
Codexclassic, verified. Hooks need trust: run /hooks and approve clowk; trust is hash-based, so every update asks again
Gemini CLIclassic; the hooks are registered, but its payload shape is not verified (clowk debug-payload shows what it sends)
Antigravity, opencode, Grok CLInot supported yet

What it is good for

Catching a paste is how most people meet clowk, but it is the smaller half. The same three primitives — a local store, a $NAME reference that is worthless on its own, and a guard that only permits command substitution — make clowk a credential supply route for agents rather than only a net under your mistakes.

Nothing here needs a credential to have been leaked first. clowk add NAME takes a value at the terminal, so it never enters a chat at all.

Use it forHow
Credentials that survive across sessionsclowk add each one once, then name clowk in your CLAUDE.md. Every future session can reach $(clowk get DATABASE_URL) without a value ever entering a prompt, a transcript, or the model.
Keeping keys out of the agent's environmentA captured value is not exported. env, printenv and a leaked .env dump cannot reveal it — unlike export STRIPE_KEY=… in your shell profile, which every agent session inherits and any env call prints straight into the transcript.
Runbooks and docs you can commitpsql "$(clowk get DATABASE_URL)" is safe to write into a committed runbook, CLAUDE.md, or a README. The reference is worth nothing to whoever clones it.
One store across several agent CLIsClaude Code, Codex and Gemini CLI all read the same ~/.clowk/vault.json. One credential store instead of three sets of environment variables.
Rotation without archaeologyclowk uses NAME reports where a credential was captured and every directory that has drawn on it since, so a rotation is a list to work through rather than a guess.
Noticing a bad habitRe-catches are counted. When clowk list says a key has been caught five times, that key belongs in clowk add and out of your muscle memory.
Agent-initiated leaksThe tool-deny hook refuses cat .env, git credential fill, private keys and the vault itself — the ways a credential reaches a transcript without you typing anything.
Pasting logs and dumpsCredentials inside a pasted log are redacted before the model sees them, whether or not they get filed.

What it is not good for. Screen sharing and recordings: what you paste is on screen until you press Enter — and for a moment after, while Claude Code draws the message before the plugin's rewrite lands. In classic mode Claude Code prints the raw prompt back under clowk's message. The model never gets the value either way, but the screen does. It is also not a CI or deployment secret manager — there is no daemon, nothing on the network in the capture path, and the vault is a local file belonging to one user.

Uninstalling

clowk uninstall                       # per host you registered

Your credentials are a separate decision, and uninstall makes you take it. The vault is the only copy of every value in it, so removing clowk does not remove ~/.clowk/vault.json unless you say so. Either way it tells you the file is still there, how many credentials it holds, and that nothing else on your machine reads it — a plaintext file of live keys should not be left behind by accident, and it should not be destroyed by accident either.

Interactively you get three options: back it up first, delete it, or keep it. Deleting asks you to type DELETE rather than pressing a key, because it cannot be undone. Unattended, it keeps the vault and prints the flags:

clowk uninstall --backup ~/clowk-backup.json   # the vault itself, values and all, mode 0600
clowk uninstall --purge                        # delete the vault, no prompt
clowk uninstall --keep-vault                   # keep it without being asked

The backup is the vault's own JSON rather than a report, so restoring is a file copy — cp ~/clowk-backup.json ~/.clowk/vault.json — not a re-typing of every credential. It carries a _restore key saying so, since JSON cannot hold a comment.

It is plaintext by definition, and clowk's own deny hook does not protect it — that hook knows the vault's real path and nothing else. Move it somewhere safe or delete it once you are done.

Run clowk uninstall before pip uninstall clowk or pipx uninstall clowk. Removing the package first leaves the hooks registered, pointing at a script that no longer exists — and since every host fails open, that is a guard that silently passes every credential from then on.

On Windows use python or py; there is no python3 on a stock install. On Codex, hooks need trust: run /hooks and approve clowk. Trust is hash-based, so every update asks again.

Updating

clowk update           # --check to look without changing anything

Why this is a command and not just git pull. The hooks and launcher hold absolute paths, so they pick up new code immediately — but the skill is copied into each host's skills directory and /clowk is generated into ~/.claude/commands/, so neither moves until install runs again. New code, old skill, and nothing on screen saying so. clowk update does both halves for every host you have registered.

For a clone it runs git pull --ff-only itself, refusing if you have modified tracked files. Untracked files are ignored, since they cannot make the pull fail. For a package install it prints the right command for the manager it can see — pipx upgrade, uv tool upgrade or pip install --upgrade — rather than guessing, because guessing wrong can leave a half-replaced package and on Windows pip install -U against a running package can fail on locked files.

The plugin updates through Claude Code, not through clowk update. Claude Code caches it under ~/.claude/plugins/cache/clowk/clowk/<version>/, keyed by plugin.json's version. A release bumps that version; then /plugin marketplace update clowk and /reload-plugins pick it up. Until then the cached copy keeps running, and if it is older than the package it still works: the package is what it calls.

clowk install writes ~/.claude/commands/clowk.md, so /clowk works with no further steps, and gives the agent the skill that tells it what a $NAME is. Without that skill an agent reads $DATABASE_URL as an ordinary empty variable and asks you to paste the real one again. With the plugin installed the skill comes from the plugin, along with /clowk:clowk; otherwise it is copied to ~/.claude/skills/clowk/. Never both, so it isn't listed twice; clowk update removes a copy an older install left behind.

How it works

In Claude Code, the plugin rewrites the prompt on its way out:

flowchart TD
    A["you paste<br/><b>postgresql://…:REDACTED@db</b>"]:::secret
    B{"clowk plugin<br/>runs clowk rewrite<br/>as you press Enter"}:::tool
    V[("🗄️ ~/.clowk/vault.json<br/>0600 · your only copy")]:::secret
    R["your message, rewritten<br/><b>$DATABASE_URL</b>"]:::ref
    M["🤖 the model"]:::ref
    C["clowk get DATABASE_URL<br/>inside the command<br/>the value goes to the program"]:::ref

    A --> B
    B -->|"files the value"| V
    B --> R --> M
    M -->|"uses it by reference"| C
    V -.-> C

    classDef secret fill:#fdf1ea,stroke:#c2410c,stroke-width:1.5px,color:#17181c
    classDef ref fill:#eef7f5,stroke:#0f766e,stroke-width:1.5px,color:#17181c
    classDef tool fill:#f2f1ff,stroke:#4338ca,stroke-width:1.5px,color:#17181c

In classic mode, a hook can only allow or block, so it blocks and hands the rewrite back on your clipboard:

flowchart TD
    A["you paste<br/><b>sk_live_51H8xR…</b>"]:::secret
    B{"clowk scans it<br/>before it is sent"}:::tool
    X["🔒 turn blocked<br/>the model gets nothing"]:::blocked
    V[("🗄️ ~/.clowk/vault.json<br/>0600 · your only copy")]:::secret
    P["📋 your message, rewritten<br/><b>$STRIPE_SECRET_KEY</b><br/>on your clipboard"]:::ref
    M["🤖 the model"]:::ref

    A --> B
    B -->|"nothing found"| M
    B -->|"credential found"| X
    X -->|"files the value"| V
    X --> P -->|"you repaste"| M

    classDef secret fill:#fdf1ea,stroke:#c2410c,stroke-width:1.5px,color:#17181c
    classDef ref fill:#eef7f5,stroke:#0f766e,stroke-width:1.5px,color:#17181c
    classDef tool fill:#f2f1ff,stroke:#4338ca,stroke-width:1.5px,color:#17181c
    classDef blocked fill:#fdf0ef,stroke:#b42318,stroke-width:1.5px,color:#17181c

Why two shapes? No settings hook can rewrite a prompt you have already submitted — verified on Claude Code, Codex and Gemini CLI. A hook may block or allow, and that is the entire API. Claude Code 2.1.287 added mods, which can rewrite, so on Claude Code the plugin does. Everywhere else block-and-repaste is the only shape there is, and the clipboard is what keeps it tolerable.

Detection

221 gitleaks rules plus three of clowk's own: one for credential-shaped tokens standing alone, and one for each connection-string dialect.

  • Connection strings are captured whole. postgresql://user:pw@host/db files as $DATABASE_URL, so your hostname and database name do not travel either. The key=value; dialect used by Microsoft SDKs and ODBC drivers gets the same treatment — an Azure storage string files as $AZURE_STORAGE_CONNECTION_STRING rather than having its AccountKey swapped while AccountName=prodstore rides along.
  • Placeholders are left alone. changeme, <your-account-key>, and values that are already references such as $DB_PASS.
  • At most 20 names per message. More hits than that is a pasted log, not a paste of credentials. The rest are still redacted and the turn is still blocked; they are just not filed.

Naming

The $NAME comes from whatever identified the credential:

CaseName
A vendor rule matchedits own name — $STRIPE_SECRET_KEY
Caught only because of a label you typedthat label — secret access key = … → $SECRET_ACCESS_KEY
No label anywhere near it$SECRET
Name taken, same valuereused; the directory is added to the ledger
Name taken, different valuesuffixed — $NAME_2; nothing is overwritten

Using a credential

A captured value is not in your agent's environment — echo $DATABASE_URL prints nothing, by design. Substitute it at the point of use:

psql "$(clowk get DATABASE_URL)"
curl -H "Authorization: Bearer $(clowk get STRIPE_SECRET_KEY)" https://api.stripe.com/v1/charges
flowchart TD
    A["🤖 the agent runs a command<br/>that substitutes <b>clowk get NAME</b>"]:::ref
    S["your shell expands it"]:::tool
    V[("🗄️ vault")]:::secret
    D["🐘 the database gets<br/>the real value"]:::secret
    M["🤖 the model sees only<br/>the command it wrote"]:::ref

    A --> S
    V -->|"the value, never printed"| S
    S --> D
    A -.-> M

    classDef secret fill:#fdf1ea,stroke:#c2410c,stroke-width:1.5px,color:#17181c
    classDef ref fill:#eef7f5,stroke:#0f766e,stroke-width:1.5px,color:#17181c
    classDef tool fill:#f2f1ff,stroke:#4338ca,stroke-width:1.5px,color:#17181c

The shell hands the value to the command as an argument. Nothing wraps your command, so your host's own permission rules still match what you actually ran.

clowk get is the only thing that prints a credential, so it is guarded. A second hook refuses a bare clowk get, a substitution piped into echo/cat/printf, a redirect, and capture into a shell variable — each of those would put the value back in your transcript. The check lives in the hook rather than in clowk get because a process cannot tell whether it was command-substituted.

Your agent learns this from a skill setup installs, pointed at from a session's first block so the rule arrives with the $NAME it governs. What each host gets:

HostHooksSkill/clowk
Claude CodeUserPromptSubmit + PreToolUsefrom the plugin, else ~/.claude/skills/clowk/yes
CodexUserPromptSubmit + PreToolUse~/.codex/skills/clowk/no
Gemini CLIBeforeAgent + BeforeToolnone — it has no skills directory, so setup says so rather than inventing a pathno

Every clowk get is recorded, so clowk uses tells you where a credential was caught and what has drawn on it since. Re-catches are counted too: clowk list will tell you a key has been caught five times and when the last one was. That number is worth watching — pasting the same credential repeatedly is a habit, and clowk add is how you stop.

Commands

CommandDescription
clowk listStored credentials — names and metadata, never values
clowk add NAMEType a credential at the terminal instead of pasting it in chat
clowk get NAMEPrint one, for command substitution only
clowk set NAMEReplace a value after rotating it upstream
clowk clear NAMEForget one
clowk rename OLD NEWRename one
clowk uses [NAME]Where a credential was caught, and what has drawn on it
clowk allow PATTERNStop denying one of clowk's rules — a filename, suffix or command phrase, exactly as the deny message prints it
clowk deny PATTERNUndo an allow
clowk updateFetch new code, then refresh the skill and command that do not move on their own. --check looks without changing
clowk setupGuided first-time setup: detect hosts, install, then verify the guard actually blocks. --hosts a,b, --yes, --dry-run for unattended use
clowk install [HOST]Register hooks for one host
clowk uninstall [HOST]Remove them, then decide about the vault. --backup FILE, --purge, --keep-vault
clowk --versionThe installed version
clowk debug-payloadDump what a host sends, for adding a new one
clowk rewriteWhat the Claude Code plugin runs on each prompt: reads {"prompt": …} on stdin, files what it finds, prints the rewritten text as JSON

add and set never take the value as an argument — that would put it straight in your shell history.

After a rotation, use set. Paste a replacement for a credential clowk already holds and nothing is overwritten: the new value files as $NAME_2 while $NAME still resolves to the revoked one. The block message says so and prints the clowk set NAME that moves the name a

Source 1 files
hooks/register.js 203 lines
1// clowk as a Claude Code mod (Claude Code v2.1.287+).
2//
3// A settings hook can only block a prompt, which is why clowk's flow everywhere else is block,
4// copy a rewrite to the clipboard, repaste. A mod's `prompt.submit` can REWRITE it, so here the
5// credential is replaced by its $NAME in place and the prompt goes on: nothing to repaste. The same
6// goes for what a command prints: `tool.call` scrubs a command's output before the model reads it.
7//
8// Detection and filing stay in Python (`clowk rewrite`), so there is one detector and one vault
9// writer. Text goes to it on stdin, never in argv, where `ps` would show it. ~85 ms a call, measured
10// (interpreter start plus compiling the ruleset) -- small next to a model turn, so there is no
11// JavaScript pre-filter that could disagree with the detector and let something through.
12//
13// Fails CLOSED. A mod hook that throws or times out is skipped by default, which would send the
14// credential, so every failure -- clowk missing, a non-zero exit, output that isn't the JSON it
15// should be, a timeout -- ends in a refusal, and `.catch` refuses for anything that escapes.
16// `unclowk` at the start of a message is the deliberate way through, checked here so it works even
17// when Python can't run.
18//
19// One failure is not closed: a clowk too old to know `rewrite`. The plugin and the Python package
20// update separately, so that skew is ordinary, and the old package's settings hooks are still
21// registered and still block. So the mod steps aside, the old block-and-repaste flow handles it,
22// and the status line says to run `clowk update`.
23//
24// The settings hooks `clowk setup` registers stay as the backstop. They run beneath the mod chain
25// and see the rewritten text, so they pass it; they still block when the mod isn't running
26// (--bare, --safe-mode, a policy that refuses user mods, a crashed plugin worker).
27
28const BYPASS = /^\s*unclowk/i
29// Turns Claude Code starts on the model's behalf: a subagent's hand-back, a background task's
30// notice, another session's message. Whatever credential is in one came out of a model or a tool,
31// so the model has had it already; rewriting only files junk in the vault, and Claude Code stores a
32// peer hand-back as delivered anyway (measured, 2.1.295). The settings hook skips the same turns by
33// their `source: "system"`. What a person wrote -- the composer, Remote Control, the SDK, a channel,
34// a schedule -- is checked.
35const MACHINE_AUTHORED = new Set([
36  'task-notification', 'peer', 'peer-send-message', 'projects-relay', 'coordinator', 'observer', 'observer-activity',
37])
38// Read is left out on purpose: an agent edits what it reads, and a file read back with `$NAME` in it
39// and written out whole would replace the real value with the name.
40const SCRUBBED_TOOLS = ['Bash', 'BashOutput', 'Grep', 'WebFetch', 'WebSearch']
41const DETECT_TIMEOUT_MS = 20000
42// Joins a tool result's strings for one detector call. A NUL never appears in a credential, so no
43// finding can span two of them.
44const SEP = '\n\u0000clowk\u0000\n'
45
46const UNSENT = 'clowk could not check this message for credentials, so it was not sent. '
47const FIX = {
48  missing: 'Is `clowk` installed and on your PATH? Run `clowk setup` to check. ',
49  broken: 'Run `clowk setup` to check the install. ',
50}
51const OVERRIDE = 'To send this message unchecked, start it with  unclowk'
52const WITHHELD = 'clowk could not check this output for credentials, so it was withheld. Run `clowk setup` to check the install.'
53// Claude Code draws a plugin's status line as a notice -- "⚠ clowk: <text>" -- so it is only used
54// when something needs doing. An armed guard says nothing there; the note under a rewritten message
55// is where it shows itself.
56const STATUS = {
57  ok: undefined,
58  old: 'older than its plugin, so credentials are blocked, not rewritten · run `clowk update`',
59  missing: 'not found on PATH, so messages are held back · install clowk, or start a message with unclowk',
60}
61const TEAL = '#14b8a6'
62const ROW_BACKGROUND = 'userMessageBackground'   // Claude Code's theme key for a user message row
63
64class TooOld extends Error {}
65
66// Module state: it resets on a reload, and a resumed session's old rows simply draw plainly.
67const rewrote = new Map()   // rewritten prompt -> the names put into it, for the message row
68
69export function register(on) {
70  on('session.start', async ($, e, next) => {
71    const started = await next(e)
72    $.ui.status(STATUS[await health($)])
73    return started
74  })
75  on('prompt.submit', guard).catch(async ($, e, next) => {
76    // Already passed on: that result stands, and nothing runs twice.
77    if (next.called) return next(e)
78    return { drop: UNSENT + FIX.broken + OVERRIDE + ' (' + next.error.kind + ')' }
79  })
80  // Anything that escapes scrub withholds the output rather than handing it over unchecked.
81  on('tool.call', { tool: SCRUBBED_TOOLS }, scrub).catch(async () => ({ deny: WITHHELD }))
82  on('tool.call', { tool: /^mcp__/ }, scrub).catch(async () => ({ deny: WITHHELD }))
83  // Claude Code's row, redrawn with the $NAMEs in teal, and one line under it saying what happened.
84  // A rewrite that happens silently reads like magic, or like a bug.
85  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
86    const names = rewrote.get(e.props.text)
87    if (!names) return next(e)
88    const { Box, Text } = $.ui.resolve(e)
89    return Box({ flexDirection: 'column', children: [message(Box, Text, e.props.text, names), note(Box, Text, names)] })
90  })
91}
92
93async function guard($, e, next) {
94  if (BYPASS.test(e.text) || MACHINE_AUTHORED.has(e.origin?.kind)) return next(e)
95  let result
96  try {
97    result = await detect($, e.text)
98  } catch (err) {
99    if (err instanceof TooOld) return next(e)   // the old settings hook handles it
100    return { drop: UNSENT + (err.missing ? FIX.missing : FIX.broken) + OVERRIDE }
101  }
102  if (result.names.length === 0) return next(e)
103  rewrote.set(result.text, result.names)
104  // The pointer is for the model only: the user's message stays what they typed, minus the value.
105  if (!result.pointer) return next({ ...e, text: result.text })
106  return next({ ...e, text: result.text, context: [...(e.context ?? []), result.pointer] })
107}
108
109async function scrub($, e, next) {
110  const ran = await next(e)
111  if (!ran || ran.deny || ran.result === undefined) return ran
112  const leaves = []
113  collect(ran.result, leaves)
114  if (leaves.length === 0) return ran
115  let result
116  try {
117    result = await detect($, leaves.join(SEP), 'output')
118  } catch (err) {
119    if (err instanceof TooOld) return ran
120    return { deny: WITHHELD }
121  }
122  if (result.names.length === 0) return ran
123  const parts = result.text.split(SEP)
124  if (parts.length !== leaves.length) return { deny: WITHHELD }
125  const scrubbed = { result: replace(ran.result, parts, { i: 0 }) }
126  return result.pointer ? { ...scrubbed, context: [result.pointer] } : scrubbed
127}
128
129// `clowk rewrite` on one text. `kind` "output" (a command's output) skips the two generic rules,
130// which in output match hashes and ids; see rewrite() in hook_prompt.py. Throws TooOld, or an Error with `missing` set when clowk
131// can't start.
132async function detect($, text, kind = 'prompt') {
133  let run
134  try {
135    run = await $.process.run(['clowk', 'rewrite'], {
136      stdin: JSON.stringify({ prompt: text, kind, cwd: await $.session.cwd(), session_id: await $.session.id() }),
137      timeoutMs: DETECT_TIMEOUT_MS,
138    })
139  } catch (err) {
140    throw Object.assign(new Error('clowk did not run'), { missing: true })
141  }
142  if (run.exitCode !== 0) {
143    if (run.stderr.includes('Unknown command')) throw new TooOld('clowk has no rewrite')
144    throw new Error('clowk rewrite failed')
145  }
146  const result = JSON.parse(run.stdout)
147  if (typeof result.text !== 'string' || !Array.isArray(result.names)) throw new Error('unexpected output')
148  return result
149}
150
151async function health($) {
152  try {
153    await detect($, '')
154    return 'ok'
155  } catch (err) {
156    return err instanceof TooOld ? 'old' : 'missing'
157  }
158}
159
160function collect(value, leaves) {
161  if (typeof value === 'string') leaves.push(value)
162  else if (Array.isArray(value)) value.forEach((v) => collect(v, leaves))
163  else if (value && typeof value === 'object') Object.values(value).forEach((v) => collect(v, leaves))
164}
165
166// Rebuilds `value` with its strings, in `collect` order, taken from `parts`. Never mutates.
167function replace(value, parts, at) {
168  if (typeof value === 'string') return parts[at.i++]
169  if (Array.isArray(value)) return value.map((v) => replace(v, parts, at))
170  if (value && typeof value === 'object') {
171    return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, replace(v, parts, at)]))
172  }
173  return value
174}
175
176function message(Box, Text, text, names) {
177  const pattern = new RegExp('(\\$(?:' + names.map(escapeRe).join('|') + ')(?![A-Za-z0-9_]))')
178  // The row's own background, as Claude Code paints it: its renderer skips spaces by moving the
179  // cursor, so a row drawn without one shows the old row's background between the words.
180  const lines = text.split('\n').map((line, i) => Text({
181    backgroundColor: ROW_BACKGROUND,
182    children: [Text({ dimColor: true, children: [i === 0 ? '❯ ' : '  '] }), ...line.split(pattern).map((part, j) =>
183      j % 2 === 1 ? Text({ color: TEAL, bold: true, children: [part] }) : part)],
184  }))
185  return Box({ flexDirection: 'column', children: lines })
186}
187
188function note(Box, Text, names) {
189  return Box({
190    flexDirection: 'row',
191    paddingLeft: 2,
192    children: [
193      Text({ color: TEAL, bold: true, children: ['🔒 clowk  '] }),
194      Text({ color: TEAL, children: [names.map((n) => '$' + n).join(', ')] }),
195      Text({ dimColor: true, children: [' stayed on this machine · the model gets the name, never the value'] }),
196    ],
197  })
198}
199
200function escapeRe(s) {
201  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
202}
203