ash is a recursive-development experiment: the project's value depends on agents working in this repo actually using ash for find/grep/read/git status/git log so the ledger captures real friction data. We observed that agents (Claude included) were not reliably following the CLAUDE.md switch criteria — they reach for the harness's built-in Grep/Glob tools (or shell out to grep/rg/find via Bash) instead of ash.
The root cause is structural, not documentary: the harness system prompt instructs the model to "prefer dedicated tools over Bash," and the dedicated Grep/Glob tools are the ergonomic default. CLAUDE.md prose competes with that bias and loses. Shell aliases don't help — the Bash tool runs commands non-interactively (no rc files), and the dedicated tools bypass bash entirely.
The fix is a PreToolUse hook that intercepts the offending tool calls and denies them with a message pointing to the equivalent ash invocation. Block-and-nudge (not auto-rewrite), so the friction stays visible and feeds the session-feedback ritual. Project-scoped (checked into the repo) so anyone working on ash gets the same enforcement.
The hook is implemented as the ash hook verb — the only client-only verb in ash. It runs the deny decision in-process for low latency, then best-effort fires a normal ash request to the daemon to record a ledger row. Two reasons for folding it into ash:
- Latency. The hook runs on every
Grep/Glob/Bash/Edit/Write/Readcall. A single-binary Go fast path is faster than spawning a Python interpreter on each invocation, and importantly never blocks on the daemon — auto-start is intentionally skipped to keep hook latency on the agent's critical path low. - Ledger queryability. Hook denials are the highest-fidelity friction signal in the project. Routing them through the ledger turns the hook from a fence into a research instrument:
ash report --verb hookaggregates which rules are firing, which transitions agents are fighting, and how often.
ash hook is the only verb in the codebase that runs client-side. Every other verb round-trips to ashd. The flow:
cmd/ash/main.gospecial-casesverb == "hook"immediately after parsing argv (beforedialOrStart). Hand-off torunHookincmd/ash/hook.go.runHookreads the Claude payload from stdin, callshook.Decide, writes the Claude decision JSON to stdout, then dials the daemon with a 5ms timeout and fires the same args as a normalashrequest — fire-and-forget, noReadFrame. If the daemon isn't up, the ledger row is skipped silently. The daemon is never auto-started by the hook fire.- Daemon-side:
hookis registered as a regular verb (internal/verbs/verbs.go). ItsRunre-runshook.Decideover the same args. Client and daemon agree by construction — both call the same pure function.
Soft-fail is end-to-end: any error in payload parsing, decision, or fire produces "allow" (no Claude output, exit 0). The hook should steer agents, never break their tool calls.
The matcher is a regex OR of the harness tool names that the hook decides on: Grep|Glob|Bash|Edit|Write|Read. The command is "$CLAUDE_PROJECT_DIR/bin/ash" hook. $CLAUDE_PROJECT_DIR is set by Claude Code when invoking hooks; it gives the command a stable anchor regardless of cwd.
If bin/ash doesn't exist (fresh clone, before go build), the hook command fails non-zero and the harness allows the call through. CLAUDE.md already directs go build as session step 1.
The Go implementation. Exports:
ExtractArgs(payload []byte) (map[string]any, *Args, error)— client-only helper that parses the Claude JSON into a wire-args map (for the daemon fire) and a typedArgs.Decide(*Args) *Result— pure decision engine, shared by client and daemon. Never returns an error.ParseArgs/Run— daemon-side wire decoder + runner, mirroring every other verb.EncodeClaudeDecision(*Result) ([]byte, error)— rendersResultas Claude PreToolUse hook JSON; returns nil for allow (no output).
The client-only fast path: runHook ties ExtractArgs → Decide → EncodeClaudeDecision → async ledger fire.
Dispatched on tool_name:
Grep→ always deny. Suggestsash grep --pattern <p> --path <d> [--glob …].Glob→ always deny. Suggestsash find --path <d> --glob <p> --type file.Edit→ always deny. Suggestsash edit --path <p> --old <text> --new <replacement>. The harness's ownEditenforces a prior harnessRead, which is also denied — denyingEditsymmetrically routes the agent toash editdirectly instead of through the misleading "File has not been read yet" error path (ASH-126).Write→ always deny. Suggestsash write --path <p> --content <text>.Read→ deny on text files. Allow on.png/.jpg/.jpeg/.gif/.webp/.pdf/.ipynb(file typesash readcan't render meaningfully).Bash→ tokenize the command across shell separators. For each segment, look at the first command word (skipping leadingVAR=valueassignments andenv/command/exec/time/niceprefixes). Deny when:grep/rg/egrep/fgrep→ suggestash grepfind→ suggestash find(extracts-name/-inameglob if present)cat/head/tail→ suggestash readcat/echo/printf/teewith an output redirection (>,>>,&>,&>>) → suggestash write --path FILE --content - << 'EOF'(ruleBash:redirect-write). The redirect target wins over the read mapping;cat foo > bardenies as a write, not a read.ls -R(or--recursive) → suggestash findstat→ suggestash statgit status/git log→ suggestash git --op statusorash git --op log- Allowed git ops (do not block):
diff,blame,show,add,commit,push,reset,rebase,checkout,branch,stash,tag,fetch,pull,init,remote,merge,cherry-pick,restore,switch. PerCLAUDE.md, these stay in bash until the correspondingash git --op <name>ships. - Anything else → allow.
Suggested invocations are best-effort — the goal is to give the agent a known-good starting point, not a perfect translation.
The bash command splitter is literal (no shell quoting awareness): a ; inside a quoted string still splits the command. This matches the previous python implementation's behavior and is sufficient for the patterns agents actually emit; it can occasionally trip on content-bearing arguments. The fix is to feed such content via stdin (--content -) rather than weaken the splitter.
Within a segment, positional args are scanned with shell redirection operators (>, >>, <, <<, &>, 2>&1, [N]>..., etc.) stripped before they reach the suggestion builders. A stray 2>&1 or > /tmp/list no longer pollutes the suggested --path value, and cat/echo/printf/tee invocations with an output redirect are routed to ash write rather than ash read (ASH-69).
Subagents spawned by Claude Code do not inherit the parent session's MCP servers. That means mcp__ash__ash_grep, mcp__ash__ash_read, etc. are unavailable inside a subagent. The hook still fires (it runs via a Bash command, not MCP), so every built-in tool call is redirected to ash * — but if Bash(ash *) is not pre-approved in the project's permissions.allow, the subagent blocks on a per-call human approval prompt. With no human watching, this is a deadlock.
ash init adds "Bash(ash *)" to permissions.allow as part of its settings merge, so newly initialized repos are covered automatically. For repos initialized before ASH-228, run ash init again — it is idempotent and will add the missing entry without touching anything else. ash uninit removes it symmetrically.
The hook steers via canonical bash idioms — it tokenizes the top-level command and routes on the first program word. Wrapping the inner command in a shell-of-a-shell sidesteps that introspection:
bash -c '<inner>'(andsh -c,dash -c,zsh -c) — the segmenter seesprog=bash,args[0]=-c, and treats the quoted body as an opaque argument. No recursion into the inner command.eval '<inner>'— same shape: the inner string is never re-parsed as a command.xargs … sh -c '<inner>'— thexargswrapper hides thesh -cfrom the segmenter.
These are bypass-by-design (the hook is steering, not enforcing) rather than bugs. Agents who genuinely need the escape (e.g., running go test -bench=… while ash test does not yet expose --bench — see docs/performance-baselines.md) should not pretend it does not exist; agents tempted to use it to dodge intent should know they are sidestepping the design, not exploiting a hole. If a session surfaces an unintentional escape (the agent reached for bash -c without realizing it bypassed the deny), file a follow-up to recursively segment the inner command.
An earlier iteration of this hook (when it was a Python script) denied Read on text files but had to remove that handler: at the time, ash edit and ash write did not exist, and the harness's Edit/Write tools refused to act unless they had previously seen a harness Read on the same path. Denying Read therefore left the agent unable to edit anything without bash workarounds.
ash edit and ash write are now live, so the harness's edit workflow no longer needs harness Read to function. Read is denied symmetrically with the rest, with the image/PDF/notebook exemption preserved.
- Auto-rewriting the call to an
ashinvocation. Block-and-nudge keeps friction visible and feeds session notes. Revisit onceash report --verb hookdata shows how often agents retry correctly. - An escape hatch (env var or sentinel comment). Per CLAUDE.md's spirit, friction is the feature. Drive any bypass from a real session note, not preemptively.
- Allowlist-style permission denies in
settings.json. They block without a custom reason and don't coverBash-side leakage. The hook is strictly more flexible. - Shell aliases. Don't work for the harness
Bashtool (non-interactive, no rc) and don't touchGrep/Glob/Readat all. - Generalizing "client-only verb" into a framework. This is the only verb that needs it; resist the urge to abstract until a second one shows up.
Build first: go build -o bin/ash ./cmd/ash && go build -o bin/ashd ./cmd/ashd.
Smoke test the decision engine by piping JSON payloads to bin/ash hook:
- Grep payload should produce a Claude deny pointing at
ash grep. - Read on a
.gofile should deny withash read --path …(new behavior). - Read on a
.pngfile should allow (no output, exit 0). - Edit payload should deny with
ash edit --path … --old … --new …(sends the agent straight toash editinstead of the harness's "File has not been read yet" error path). - Bash
git diffshould allow. - Bash
git statusshould deny withash git --op status. - Bash
cat foo.goshould still deny withash read --path foo.go.
Hook latency: time bin/ash hook < payload.json should land in single-digit ms cold (the python it replaces is roughly 2x slower on macOS, more on Linux).
Ledger fire: with ashd running, fire a few hook calls. ash report --verb hook should show the rows. Stop the daemon — hook still responds at the same speed; new fires silently skip the ledger row. Restart, fires resume.
End-to-end: restart the Claude Code session in this project so the new settings.json takes effect (hooks are loaded at session start). In a fresh session, ask Claude to "find all .go files under internal/" — the Glob or Bash find attempt should be denied and Claude should retry with ash find. After a few sessions, ash report --verb hook aggregates which rules are firing — the recursive-development signal we want.
If Claude fights the hook (repeated bash subshells, creative escapes), that itself is a finding — write the session note rather than weakening the hook.