SLOPSHOPPER

honmoon-redact

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…

newguardpromptprocessnetwork
★ 5v0.1.1MITupdated 2026-09-28pleaseai/honmoon/packages/claude-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · honmoon-redact
› 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 ⏺ Read(/work/app/src/auth.ts) ⎿ Denied by honmoon-redact: honmoon: redaction engine unavailable (session lookup failed: hook budget exhaus ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Honmoon Claude Code plugin — secret/PII redaction

Client-side Claude Code hooks that keep secrets and sensitive identifiers out of what Claude Code persists locally.

Why this exists (and how it relates to the proxy)

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:

  • Proxy = enforcement backstop (agent-agnostic, catches everything on the wire).
  • Plugin = lightweight onboarding (no local CA trust needed) + transcript hygiene (plaintext is redacted before it can land on disk).

What the hooks do

HookEventBehavior
Redact tool outputPostToolUse (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 promptsUserPromptSubmitA 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 readsPreToolUse (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.

Requirements

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.

Install the plugin

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.

Verify

# 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

Transcript hygiene — verified

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:

  • Only the tool output is rewritten. The hook's stdin (the raw tool_response) is not persisted today, but that is an implementation detail of Claude Code, not a documented guarantee.
  • For files that are known credential stores, the 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
)

Function hooks (early access)

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.

What changes versus the command hooks

Command hooksFunction-hooks module
PromptsBlocked — a command hook cannot rewrite a promptRewritten: 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 floorSeverity 3 (high) — handle_user_prompt_submitSeverity 2 — the prompt is scanned as tool output, so medium-severity PII (email, phone) is rewritten too
Tool outputRead, Bash, GrepRead, Bash, Grep and WebFetch
Engine unreachableFails 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 refusedsystemMessage on the hook response, shown to youThe same line, shown as a $.ui.log transcript line; the verdict beside it is applied unchanged
Transporthonmoon hook subprocesshonmoon 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.

Options

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):

OptionDefaultMeaning
transportprocess when hookUrl is unsetprocess runs honmoon hook; http POSTs the same JSON to hookUrl
honmoonBinhonmoonThe 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
failModeclosedclosed 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.

Both layers run at once

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:

ruleWhat went wrongWhat to do
hook-salt-fallbackthe key in use is not the persisted one; key_source says what that costfix what stopped the loader reading or writing ~/.honmoon/hook-salt
hook-salt-exposedthe key is the persisted one, but its file is readable by other local users and the loader could not restrict it to 0600tighten the file — the loader already tried and could not
hook-salt-was-exposedthe 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 itrotate, 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-unreadthe loader discarded a salt file it could not read, so what it held — and who could read that — is unknownusually 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).

ruleWhat was observedWhat to do
audit-sink-exposedthe file's mode admits local users other than its owner — every deployment that created its log before issue #138 has this at the umask defaultchmod 600 it, unless the mode is deliberate
audit-sink-foreign-ownerthe file is owned by another uid, so honmoon did not create itpoint --audit-log at a file you own, unless an administrator provisioned this one
audit-sink-hard-linkedmore than one directory entry names the file's inode, so every record also lands under a name you did not configurefind 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_sourceKeyWhat is lost
persistedthe random secret at ~/.honmoon/hook-saltnothing 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
unpersistedrandom and private, but never reached diskbyte-stable placeholders across turns and transports (#20, #98). Still unforgeable
fallbackthe constant compiled into the binaryunforgeability, 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

Source 1 files
hooks/honmoon.ts 666 lines
1// 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