Client-side secret/PII redaction hooks: keep API keys and sensitive identifiers out of Claude Code's model context by redacting Read/Bash/Grep tool output…

Client-side Claude Code hooks that keep secrets and sensitive identifiers out of what Claude Code persists locally.
Honmoon's proxy covers the wire: agent clients resend the full conversation each turn, so a secret the proxy detects is re-redacted on every turn — the model and provider see the placeholder, not the raw value. What the proxy cannot reach is what the client writes to disk before sending: Claude Code stores raw prompts and raw tool output in its session transcript (~/.claude/projects/<project>/<session-id>.jsonl), which then feeds /resume, compaction summaries, subagents, and any backup/sync of that directory.
This plugin closes that gap at the client. It is complementary to the proxy, not a replacement:
| Hook | Event | Behavior |
|---|---|---|
| Redact tool output | PostToolUse (Read, Bash, Grep) | Scans the tool result and replaces every detected secret/PII surface with a stable placeholder via updatedToolOutput, so the redacted form is what enters the model context. Bash and Grep are matched too, not just Read: a secret surfaced by cat/grep/echo lands in the same local transcript and never touches the proxy for that local copy. |
| Block risky prompts | UserPromptSubmit | A hook cannot rewrite a prompt, so a prompt carrying a secret (or a high-severity identifier like an RRN) is blocked with an actionable reason. Remove the value and resubmit. |
| Deny sensitive reads | PreToolUse (Read) | Denies reads of known credential/key files (.env*, *.pem, *.key, id_rsa/id_ed25519, ~/.aws/credentials, …) before the file is opened — so their plaintext never reaches the transcript. Template files (.env.example) are allowed. |
All three call the same engine (honmoon hook), which reuses the exact Tier-1 detectors and tokenizer from honmoon-core — the crate that also backs the proxy. On this client path the redaction is one-way (there is no reverse substitution; the tokenizer's mapping is not shared with a proxy today). What carries over is determinism: placeholders are byte-stable for a given secret within a session, so re-redacting resent history keeps a provider's prompt cache prefix intact.
The plugin is a thin shell around the honmoon binary — install it and put it on PATH:
cargo install --path crates/honmoon-cli # from a checkout of the honmoon repo
# or: cargo build --release && add target/release to PATH
honmoon --help # sanity check
If honmoon is not found, every hook is a deliberate no-op (it exits 0 with no output): the tool call / prompt proceeds unredacted and the proxy remains the backstop. Point the hooks at a specific binary with the HONMOON_BIN env var.
Point Claude Code at this directory (packages/claude-plugin/) as a local plugin (once the repo publishes a plugin marketplace, you'll be able to install from there instead). See Claude Code plugins. Once installed, /hooks should list the three honmoon hooks.
The manifest (.claude-plugin/plugin.json) deliberately has no hooks key: Claude Code loads hooks/hooks.json automatically, and on 2.1.263 a manifest entry pointing at that same file is reported as a duplicate and logged as a hook-load failure (manifest.hooks should only reference additional hook files). Do not add it back.
# Redacts an Anthropic key in Read output → updatedToolOutput with a placeholder:
printf '{"hook_event_name":"PostToolUse","tool_name":"Read","tool_response":"API_KEY=REDACTED-ANTHROPIC-KEY-BY-SLOPSHOPPER"}' | honmoon hook
# Denies a Read of .env:
printf '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/proj/.env"}}' | honmoon hook
# Blocks a prompt carrying a secret:
printf '{"hook_event_name":"UserPromptSubmit","prompt":"deploy with REDACTED-ANTHROPIC-KEY-BY-SLOPSHOPPER"}' | honmoon hook
PostToolUse updatedToolOutput is documented to replace what the model sees; the docs do not spell out that the persisted transcript (~/.claude/projects/<project>/<session-id>.jsonl) stores the redacted value. That was verified empirically against Claude Code 2.1.263 (2026-09-07, issue #49): a headless session with this plugin loaded read a file carrying a valid-checksum RRN and an Anthropic-shaped API key, and the session .jsonl contained zero occurrences of either raw value. The placeholders appear in every place the tool output is persisted — the tool_result block the model sees, the toolUseResult.file.content field Claude Code keeps for /resume, and the hook_success record that logs the hook's own stdout. A control run of the same prompt without the plugin persisted both raw values, so the fixture would have been transcribed without the hook.
Two things remain version-dependent, so re-run the check below when Claude Code changes:
tool_response) is not persisted today, but that is an implementation detail of Claude Code, not a documented guarantee.PreToolUse deny stays the guaranteed path: the file is never read, so there is nothing to rewrite.To re-verify against your Claude Code version:
(
set -e # a failed step must never fall through to the cleanup below
PROBE=$(mktemp -d /tmp/hm-probe-XXXXXX)
cd "$PROBE" && git init -q
printf 'rrn: 670125-1230644\nkey=REDACTED-ANTHROPIC-KEY-BY-SLOPSHOPPER\n' > notes.txt
SESSION_ID=$(claude -p --plugin-dir /path/to/honmoon/packages/claude-plugin \
--allowedTools Read --output-format json \
'Read notes.txt and reply with its contents verbatim.' | jq -r .session_id)
# The project directory is named after the *canonical* cwd, which is
# platform-dependent (macOS resolves /tmp to /private/tmp), so find the
# transcript by session id rather than by a hardcoded slug.
set -- ~/.claude/projects/*/"$SESSION_ID".jsonl
TRANSCRIPT=$1
if [ ! -f "$TRANSCRIPT" ]; then
echo "FAIL — no transcript found for session $SESSION_ID"
exit 1
fi
# Assert a placeholder reached each of the three places the tool output is
# persisted — counting occurrences would also be satisfied by three copies
# in one of them — and that neither raw fixture survived anywhere.
if jq -s -e '
any(.[]; any(.message.content[]?;
.type == "tool_result" and (.content | tostring | contains("<<hs:"))))
and any(.[]; (.toolUseResult.file.content? // "") | contains("<<hs:"))
and any(.[]; .attachment.type? == "hook_success"
and ((.attachment.stdout? // "") | contains("<<hs:")))
' "$TRANSCRIPT" > /dev/null \
&& ! grep -q -e 670125-1230644 -e sk-ant-api03 "$TRANSCRIPT"
then
echo "PASS — every persisted copy was redacted"
# Both paths belong to this probe alone. A control run without the plugin
# stores the raw fixture, so clean that one up the same way.
rm -rf -- "$PROBE" "${TRANSCRIPT%/*}"
else
echo "FAIL — keeping $PROBE and $TRANSCRIPT for inspection"
exit 1
fi
)
Claude Code 2.1.263 ships a prototype function hooks API behind CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1: a plugin may name a TypeScript module in hooks/hooks.json and register hooks that wrap the tool chain in-process. This plugin ships one — hooks/honmoon.ts — beside the command hooks above. The API is pre-release and may change between Claude Code releases.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/honmoon/packages/claude-plugin
Without the flag the module is ignored and only the command hooks run, so the plugin works unchanged on older Claude Code versions.
| Command hooks | Function-hooks module | |
|---|---|---|
| Prompts | Blocked — a command hook cannot rewrite a prompt | Rewritten: the redacted prompt is submitted, with a context note telling the model values were replaced. It is dropped only when the engine is unreachable |
| Prompt PII floor | Severity 3 (high) — handle_user_prompt_submit | Severity 2 — the prompt is scanned as tool output, so medium-severity PII (email, phone) is rewritten too |
| Tool output | Read, Bash, Grep | Read, Bash, Grep and WebFetch |
| Engine unreachable | Fails open (the tool call proceeds unredacted) | Fails closed: the tool result is denied (honmoon: redaction engine unavailable (…); tool output withheld) and the prompt is dropped. Set failMode: "open" for the old behavior |
| Degradation the audit log refused | systemMessage on the hook response, shown to you | The same line, shown as a $.ui.log transcript line; the verdict beside it is applied unchanged |
| Transport | honmoon hook subprocess | honmoon hook subprocess, or HTTP to the management API |
Only the Read result variants the detectors can read are rewritten: text and notebook (its cells are plain JSON). An image or PDF record is base64 bytes and is passed through untouched, as is a denied tool result. An errored result (a non-zero Bash exit whose stderr holds a key, say) is scanned too: because a hook cannot return its own isError, a redacted error goes back as a deny carrying the redacted text, which the model reads as the tool's error.
Configure with /plugin configure honmoon-redact, or in settings.json under pluginConfigs["honmoon-redact"].options (user, --settings or managed settings — project settings are not read):
| Option | Default | Meaning |
|---|---|---|
transport | process when hookUrl is unset | process runs honmoon hook; http POSTs the same JSON to hookUrl |
honmoonBin | honmoon | The binary the process transport runs (command name or absolute path). Note this is a plugin option, not the command hooks' HONMOON_BIN env var — set both if honmoon is off PATH |
hookUrl | — | Management-API endpoint, e.g. http://127.0.0.1:7777/api/hooks/claude-code. Setting it selects the http transport unless transport says otherwise |
hookToken | — | Bearer token for hookUrl. Required as of honmoon 0.1.0: the gateway authenticates every /api route, minting a token at ~/.honmoon/mgmt-token when --mgmt-token is unset. Without it the endpoint answers 401, which the http transport treats as a failure and resolves through failMode |
failMode | closed | closed denies tool output / drops the prompt when the engine is unreachable; open passes through |
Each hook phase runs under its own 8 s budget, inside the host's 10 s per-hook limit. The host budgets only the hook's own work, not the time the tool spends inside next(), and the module mirrors that: the session lookups and the PreToolUse check share one budget before the tool runs, and the PostToolUse redaction gets a fresh one after it, so a slow tool never denies its own redaction. Whatever is still pending when a budget runs out fails closed instead of running past the host and being skipped. Every failure path (spawn error, timeout, non-zero exit, unparseable stdout, HTTP error, a rejected session lookup, a transport of http without a hookUrl) is caught: the hook itself never throws.
With the flag on, the command hooks and the module both fire on the same tool call. This is harmless: placeholders are keyed by the session salt, so the command hook redacts first (it runs beneath the module, inside its next()) and the module then finds nothing left to redact and hands back the result it was given, verbatim. Verified on 2.1.263 — the module's engine call returns an empty verdict and the transcript carries one set of placeholders. Drop the hooks key from hooks/hooks.json to run the module alone.
That holds for transport: "http" as well when both transports derive from the same machine key — the per-machine random secret that keys the HMAC behind every placeholder, read from ~/.honmoon/hook-salt whenever that file is usable (see the fallback note below for when it is not). What has to match is the key bytes; where they are stored only matters in so far as it decides which bytes each process gets. Two processes on one host reading one $HOME/.honmoon/hook-salt read the same bytes, which is how the co-located deployment the hookUrl example above describes satisfies it. Both transports then derive the salt from the payload's session_id under that shared key, so one secret mints one <<hs:…>> token per session whichever layer saw it: a Bash result redacted by the command hook and a WebFetch result redacted by the module carry the identical placeholder (#98). A gateway started with --hook-salt-context is the exception — that pins the endpoint to the given context instead of the session, so either leave it unset or pin the command hooks to the same value (honmoon hook --salt-context, or HONMOON_HOOK_SALT_CONTEXT in their environment).
Known limitation — different key bytes break parity. Each process reads its own $HOME/.honmoon/hook-salt, so anything that leaves those two reads holding different bytes breaks the parity above. It happens on separate hosts; in a container with its own filesystem, where an identical HOME path still names a different file; under a different user; and for the same user whenever HOME differs — a service unit with its own Environment=HOME=, or a sudo that resets it. (A process with no HOME reads a .honmoon relative to its working directory.) Those are examples of one condition, not a list to check off: different key bytes, so the same session_id mints different placeholders. Matching --hook-salt-context values do not close any of them — the context is mixed into an HMAC the machine key keys, so mismatched keys stay mismatched. Provisioning one key to both sides is what closes them, and it costs something: see "Getting one key onto both sides" below, which describes both.
When the salt file is unusable, the key is not secret. The loader only fails when it has to mint a new salt and cannot — no readable /dev/urandom, or a ~/.honmoon it cannot create or write (read-only filesystem, unwritable HOME). honmoon hook then prints using fallback salt (…) to stderr and keys the HMAC with a constant compiled into the binary and published in this repository's source. Placeholders on that path are still stable and still restore, but they are no longer keyed by anything private: anyone can mint the placeholder a guessed secret would produce in a given session and check it against a redacted transcript, so redaction stops hiding which secrets a transcript contains. Note the direction — falling back improves parity rather than breaking it, because two processes that both fail share the same public constant and agree, so the parity above holds while the property it is meant to protect is gone. Failing open here is deliberate — a hook that hard-fails breaks the agent it runs inside — and #131 tracks whether that is the right default for this key specifically.
Make that degradation visible. Do not rely on the stderr line: a hook runs non-interactively, so it usually reaches nobody. Point the hooks at the audit log instead, by exporting HONMOON_AUDIT_LOG in the agent's environment (the plugin's dispatcher runs honmoon hook with no arguments, so the flag form honmoon hook --audit-log <file> is only useful when invoking it by hand):
export HONMOON_AUDIT_LOG=honmoon-audit.jsonl # the file `honmoon gateway --audit-log` writes
Each degraded derivation then appends a "decision":"degraded" event per degradation, naming the transport and what the loader observed — usually one, and two where a single derivation owes a record about the key in use and about a key it replaced. Only degradations are written from the hook, never per-invocation verdicts, so a healthy host leaves the file untouched: an event appearing there at all is the signal. If the configured file refuses the record — a symlink or FIFO planted at that path, an unwritable directory — the hook carries the same rule / key_source / reason back to Claude Code as a systemMessage, which is shown to you and not added to the model's context (the function-hooks module below shows it as a $.ui.log transcript line, which is likewise not sent to the model). That message is the only trace of that degradation, so treat one as a reason to fix the log path (#165).
Two independent things can be wrong with the key, so rule says which one this event is about — and the exposure half splits again, because a window still open and a window already closed need opposite responses. A fourth rule is not about the key in use at all, but about the one it replaced:
rule | What went wrong | What to do |
|---|---|---|
hook-salt-fallback | the key in use is not the persisted one; key_source says what that cost | fix what stopped the loader reading or writing ~/.honmoon/hook-salt |
hook-salt-exposed | the key is the persisted one, but its file is readable by other local users and the loader could not restrict it to 0600 | tighten the file — the loader already tried and could not |
hook-salt-was-exposed | the key is the persisted one, and the loader found its file readable by other local users, then did not see it that way after restricting it | rotate, per the suspicion rule below — the mode is no longer the problem. Where the reason says the mode could not be read back, check the file is 0600 first: the correction is unconfirmed there |
hook-salt-replaced-unread | the loader discarded a salt file it could not read, so what it held — and who could read that — is unknown | usually nothing to fix on the file: the loader already rotated. Find out why the file was unreadable, and read the reason's mode: one letting other local users read it means treat every placeholder minted before this event as forgeable; owner-only, execute-only, or a mode that could not be read leaves it open either way. If it repeats, see "It does not always fire once" below — the key is being rotated every invocation |
The sink can also report on itself. Opening it reads the file's mode, owner and link count, and anything beyond an owner-only file with one name that this process owns is appended as a degraded event with facts.sink (path, reason) — before the salt event, in the same file. Nothing is corrected: the path is yours, and a group-readable log may be one a log shipper reads on purpose (issue #161).
rule | What was observed | What to do |
|---|---|---|
audit-sink-exposed | the file's mode admits local users other than its owner — every deployment that created its log before issue #138 has this at the umask default | chmod 600 it, unless the mode is deliberate |
audit-sink-foreign-owner | the file is owned by another uid, so honmoon did not create it | point --audit-log at a file you own, unless an administrator provisioned this one |
audit-sink-hard-linked | more than one directory entry names the file's inode, so every record also lands under a name you did not configure | find the other name (find <dir> -samefile <log>) and move the log to a directory only you can write |
The hook opens the sink only when it has a degraded key to report, so these appear on the hook transport only alongside a hook-salt-* event; the gateway reports them at every start.
On the fallback rule, key_source says which guarantee was lost, because they are not the same failure:
key_source | Key | What is lost |
|---|---|---|
persisted | the random secret at ~/.honmoon/hook-salt | nothing about the key's provenance — under the two exposure rules the bad news is that file's mode, and under hook-salt-replaced-unread it is about a different key entirely |
unpersisted | random and private, but never reached disk | byte-stable placeholders across turns and transports (#20, #98). Still unforgeable |
fallback | the constant compiled into the binary | unforgeability, entirely — the key is published in this repository |
A salt anyone can read is a published key. ~/.honmoon/hook-salt is created 0600, and the loader re-tightens it on every read in case a backup restore or an older build left it looser. Where that chmod cannot be applied — a root-owned 0644 file, a read-only mount — the salt is adopted anyway, because refusing to redact is worse; any local user who can read it can then mint the placeholder a guessed secret would produce in a given session and check it against a redacted transcript, exactly as under a fallback key. hook-salt-exposed is what makes that visible, and its reason names the mode observed:
salt file /home/a/.honmoon/hook-salt is readable by other local users (mode 0644) and could not be restricted to 0600
Both events fire on any permission beyond the owner, not only a read bit — a salt another local user can write is a key they can replace with one they chose — and the reason names which access was observed rather than assuming the worst of them.
Tightening the mode does not un-publish the key. Where the chmod does take, the loader has closed the window going forward and learnt nothing about the one before it. (In the narrow case where the mode cannot be read back afterwards, the correction is unconfirmed and the reason says so — the event is still raised, because whether there was a window and whether it is now shut are separate questions, and the first is already answered.) Whoever the old mode admitted may already hold a copy of those bytes, and that copy still mints every placeholder from here on. hook-salt-was-exposed is that case, and its reason names both modes:
salt file /home/a/.honmoon/hook-salt was readable by other local users (mode 0644) when the loader read it and is now mode 0600
Expect this one on a healthy host, once. A restored backup, a cp -p from an old machine, a permissive umask on a file created before honmoon tightened it — each of those is a legitimate, non-malicious way to arrive at a loose salt, and each now raises an event. That is deliberate: there is no way to tell those apart from the malicious case by looking at the mode, which is why the record exists rather than a judgement. It is one event per loose-find, not one per invocation: where the correction took, the next loader sees 0600 and says nothing.
If it does repeat, read the reason before concluding anything. Two different hosts produce that. One where the reason names a mode it read back is a host where something keeps re-loosening the file — a sync agent, a cron chmod, a restore that runs on a timer — which is itself worth knowing. One where the reason says the mode could not be read back is a host where the loader read the mode, found it loose, and then could not read it back after its own correction — so it cannot confirm the correction took, and reports the window it already saw every time; there the fix is to find out why the read-back fails, not to hunt for a process changing modes.
What it does and does not attest. Both events are raised on a mode, never on the chmod returning an error — a read-only mount fails the call on a file that is already 0600, a
hooks/honmoon.ts 666 lines1// honmoon-redact — Claude Code function-hooks module (early access).
2//
3// Runs the same `honmoon` redaction engine the command hooks shell out to, but
4// from inside the hook chain, so it can (a) rewrite a prompt instead of blocking
5// it and (b) fail *closed*: a transport failure denies the tool output rather
6// than letting raw bytes through. Enable with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1;
7// see the README section "Function hooks (early access)".
8//
9// Transports (option `transport`): "process" (default) runs `honmoon hook`,
10// "http" POSTs to the management API. Both speak the hook payload/verdict JSON
11// of crates/honmoon-cli/src/hook.rs and honmoon-core's claude_code_hook.rs.
12//
13// `$` may never be passed as an argument (the host's module validator rejects
14// it), so each hook hands the shared logic small closures over its own `$`.
15import type { Hook, HttpInit, HttpResponse, MatchedHook, PluginOptions, Register } from 'claude-code'
16
17/**
18 * One budget per hook invocation, shared by the session lookups and every
19 * engine call the hook makes (a Read makes two). The host skips a hook that
20 * runs past 10 s and lets the raw result through, so the whole hook must
21 * settle before that: whatever is still pending at the budget fails closed.
22 */
23const HOOK_BUDGET_MS = 8_000
24/** Placeholder shape minted by honmoon-core's tokenizer. */
25const PLACEHOLDER = /<<hs:[^>]*>>/g
26/** Tools whose output is scanned. Read is additionally checked before it runs. */
27const TOOL_MATCHER = { tool: ['Read', 'Bash', 'Grep', 'WebFetch'] } as const
28
29type Json = Record<string, unknown>
30/**
31 * A parsed hook verdict, or why the engine could not produce one. `notice` is
32 * the verdict's `systemMessage`, lifted off it: a line for the user that
33 * decides nothing (see `parseVerdict`).
34 */
35type Answer = { ok: true, verdict: Json, notice?: string } | { ok: false, cause: string }
36interface Config {
37 bin: string
38 url: string
39 token: string
40 failClosed: boolean
41 /** A configuration the hooks must not run on; reported as the engine cause. */
42 error?: string
43}
44type Runner = (
45 argv: readonly string[],
46 init: { stdin: string, timeoutMs: number },
47) => Promise<{ exitCode: number, stdout: string }>
48type Fetcher = (url: string, init: HttpInit) => Promise<HttpResponse>
49type Sleeper = (ms: number, options?: { signal?: AbortSignal }) => Promise<void>
50type Logger = (text: string) => void
51type Ask = (payload: Json) => Promise<Answer>
52
53export function configure(options: PluginOptions = {}): Config {
54 const url = typeof options.hookUrl === 'string' ? options.hookUrl.trim() : ''
55 // Unset (the manifest declares no default for it, deliberately): `hookUrl`
56 // alone selects the http transport, as the README documents.
57 const transport = String(options.transport ?? (url ? 'http' : 'process')).trim().toLowerCase()
58 const config: Config = {
59 bin: String(options.honmoonBin ?? 'honmoon'),
60 url: transport === 'http' ? url : '',
61 token: String(options.hookToken ?? ''),
62 failClosed: String(options.failMode ?? 'closed').trim().toLowerCase() !== 'open',
63 }
64 // A transport that cannot be honoured must never fall back to another one:
65 // "http" without a URL would silently run the local binary instead.
66 if (transport !== 'http' && transport !== 'process') {
67 config.error = `unknown transport "${transport}"`
68 }
69 else if (transport === 'http' && !url) {
70 config.error = 'transport "http" needs a hookUrl'
71 }
72 return config
73}
74
75/** An empty body is the engine's documented no-op; anything else must be JSON. */
76/**
77 * The keys `honmoon hook` and the mgmt endpoint emit (Claude Code hook JSON).
78 * `systemMessage` is the common field `honmoon hook` adds when a degraded
79 * machine key could not be recorded in the audit log (honmoon issue #165): a
80 * line for the user, never a decision, so `parseVerdict` lifts it off the
81 * verdict rather than letting it read as an answer to the event.
82 */
83const VERDICT_KEYS = new Set(['hookSpecificOutput', 'decision', 'reason', 'systemMessage'])
84
85function parseVerdict(body: string, transport: 'process' | 'http'): Answer {
86 const text = body.trim()
87 if (!text) {
88 // `honmoon hook` prints nothing for a no-op; the management endpoint
89 // always serializes `{}`, so an empty HTTP body is not the engine talking.
90 return transport === 'process'
91 ? { ok: true, verdict: {} }
92 : { ok: false, cause: 'engine output is empty' }
93 }
94 let parsed: unknown
95 try {
96 parsed = JSON.parse(text)
97 }
98 catch {
99 return { ok: false, cause: 'engine output is not JSON' }
100 }
101 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
102 return { ok: false, cause: 'engine output is not a JSON object' }
103 }
104 // Only the keys a hook verdict carries. A JSON body from something other
105 // than the engine (an `{ "error": … }` from a proxy or an unhealthy
106 // endpoint answering 200) must not read as "nothing to redact".
107 const unknown = Object.keys(parsed).filter(key => !VERDICT_KEYS.has(key))
108 if (unknown.length > 0) {
109 return { ok: false, cause: `engine output is not a hook verdict (unexpected key "${unknown[0]}")` }
110 }
111 // The nested shapes too: a verdict either omits `hookSpecificOutput` or
112 // carries an object, and `updatedToolOutput`, when present, is a value.
113 const output = (parsed as Json).hookSpecificOutput
114 if (output !== undefined && (!output || typeof output !== 'object' || Array.isArray(output))) {
115 return { ok: false, cause: 'engine output is not a hook verdict (hookSpecificOutput is not an object)' }
116 }
117 if (output && (output as Json).updatedToolOutput === null) {
118 return { ok: false, cause: 'engine output is not a hook verdict (updatedToolOutput is null)' }
119 }
120 const { systemMessage, ...verdict } = parsed as Json
121 if (systemMessage === undefined) {
122 return { ok: true, verdict }
123 }
124 if (typeof systemMessage !== 'string') {
125 return { ok: false, cause: 'engine output is not a hook verdict (systemMessage is not a string)' }
126 }
127 return { ok: true, verdict, notice: systemMessage }
128}
129
130/** A lookup run under the hook budget: its value, or why it did not arrive. */
131type Guarded<T> = { ok: true, value: T } | { ok: false, cause: string }
132
133/** What the budget timer resolves to, distinguishable from any hook value. */
134class BudgetExhausted {
135 constructor(readonly cause: string) {}
136}
137
138interface Engine {
139 /** Ask the engine; every failure mode comes back as `{ ok: false }`. */
140 ask: Ask
141 /** Run any other promise under the same budget, never throwing. */
142 guard: <T>(pending: Promise<T>) => Promise<Guarded<T>>
143 /** Release the budget timer once the hook has returned. */
144 close: () => void
145}
146
147/** Start the hook's budget timer; `guard` races anything against it. */
148function startBudget(sleep: Sleeper): Pick<Engine, 'guard' | 'close'> {
149 // The timer is the hook's own; `close()` aborts it so a fast answer does not
150 // leave an 8 s timer (and this closure) pending per tool call —
151 // `$.clock.sleep` takes the signal for exactly this.
152 const abort = new AbortController()
153 const expired = sleep(HOOK_BUDGET_MS, { signal: abort.signal }).then(
154 () => new BudgetExhausted('hook budget exhausted'),
155 // Aborted by `close()`, which runs only after the hook has returned, so no
156 // race is still listening; settle anyway rather than leave a promise
157 // pending forever.
158 () => new BudgetExhausted('hook closed'),
159 )
160 const guard = async <T>(pending: Promise<T>): Promise<Guarded<T>> => {
161 // The race only reads whichever promise settles first; mark the other one
162 // handled so a late rejection is not an unhandled rejection.
163 pending.catch(() => {})
164 try {
165 const value = await Promise.race([pending, expired])
166 if (value instanceof BudgetExhausted) {
167 return { ok: false, cause: value.cause }
168 }
169 return { ok: true, value }
170 }
171 catch (error) {
172 return { ok: false, cause: error instanceof Error ? error.message : String(error) }
173 }
174 }
175 return { guard, close: () => abort.abort() }
176}
177
178/** One engine round trip over the configured transport; may throw. */
179async function transportCall(config: Config, run: Runner, fetch: Fetcher, payload: Json): Promise<Answer> {
180 if (config.error) {
181 return { ok: false, cause: config.error }
182 }
183 const body = JSON.stringify(payload)
184 if (config.url) {
185 const headers: Record<string, string> = { 'content-type': 'application/json' }
186 if (config.token) {
187 headers.authorization = `Bearer ${config.token}`
188 }
189 const response = await fetch(config.url, { method: 'POST', headers, body })
190 if (!response.ok) {
191 return { ok: false, cause: `HTTP ${response.status}` }
192 }
193 return parseVerdict(response.text, 'http')
194 }
195 const result = await run([config.bin, 'hook'], { stdin: body, timeoutMs: HOOK_BUDGET_MS })
196 if (result.exitCode !== 0) {
197 return { ok: false, cause: `${config.bin} hook exited ${result.exitCode}` }
198 }
199 return parseVerdict(result.stdout, 'process')
200}
201
202/**
203 * Bind the transport and start the hook's budget. Nothing here throws and
204 * nothing outlives the budget: a spawn error, timeout, non-zero exit, bad JSON,
205 * HTTP error, or a rejected lookup all come back as `{ ok: false }` so the
206 * caller can make the fail-closed decision while the host still listens.
207 */
208export function engineAsk(config: Config, run: Runner, fetch: Fetcher, sleep: Sleeper, log: Logger): Engine {
209 const budget = startBudget(sleep)
210 const ask: Ask = async (payload) => {
211 const answer = await budget.guard(transportCall(config, run, fetch, payload))
212 if (!answer.ok) {
213 return answer
214 }
215 // The engine's `systemMessage` is what a command hook would have shown the
216 // user; `$.ui.log` is this API's equivalent — a transcript line that is
217 // not sent to the model. It is the degradation's only trace when it
218 // arrives (the audit log refused it), so it is shown before the verdict
219 // is judged, whatever the verdict then turns out to be.
220 if (answer.value.ok && answer.value.notice !== undefined) {
221 log(answer.value.notice)
222 }
223 return answer.value
224 }
225 return { ask, ...budget }
226}
227
228function hookSpecific(verdict: Json): Json {
229 const output = verdict.hookSpecificOutput
230 return output && typeof output === 'object' ? (output as Json) : {}
231}
232
233/** The engine's `PreToolUse` deny reason, or undefined when it allowed the call. */
234function denyReason(verdict: Json): string | undefined {
235 const output = hookSpecific(verdict)
236 if (output.permissionDecision !== 'deny') {
237 return undefined
238 }
239 const reason = output.permissionDecisionReason
240 return typeof reason === 'string' && reason ? reason : 'honmoon: blocked by policy'
241}
242
243/**
244 * The redacted form of what was sent, or undefined when nothing was redacted.
245 * honmoon-core returns `updatedToolOutput` shaped exactly like what it was
246 * given, so a tool record comes back that record and a string comes back a string.
247 */
248function updatedOutput(verdict: Json): unknown {
249 return hookSpecific(verdict).updatedToolOutput
250}
251
252/**
253 * Why a verdict is not an answer to the event that was sent: it names another
254 * event, or carries keys (`foreign`) that event's verdict never has. A
255 * misrouted or unhealthy endpoint answering with the wrong verdict must not
256 * read as "nothing to redact".
257 */
258function misrouted(verdict: Json, sent: string, foreign: readonly string[]): string | undefined {
259 const output = hookSpecific(verdict)
260 // The engine always names the event it answered inside `hookSpecificOutput`.
261 if (verdict.hookSpecificOutput !== undefined && output.hookEventName !== sent) {
262 const name = typeof output.hookEventName === 'string' ? output.hookEventName : 'an unnamed event'
263 return `engine answered ${name}, not ${sent}`
264 }
265 const key = foreign.find(k => k in verdict || k in output)
266 if (key !== undefined) {
267 return `engine answered with "${key}", not a ${sent} verdict`
268 }
269 // The engine answers `{}` for a no-op and otherwise says what it decided;
270 // a verdict that is neither is not the engine talking.
271 const said = Object.keys(verdict).length === 0
272 || ('decision' in verdict)
273 || ('updatedToolOutput' in output)
274 || ('permissionDecision' in output)
275 return said ? undefined : `engine answered a ${sent} verdict that decides nothing`
276}
277
278const NOT_PRE = ['decision', 'reason', 'updatedToolOutput'] as const
279const NOT_POST = ['decision', 'reason', 'permissionDecision', 'permissionDecisionReason'] as const
280const NOT_PROMPT = ['permissionDecision', 'permissionDecisionReason'] as const
281
282/**
283 * Count the placeholders the model is about to read. Counting the *result*
284 * rather than a before/after delta keeps the number true when something else
285 * redacted first (the command hooks run inside `next()` and mint the same
286 * token shape), where a delta would be 0 and have to be faked.
287 */
288function redactionNote(after: unknown): string {
289 const count = (JSON.stringify(after ?? null).match(PLACEHOLDER) ?? []).length
290 return `honmoon: ${count} value(s) redacted with stable placeholders; treat <<hs:…>> tokens as opaque`
291}
292
293function unavailable(cause: string): string {
294 return `honmoon: redaction engine unavailable (${cause}); tool output withheld`
295}
296
297/**
298 * The engine gates `PostToolUse` redaction on `tool_name` ∈ {Read, Bash, Grep}
299 * (honmoon-core `handle_post_tool_use`). WebFetch output and prompt text reach
300 * the same content-driven redactor by being presented under `Read`, for which
301 * the field is only a gate.
302 */
303function engineToolName(tool: string): string {
304 return tool === 'Bash' || tool === 'Grep' ? tool : 'Read'
305}
306
307/**
308 * The plugin's options, applied by `register`. The host requires every hook to
309 * be a top-level function, so the configuration reaches them through module
310 * state rather than a closure; the unit tests call this before driving a hook.
311 */
312let config: Config = configure({})
313/** The session id keys the engine's placeholder salt (stable across turns). */
314let sessionId: Promise<string> | undefined
315/** The session cwd, which the engine anchors relative `file_path`s against. */
316let sessionCwd: Promise<string> | undefined
317
318export function applyOptions(options: PluginOptions = {}): void {
319 config = configure(options)
320 sessionId = undefined
321 sessionCwd = undefined
322}
323
324/**
325 * Memoize a session lookup, but never memoize a *rejection*: evict it so the
326 * next hook call retries, and let it propagate — the session id keys the
327 * engine's placeholder salt, so an empty stand-in would be a salt shared across
328 * every session that hit the failure, and placeholders would stop being
329 * unforgeable. A lookup that fails is an engine that is unavailable.
330 */
331async function once(
332 get: () => Promise<string> | undefined,
333 set: (p: Promise<string> | undefined) => void,
334 load: () => Promise<string>,
335): Promise<string> {
336 let cached = get()
337 if (!cached) {
338 cached = load()
339 set(cached)
340 }
341 try {
342 return await cached
343 }
344 catch (error) {
345 // Evict only our own promise: a concurrent call may already have stored a
346 // fresh lookup, and a late rejection must not throw that one away.
347 if (get() === cached) {
348 set(undefined)
349 }
350 throw error
351 }
352}
353
354interface SessionFacts { session_id: string, cwd: string }
355
356/**
357 * Both session facts the engine payloads carry, resolved once per session and
358 * under the hook's budget, so a hung lookup fails closed instead of eating the
359 * time the host allows.
360 */
361async function sessionFacts($: Parameters<typeof promptHook>[0], engine: Engine): Promise<Guarded<SessionFacts>> {
362 const facts = await engine.guard(Promise.all([
363 once(() => sessionId, p => (sessionId = p), () => $.session.id()),
364 once(() => sessionCwd, p => (sessionCwd = p), () => $.session.cwd()),
365 ]))
366 if (!facts.ok) {
367 return { ok: false, cause: `session lookup failed: ${facts.cause}` }
368 }
369 const [session_id, cwd] = facts.value
370 return { ok: true, value: { session_id, cwd } }
371}
372
373type ToolEvent = Parameters<typeof toolHook>[1]
374type ToolResult = Awaited<ReturnType<Parameters<typeof toolHook>[2]>>
375
376/** Ask the engine whether a Read may open the file at all; a deny, or nothing. */
377async function denyBeforeRead(engine: Engine, e: ToolEvent, facts: SessionFacts): Promise<{ deny: string } | undefined> {
378 if (e.tool !== 'Read') {
379 return undefined
380 }
381 const pre = await engine.ask({
382 hook_event_name: 'PreToolUse',
383 tool_name: 'Read',
384 tool_input: { file_path: e.file_path },
385 // The http transport resolves a relative `file_path` against this and
386 // denies the read as "unresolved" without it (honmoon-mgmt
387 // `resolve_agent_path`); the command hooks get it from the host payload.
388 cwd: facts.cwd,
389 session_id: facts.session_id,
390 })
391 if (!pre.ok) {
392 return config.failClosed ? { deny: unavailable(pre.cause) } : undefined
393 }
394 const wrong = misrouted(pre.verdict, 'PreToolUse', NOT_PRE)
395 if (wrong !== undefined) {
396 return config.failClosed ? { deny: unavailable(wrong) } : undefined
397 }
398 // The engine either denies or says nothing; any other decision value is not
399 // the engine talking (an `{}` no-op is how it permits).
400 const decision = hookSpecific(pre.verdict).permissionDecision
401 if (decision !== undefined && decision !== 'deny') {
402 return config.failClosed ? { deny: unavailable(`engine returned an unexpected shape (permissionDecision ${JSON.stringify(decision)})`) } : undefined
403 }
404 const reason = denyReason(pre.verdict)
405 return reason ? { deny: reason } : undefined
406}
407
408/** Whether a settled call carries something the detectors can read. */
409function scannable(e: ToolEvent, r: ToolResult): boolean {
410 // A refusal carries no tool record to redact; core's own message is what
411 // the model should read. An errored call is handled by `redactError`.
412 if (r.deny !== undefined || r.isError) {
413 return false
414 }
415 // Only the variants the detectors can read. An image or pdf record holds
416 // base64 bytes, and rewriting it would corrupt it; a notebook record is plain
417 // JSON cells, so it is scanned like any other text.
418 if (e.tool === 'Read') {
419 const type = (r.result as { type?: string } | undefined)?.type
420 return type === 'text' || type === 'notebook'
421 }
422 return true
423}
424
425/**
426 * Redact an errored call. A hook's own `{ result }` cannot carry `isError`, so
427 * rewriting the record would present a failed command as a success (and a
428 * string would fail core's output-schema check, which fails open). A `deny`
429 * reaches the model as an error result, so a redacted error goes out as one.
430 */
431async function redactError(engine: Engine, r: ToolResult, session_id: string): Promise<ToolResult> {
432 // The model reads `text`; the transcript stores `result`. Both are scanned
433 // in one call: the engine walks any JSON value it is given.
434 const errored: Record<string, string> = {}
435 if (typeof r.text === 'string') {
436 errored.text = r.text
437 }
438 if (typeof r.result === 'string') {
439 errored.result = r.result
440 }
441 if (Object.keys(errored).length === 0) {
442 return r
443 }
444 const post = await engine.ask({
445 hook_event_name: 'PostToolUse',
446 tool_name: 'Read',
447 tool_input: {},
448 tool_response: errored,
449 session_id,
450 })
451 if (!post.ok) {
452 return config.failClosed ? { deny: unavailable(post.cause) } : r
453 }
454 const wrong = misrouted(post.verdict, 'PostToolUse', NOT_POST)
455 if (wrong !== undefined) {
456 return config.failClosed ? { deny: unavailable(wrong) } : r
457 }
458 const updated = updatedOutput(post.verdict) as Partial<Record<'text' | 'result', unknown>> | undefined
459 if (updated === undefined) {
460 return r
461 }
462 const text = [updated.text, updated.result].find(v => typeof v === 'string')
463 if (typeof text !== 'string') {
464 return config.failClosed ? { deny: unavailable('engine returned an unexpected shape') } : r
465 }
466 return { deny: `${text}\n\n${redactionNote(updated)}` }
467}
468
469/** Redact a settled call's record; `r` itself when nothing was redacted. */
470async function redactResult(engine: Engine, e: ToolEvent, r: ToolResult, session_id: string): Promise<ToolResult> {
471 const post = await engine.ask({
472 hook_event_name: 'PostToolUse',
473 tool_name: engineToolName(e.tool),
474 tool_input: {},
475 tool_response: r.result,
476 session_id,
477 })
478 if (!post.ok) {
479 return config.failClosed ? { deny: unavailable(post.cause) } : r
480 }
481 const wrong = misrouted(post.verdict, 'PostToolUse', NOT_POST)
482 if (wrong !== undefined) {
483 return config.failClosed ? { deny: unavailable(wrong) } : r
484 }
485 const updated = updatedOutput(post.verdict)
486 // Nothing redacted: hand back exactly what `next` resolved to, so core reuses
487 // the messages it already built (`ref`/`text`).
488 if (updated === undefined) {
489 return r
490 }
491 // A record comes back a record; anything else is not the engine talking.
492 if (!sameShape(updated, r.result)) {
493 return config.failClosed ? { deny: unavailable('engine returned an unexpected shape') } : r
494 }
495 return {
496 result: updated as typeof r.result,
497 context: [...(r.context ?? []), redactionNote(updated)],
498 }
499}
500
501/**
502 * Whether the replacement is the record that was sent with only its string
503 * leaves rewritten. That is exactly what the engine does, so any other
504 * difference (a key added or dropped, a nested object hollowed out, a number
505 * or flag changed) is not a redaction; forwarding it would fail core's
506 * output-schema check, which skips the hook and lets the unredacted result
507 * stand.
508 */
509function sameShape(replacement: unknown, original: unknown): boolean {
510 if (Array.isArray(original)) {
511 return Array.isArray(replacement)
512 && replacement.length === original.length
513 && original.every((item, i) => sameShape(replacement[i], item))
514 }
515 if (original && typeof original === 'object') {
516 if (!replacement || typeof replacement !== 'object' || Array.isArray(replacement)) {
517 return false
518 }
519 const sent = Object.keys(original)
520 const got = Object.keys(replacement)
521 return sent.length === got.length
522 && sent.every(key => key in replacement && sameShape((replacement as Json)[key], (original as Json)[key]))
523 }
524 return typeof original === 'string' ? typeof replacement === 'string' : replacement === original
525}
526
527/** An engine bound to this hook's `$`, with a fresh budget. */
528function engineFor($: Parameters<typeof toolHook>[0]): Engine {
529 return engineAsk(
530 config,
531 (argv, init) => $.process.run(argv, init),
532 (url, init) => $.http.fetch(url, init),
533 (ms, options) => $.clock.sleep(ms, options),
534 text => $.ui.log(text),
535 )
536}
537
538export const toolHook: MatchedHook<'tool.call', typeof TOOL_MATCHER> = async ($, e, next) => {
539 // The host budgets only the hook's own work, not the time inside `next(e)`
540 // (measured on 2.1.263). Mirror that: one budget before the tool, a fresh
541 // one after, so a slow tool never denies its own redaction.
542 const pre = engineFor($)
543 let session_id: string
544 try {
545 const facts = await sessionFacts($, pre)
546 if (!facts.ok) {
547 // No per-session salt means no redaction worth trusting: closed denies
548 // before the tool runs, open behaves as if the module were absent.
549 return config.failClosed ? { deny: unavailable(facts.cause) } : next(e)
550 }
551 const denied = await denyBeforeRead(pre, e, facts.value)
552 if (denied) {
553 return denied
554 }
555 session_id = facts.value.session_id
556 }
557 finally {
558 pre.close()
559 }
560 const r = await next(e)
561 if (!r.isError && !scannable(e, r)) {
562 return r
563 }
564 const post = engineFor($)
565 try {
566 return await (r.isError ? redactError(post, r, session_id) : redactResult(post, e, r, session_id))
567 }
568 finally {
569 post.close()
570 }
571}
572
573/**
574 * Redact and forward rather than block: the prompt is dropped only when the
575 * transport failed (or, defensively, if a future engine answers this payload
576 * with a `decision:"block"` verdict — today's `handle_post_tool_use` only ever
577 * answers with `hookSpecificOutput`).
578 *
579 * Note the threshold this path uses: presenting the prompt as tool output runs
580 * it through `redact_json_value` at `DEFAULT_MIN_PII_SEVERITY` (2), not the
581 * `PII_SEVERITY_HIGH` (3) floor `handle_user_prompt_submit` applies. Medium
582 * severity PII — an email address, a phone number — is therefore rewritten in
583 * prompts here where the command hook let it through untouched.
584 */
585/** What the engine's answer means for a prompt: pass, drop, or rewrite. */
586type PromptVerdict = { kind: 'pass' } | { kind: 'drop', reason: string } | { kind: 'rewrite', text: string }
587
588function promptVerdict(answer: Answer): PromptVerdict {
589 const unavailable = (cause: string): PromptVerdict =>
590 config.failClosed ? { kind: 'drop', reason: `honmoon: redaction engine unavailable (${cause}); prompt not sent` } : { kind: 'pass' }
591 if (!answer.ok) {
592 return unavailable(answer.cause)
593 }
594 const wrong = misrouted(answer.verdict, 'PostToolUse', NOT_PROMPT)
595 if (wrong !== undefined) {
596 return unavailable(wrong)
597 }
598 // A block decision wins over any rewritten text: a verdict that carried both
599 // must never be turned into a forwarded prompt.
600 if (answer.verdict.decision === 'block') {
601 const reason = answer.verdict.reason
602 return { kind: 'drop', reason: typeof reason === 'string' && reason ? reason : 'honmoon: prompt blocked' }
603 }
604 // `block` is the only decision the engine emits; any other is not it talking.
605 if (answer.verdict.decision !== undefined) {
606 return unavailable(`engine returned an unexpected shape (decision ${JSON.stringify(answer.verdict.decision)})`)
607 }
608 const updated = updatedOutput(answer.verdict)
609 if (updated === undefined) {
610 return { kind: 'pass' }
611 }
612 // A string comes back a string; anything else is not the engine talking.
613 if (typeof updated !== 'string') {
614 return unavailable('engine returned an unexpected shape')
615 }
616 return { kind: 'rewrite', text: updated }
617}
618
619export const promptHook: Hook<'prompt.submit'> = async ($, e, next) => {
620 const engine = engineAsk(
621 config,
622 (argv, init) => $.process.run(argv, init),
623 (url, init) => $.http.fetch(url, init),
624 (ms, options) => $.clock.sleep(ms, options),
625 text => $.ui.log(text),
626 )
627 try {
628 const facts = await sessionFacts($, engine)
629 if (!facts.ok) {
630 return config.failClosed
631 ? { drop: `honmoon: redaction engine unavailable (${facts.cause}); prompt not sent` }
632 : next(e)
633 }
634 const verdict = promptVerdict(await engine.ask({
635 hook_event_name: 'PostToolUse',
636 tool_name: 'Read',
637 tool_input: {},
638 tool_response: e.text,
639 session_id: facts.value.session_id,
640 }))
641 if (verdict.kind === 'drop') {
642 return { drop: verdict.reason }
643 }
644 if (verdict.kind === 'pass') {
645 return next(e)
646 }
647 const r = await next({ ...e, text: verdict.text })
648 if (r.drop !== undefined) {
649 return r
650 }
651 return { ...r, context: [...(r.context ?? []), redactionNote(verdict.text)] }
652 }
653 finally {
654 engine.close()
655 }
656}
657
658export const register: Register = (on, options) => {
659 applyOptions(options)
660 // One registration covers both placements: on 2.1.263 a second
661 // `on("tool.call", …)` from the same plugin silently replaces the first, so
662 // the before-check (PreToolUse on Read) and the after-redaction share a hook.
663 on('tool.call', TOOL_MATCHER, toolHook)
664 on('prompt.submit', promptHook)
665}
666