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.

<img src="https://raw.githubusercontent.com/Aniumbott/clowk/main/assets/logo.svg" alt="" width="96" height="96">
Catches credentials you paste into Claude Code and swaps them for a name before the model sees them.
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.
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.
| Quick start | Install in two commands |
| In Claude Code | What the plugin does, and how it fails |
| Classic mode | Codex, Gemini CLI, and Claude Code before 2.1.287 |
| What it is good for | Beyond catching a mistake |
| Uninstalling · Updating | The vault decision, and keeping the skill current |
| How it works | The capture path and what gets detected |
| Using a credential | $(clowk get NAME) and the guard around it |
| Commands | The full CLI |
| The tool-deny hook | For leaks you did not type |
| Architecture | Everything on one diagram |
| Limitations | What this does not protect you from |
| Configuration | Paths and environment variables |
| False positives | Why they happen and how to clear them |
| Contributing | How to help |
| Security | Reporting a vulnerability |
| Development | Tests, layout, the ruleset |
uv tool install clowk # or: pipx install clowk
clowk setup
That's it. clowk setup:
$NAME is,claude plugin,Restart Claude Code afterwards so it loads the plugin.
pip install clowkwill 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.
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 happens | Detail |
|---|---|
| You paste a credential | It 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 it | It 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 one | What 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 looking | The tool-deny hook refuses cat .env, private keys, git credential fill and the vault itself. |
| A subagent reports back | Turns 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.
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.--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.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.
| Host | Status |
|---|---|
| Claude Code 2.1.287+ | full: the plugin rewrites in place |
| Claude Code before 2.1.287, or without the plugin | classic, verified |
| Codex | classic, verified. Hooks need trust: run /hooks and approve clowk; trust is hash-based, so every update asks again |
| Gemini CLI | classic; the hooks are registered, but its payload shape is not verified (clowk debug-payload shows what it sends) |
| Antigravity, opencode, Grok CLI | not supported yet |
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 for | How |
|---|---|
| Credentials that survive across sessions | clowk 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 environment | A 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 commit | psql "$(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 CLIs | Claude Code, Codex and Gemini CLI all read the same ~/.clowk/vault.json. One credential store instead of three sets of environment variables. |
| Rotation without archaeology | clowk 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 habit | Re-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 leaks | The 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 dumps | Credentials 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.
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 uninstallbeforepip uninstall clowkorpipx 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.
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.
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.
221 gitleaks rules plus three of clowk's own: one for credential-shaped tokens standing alone, and one for each connection-string dialect.
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.changeme, <your-account-key>, and values that are already references such as $DB_PASS.The $NAME comes from whatever identified the credential:
| Case | Name |
|---|---|
| A vendor rule matched | its own name — $STRIPE_SECRET_KEY |
| Caught only because of a label you typed | that label — secret access key = … → $SECRET_ACCESS_KEY |
| No label anywhere near it | $SECRET |
| Name taken, same value | reused; the directory is added to the ledger |
| Name taken, different value | suffixed — $NAME_2; nothing is overwritten |
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:
| Host | Hooks | Skill | /clowk |
|---|---|---|---|
| Claude Code | UserPromptSubmit + PreToolUse | from the plugin, else ~/.claude/skills/clowk/ | yes |
| Codex | UserPromptSubmit + PreToolUse | ~/.codex/skills/clowk/ | no |
| Gemini CLI | BeforeAgent + BeforeTool | none — it has no skills directory, so setup says so rather than inventing a path | no |
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.
| Command | Description |
|---|---|
clowk list | Stored credentials — names and metadata, never values |
clowk add NAME | Type a credential at the terminal instead of pasting it in chat |
clowk get NAME | Print one, for command substitution only |
clowk set NAME | Replace a value after rotating it upstream |
clowk clear NAME | Forget one |
clowk rename OLD NEW | Rename one |
clowk uses [NAME] | Where a credential was caught, and what has drawn on it |
clowk allow PATTERN | Stop denying one of clowk's rules — a filename, suffix or command phrase, exactly as the deny message prints it |
clowk deny PATTERN | Undo an allow |
clowk update | Fetch new code, then refresh the skill and command that do not move on their own. --check looks without changing |
clowk setup | Guided 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 --version | The installed version |
clowk debug-payload | Dump what a host sends, for adding a new one |
clowk rewrite | What 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_2while$NAMEstill resolves to the revoked one. The block message says so and prints theclowk set NAMEthat moves the name a
hooks/register.js 203 lines1// 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