✳ClaudeModsClaude Code mod directory
☰
● 0 online · Views 0 times
+ Submit a project
← Back to projects
GitHub repositories · Security & privacy

honmoon-redact

Hide API keys and sensitive identifiers before tool results reach the model.

ppleaseai@pleaseai
Translated

About this mod

Hide API keys and sensitive identifiers before tool results reach the model.

This project is maintained by a community author. See the original README below for features, version requirements and configuration. This site provides permission scan results; inspect the source and external dependencies before installing.

Installation

Check the author's README for the marketplace and plugin name first. Commands may change as the repository evolves.

claude plugin marketplace add pleaseai/honmoon
claude plugin install honmoon-redact
Original text / 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

| 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.

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=sk-ant-api03-cache-stable-abcDEF123456"}' | 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 sk-ant-api03-cache-stable-abcDEF123456"}' | 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=sk-ant-api03-cache-stable-abcDEF123456\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 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.

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

| 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.

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:

| 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, and a degraded event there would be a false alarm about a correctly-permissioned key. Neither reaches past the POSIX mode bits: on macOS an ACL entry granting another local user read leaves the mode at 0600, and this check cannot see it. And neither is a claim that anyone did read the file — only that the mode allowed it.

honmoon attests the two instants the loader looked, and nothing before them. A clean log is still not a clean bill of health for this key. The loader now reads the mode both before and after its correction, so a file that is loose when honmoon runs is reported either way; but it learns a mode at those instants only, never how long the file carried it. A salt that was 0644 last week and 0600 at both of today's observations is indistinguishable here from one that was never loose, and no number of stats would tell you. So if you have reason to think the file was readable by another local user at a time honmoon was not running — a restored backup you have since tightened by hand, a shared home, a period before the plugin was installed — rotate on that suspicion rather than waiting for a signal that cannot come.

A salt the loader could not read is replaced, and what it held is unknown. Where the read of ~/.honmoon/hook-salt fails with anything other than "no such file" — a mode that denies this user the read while still allowing the write, a transient I/O error — those bytes cannot be adopted, so the loader mints a fresh secret and overwrites the file. That file may have held nothing. It may equally have held the salt every other invocation had been adopting for months, under a mode that let another local user copy it. honmoon does not know which, and hook-salt-replaced-unread says exactly that instead of guessing in either direction:

salt file /home/a/.honmoon/hook-salt could not be read (Permission denied (os error 13)) and its contents were discarded unread, so whether it held a key other invocations adopted is unknown; it was readable by other local users (mode 0244) when the loader last looked, just before discarding it

The mode there is the file's, and it is what the loader saw when it last looked, a few syscalls before the overwrite — not a claim that a key was exposed, because no key was ever seen. Read the two halves separately. A mode letting other local users read the file, on a file that may have held a key, is the combination worth acting on: treat the placeholders minted before this event as forgeable, since rotation protects a session's future and not its past. Nothing else in the reason clears it — an owner-only mode, a mode that could not be read back, and the execute-only case (whose mode grants no read at that one instant, which says nothing about earlier ones) all leave the question open, for the reason the exposure rules give: a file that was 0644 last week and 0600 now looks identical here to one that was never loose.

key_source names the key in use, not the discarded one. That is usually persisted — a fresh secret landed in the file — but it is fallback where the loader destroyed the unreadable file and then could not write the replacement, in which case this rule appears beside hook-salt-fallback and both describe the same invocation. This is the one rule whose reason is about a different key from the rest of the record, so read rule before reason on it.

There is usually nothing left to fix on the file. The loader has already applied the remedy an operator would have reached for — it rotated, having no other option — which makes this mostly an audit trail rather than a call to action. What is still worth chasing is why the file was unreadable, and this event is that record too: no outcome of a failed read is silent. Where the replacement lands, this rule is the record; where it fails at the open, nothing was destroyed and only hook-salt-fallback fires; where it fails after the open, the file is gone anyway and both rules fire.

It does not always fire once. Usually it does — the replacement lands at 0600, and the next invocation reads it without a word. But the loader can only make the replacement readable to itself if the chmod takes, and that fails on a file it does not own (a root-owned salt in your ~/.honmoon that your user can write but not read). There the next invocation fails the same read, replaces again, and raises this rule alongside hook-salt-exposed every single time. A repeat of that pair is not an audit-volume problem — it means the machine key is being rotated on every invocation, so placeholders stop being byte-stable across turns and transports (#20, #98) and a session's transcript stops matching its own earlier turns. Fix the ownership of the salt file; honmoon cannot clear that condition itself. (This is not new behaviour — such a host always rotated its key every invocation. What is new is that it now says so.)

Rotating a salt it can still read is yours to trigger, not honmoon's. (The one arm where the loader rotates on its own is the unreadable file above, where it has no other option.) It does not mint a new salt when it finds a merely loose one, because it cannot tell a single-user laptop restoring its own backup from a shared host where someone really did read the file — and rotating costs something real in both cases: placeholders for the same secret change at that moment, so an in-flight session's transcript stops matching its earlier turns and the two transports disagree until the gateway restarts, which is exactly the byte-stability #20 and #98 are about. The record gives you the input; the decision needs what you know and honmoon does not. Note also what rotation does not buy: the turns already redacted under the old key stay confirmable against it, so rotating protects the session's future, not its past.

To rotate: delete ~/.honmoon/hook-salt once its permissions can be fixed and let the next invocation mint a new one. Rotate between sessions where you can, for the reason above.

Where each event is visible. The two transports reach different readers, because the gateway's management API (/api/audit, which the embedded dashboard polls) serves its own in-process ring rather than the file:

| Recorded by | In the JSONL file | @honmoon/api (GET /api/audit?decision=degraded) | Gateway dashboard | | --- | --- | --- | --- | | honmoon hook (its own process, per invocation) | yes | yes | no | | honmoon gateway (once at startup, covering the HTTP transport and wire redaction) | yes, when --audit-log is set | yes | yes — a Degraded pill |

That table holds for all four rules: each process records what its own key read observed, so a salt that is unrestrictable — or that was loose until the loader tightened it, or that could not be read at all — reaches the same surfaces a fallback key does.

So a hook-side degradation is found by querying the log, not by watching the dashboard.

Where both processes read the same ~/.honmoon/hook-salt — the co-located deployment, not the cases under "different key bytes break parity" above — hook-salt-fallback and hook-salt-exposed do still reach the dashboard on any host that runs a gateway, because their triggers persist: a key the hook cannot persist, or a mode it cannot restrict, is still that way when the gateway starts, so the gateway records its own event into the ring the dashboard polls. Where the two resolve different salt paths there is no shared trigger at all — the gateway reads a different key and reports on that one.

The two self-clearing rules do not carry that guarantee even then, because their triggers are consumed by acting on them. Both processes run the same loader, so for hook-salt-was-exposed a correction that completes before the other's first stat leaves that one reading 0600 and recording nothing — a hook that got there first is the only witness, and the dashboard stays clean over a key that was exposed. (Loaders whose reads overlap, both stat-ing before either chmod, both see the loose mode and both record. A pill is possible; it is not something to wait for.) hook-salt-replaced-unread is the same shape with a wider window, not a narrower one: its trigger is a read that fails, and nothing clears that until some process's replacement has been written and tightened — with a stat, a /dev/urandom read and a create_dir_all in between. Nothing locks the file, so two invocations whose reads both fail before either replacement lands will each mint a different salt, each record, and race for the final bytes. A later invocation sees an ordinary 0600 salt and says nothing; a concurrent one does not. Query the log for these two.

What the dashboard cannot show for any of the four is the hook's own records — the per-invocation cardinality, and the hook-specific reason. A host running no gateway has the log and the query API only.

Pointing both processes at one file is intended: each appends whole lines, and the query layer orders by timestamp because ids are process-local.

Pick a path on a different filesystem from ~/.honmoon where you can. The conditions that make the salt unwritable — a read-only filesystem, a full disk, an unwritable HOME — can equally stop the audit log from being opened or appended to. honmoon hook carries on (reporting a degradation must never become one) and falls back to the systemMessage described above, so a correlated failure leaves the degradation visible to whoever is driving the session but recorded nowhere durable.

Getting one key onto both sides. Co-located processes sharing a HOME read one file and need nothing. Anywhere else — separate hosts, containers, different users — provision the same hook-salt to both: the loader adopts any existing file of at least 16 bytes verbatim, so identical bytes at each process's salt path mint identical placeholders. Cross-host parity is therefore achievable; it is just not automatic.

What "verbatim" covers, since this is what a deployment ends up depending on: the file's own bytes, at the length it carries — 16 is a floor and not a size, a longer key is used whole, and nothing is truncated, padded or re-derived. A file under 16 bytes is not adopted, and neither is one that is absent or unreadable: the loader mints a fresh 32-byte key for each of those, or falls back to the public constant under "When the salt file is unusable" above when it cannot produce one — the write can fail, and so can the read of /dev/urandom it draws the bytes from, before any write is attempted. On the way through it restricts the file it adopted to 0600; where that leaves the file still readable beyond its owner, it says so rather than going quiet — the hook-salt-exposed row in the table above. Where it reaches you is the transport's affair, per "Where each event is visible": a gateway records it into the ring its dashboard polls whether or not --audit-log is set, while a honmoon hook invocation needs HONMOON_AUDIT_LOG — or --audit-log, when run by hand — for its record to outlive the process, and the dashboard never shows it. (A chmod that fails on a file already at 0600 is deliberately silent, because a correction that was not needed is not evidence of exposure.) None of this depends on the mode: a world-readable file is still adopted, and audited for it. These are pinned by tests in crates/honmoon-cli/src/hook.rs (issue #126), so they are an interface you can deploy against rather than loader behaviour that might move.

Generate the bytes; do not choose them. The loader checks length and nothing else, so 16 bytes of a memorable phrase are adopted and keyed with, and every rule that could complain is about the file rather than the bytes: provision that phrase in an owner-only file and nothing fires at all, since key strength is the one property none of them looks at. (Provision it in a loose file and hook-salt-was-exposed does fire — about the mode the loader found, which is a different problem that happens to be present.) Produce the file the way honmoon produces it, owner-only from the first byte: under the usual umask 022 a bare redirection creates it 0644, and a local user who copies it in that window holds a key the loader's later 0600 correction cannot recall.

(umask 077; head -c 32 /dev/urandom > hook-salt)   # created 0600, not your umask's 0644
# on each host — install does not create the parent, and umask 077 makes it 0700
(umask 077; mkdir -p ~/.honmoon; install -m 0600 hook-salt ~/.honmoon/hook-salt)

A key small enough to search is worse than a shared one. Someone who sees a placeholder and can guess the secret behind it recovers the key offline, and a recovered key forges placeholders as well as confirming them — without the bytes ever having been handed over.

Treat that as copying a secret, because it is. The machine key is what makes a placeholder unforgeable, so everyone who holds it can mint the placeholder a given session would produce for a guessed secret and check it against a redacted transcript — the confirmation oracle tracked in #125. That is the real cost of spreading this key, and it does not depend on how the key got there: it is a property of holding the bytes, so a first-class flag would carry it unchanged. Move it only over a channel you would use for any other credential, and put it in as few places as the deployment needs. Rotating one is not the single-host recipe above: deleting one host's hook-salt mints a key only that host holds, which breaks the parity this section exists to establish, while every host you did not touch carries on adopting the leaked bytes. Generate one new key and re-provision it everywhere in the same operation, between sessions where you can. What is still missing is a supported input — a flag or environment variable naming the key — rather than arranging for a file to be at each path; issue #126 tracks that for honmoon join (#37). If none of that is worth it for your deployment, keep transport: "process".

Typings

hooks/honmoon.ts is typed against .claude/types/claude-code.d.ts, generated by /plugin-types (its header records the Claude Code version that wrote it). Regenerate after a Claude Code update:

cd packages/claude-plugin
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p --output-format text "/plugin-types"

hooks/claude-code-grep.d.ts adds Grep, which 2.1.263's /plugin-types does not emit although the tool exists at run time; delete it if a regenerated claude-code.d.ts declares Grep itself. bun test and bun run typecheck cover the module.

Known limitation — detector coverage

The detectors are precision-first, so a few shapes can slip through — the proxy remains the wire-facing backstop, but for local transcript hygiene these are gaps to be aware of:

  • Truncated PEM private keys. A private-key block is only matched when both the -----BEGIN … PRIVATE KEY----- header and the matching -----END … footer are present (the footer anchor keeps prose that merely mentions a key from matching). Output that shows the header plus only part of the body — e.g. head -5 id_rsa via Bash — is not redacted. Prefer the PreToolUse deny for whole key files.
  • Generic-secret placeholder filtering. The keyword-anchored GENERIC_SECRET detector drops values that look like placeholders by matching markers (example, changeme, test, …) as substrings, so a genuine high-entropy secret that happens to embed one of the short markers can be treated as a placeholder and passed through. Structural keys (sk-ant-…/AKIA…/ghp_…/…) are unaffected — they don't consult this list.

Scope

The command hooks ship the command transport (works with only the binary installed). A gateway-direct HTTP transport (type: "http" hooks posting to the honmoon management API, sharing the tokenization mapping with a co-running proxy) is a planned follow-up for them; settings-level HTTP hooks fail open, so it will not become the silent default. The function-hooks module above already offers the HTTP transport (transport: "http"), where honmoon controls the failure mode itself and defaults to failing closed.

View original source ↗

Similar projects