“Why do I need your thing? Claude Code already has PreToolUse hooks — I’ll
just write my own.”
Fair question — and for the simplest checks, a hook really is all you need:
blocking one command by pattern is a few lines of shell. If that’s your whole
threat model, roll your own.
But most of what sasy-guard does is not single-command pattern-matching. A
PreToolUse hook fires once per tool call, statelessly, seeing only that one
call. The moment a rule needs memory of the conversation, facts from
outside the command string, or protection from the agent itself, the hook
turns into a project nobody wants to own. Here it is, guard by guard.
A hook sees one call. Knowing an outbound curlderives from a secret read
three calls ago needs a backward slice over the conversation’s
message-dependency graph — across parallel tool calls and subagents a single
hook is blind to.
② Facts outside the string
“Is this package malicious?”, “is this repo public?”, “did a secret scan run
clean?” aren’t in the command text. They need live lookups (OSV, GitHub) and
real tool runs (gitleaks, osv-scanner), resolved and cached.
③ Session-wide state
“A large, unreviewed push”, “a scan that covers these edits” — facts that
accumulate across many calls. A stateless hook has to rebuild that
bookkeeping itself, correctly, on every single call.
④ Tamper-resistance
Your hook lives in .claude/settings.json — the first file a prompt-injected
agent edits. A guard the agent can switch off is not a guard.
sasy-guard is one locked policy engine that does all four, with every rule
written as auditable Soufflé Datalog — the same policy the SASY SDK enforces —
instead of a pile of per-developer scripts. Below, each guard group side by side.
Pattern blocks — where a hook is closest, and still drifts
Single-command signatures. A hook can match these; the catch is that “match
rm -rf” is the opening move of an arms race, and every developer’s regex drifts
differently.
grep 'rm -rf'. Then you find rm -fr, rm -r -f, rm --force --recursive, find . -delete, an aliased rm, $(printf rm) -rf… each a
new patch. Miss one and it’s an irreversible delete.
🛡️ sasy-guard
One normalized matcher (any flag ordering, find -delete, …) shipped and
maintained centrally as a policy rule — identical for every repo and
teammate, not a regex each person re-derives.
grep '/dev/tcp'. But reverse shells ship in dozens of one-liners —
nc -e, ncat, openssl s_client, mkfifo pipes, and python/perl/
php socket payloads. Your denylist is always one payload behind.
🛡️ sasy-guard
A curated remote-exec signature set, updated centrally and applied as one
rule everywhere — so a new variant is fixed once, for everyone, not
re-discovered per project.
grep 'curl.*| *sh'. Misses wget | bash and the no-pipe
command-substitution installers ($(curl …), <(curl …)), and can’t tell a
trusted host from an unknown one. You also wanted ask, not deny, so now
you’re hand-rolling the verdict protocol and the reason string.
🛡️ sasy-guard
Host-aware (matches the URL’s registrable domain — a trusted installer
passes, an unknown host is held), normalized across pipe and
command-/process-substitution forms, and returns ask with a [SASY] …
reason the agent relays back to you.
The first thing a prompt-injected agent reaches for is the off switch. The two
rules below deny the obvious ways to disable enforcement — edits a hand-rolled
hook structurally cannot block, because the hook is co-located with the thing
it’s supposed to guard.
The irony writes itself: your hook is an entry in .claude/settings.json.
To stop the agent persisting a backdoor hook, you’d need a hook that guards
the very file it lives in — which the agent can equally edit to delete yours.
A file can’t reliably protect itself.
🛡️ sasy-guard
sasy-guard’s hook entry sits in .claude/settings.json too — but editing
that file is itself a deny (config_persistence), so the agent can’t
strip the hook out. The decision lives in a separate locked engine under
~/.sasy with the policy pinned per session, so a config edit can’t
soften it mid-run, and a killed engine denies every call instead of waving
it through.
Block export ANTHROPIC_BASE_URL=. But the same agent can rewrite your hook
out of settings, point the variable via a written .env, or wrap the binary
— and your hook runs entirely at the agent’s mercy.
🛡️ sasy-guard
The policy is baked into a locked engine and pinned per session, so the
endpoint hijack and the config edits that would neuter a hook are both
denied.
These are the lethal-trifecta rules. A hook can fake the symptom (a read
happened, a curl happened); the engine computes the cause (this output
derives from that source).
“Read .env then curl” is sequence-matching: it over-blocks (any read then
any unrelated outbound) and is trivially dodged — read via cat in a
subshell, base64, a subagent, an MCP tool. It can’t tell a curl that
carries the secret from one that merely follows a read.
🛡️ sasy-guard
Provenance: the egress is cut only when its dependency slice traces back to
the secret-read node. The value never leaves the machine, and unrelated
outbound calls flow through untouched.
Stateless, the best you can do is “a WebFetch happened, so block all curls”
— which blocks every legitimate outbound call and still misses egress via a
subagent or a tool you forgot to enumerate.
🛡️ sasy-guard
A backward slice over the message-dependency graph: this egress is blocked
only because its slice touches an untrusted WebFetch result, computed
across parallel calls and subagent threads a single hook can’t see.
The verdict here doesn’t live in the command string. It lives in a tool you have
to run (gitleaks, osv-scanner), a network fact you have to fetch (OSV
verdicts, repo visibility), or state you have to accumulate across the session.
“Block git push unless gitleaks ran.” But did a run report clean, did it
cover every file edited this session, and is it real (not a spoofed echo 'no leaks found')? That’s stateful, cross-call bookkeeping tied to your
edits — not a one-call check.
🛡️ sasy-guard
The push is denied until a cleangitleaks run is present in the session
that covers every recent edit (graph reachability from each edit to a
clean scan) — and it checks gitleaks was actually invoked, so an echo 'no leaks found' can’t fake it. The denial tells the agent to run gitleaks
first.
“Large” and “unreviewed” are session facts — total edit magnitude across many
calls, and whether a review happened — that a per-call hook has to accumulate
and persist itself, correctly, every time.
🛡️ sasy-guard
Edit magnitude is tracked over the session graph; a large unreviewed push is
held for ask before it leaves the machine.
On npm install X, is X known-bad or suspiciously new? That’s a live OSV
lookup plus a package-age (cooldown) check, per package, with caching — a
network-fact resolver bolted onto your hook.
🛡️ sasy-guard
The daemon resolves OSV verdicts and package age as out-of-band facts and
holds risky installs for ask.
Two hard things at once: a live GitHub call to learn the remote’s
visibility, and a taint check to know the session is tainted. Miss
either and you leak to a public repo, or block every private push.
🛡️ sasy-guard
Repo visibility is resolved out-of-band and combined with the taint slice, so
only a tainted push to a public repo is held.
Most people don’t know this one exists: invisible Unicode tag characters that
hide instructions inside otherwise-normal text. A hook would have to decode
the tag-character range in every tool input and result, judge it deceptive,
and remember your approval.
🛡️ sasy-guard
Decodes the hidden tags, shows you the decoded instruction, asks before
acting, and records the detaint decision so it isn’t re-asked.
A hook is a place to put a check. sasy-guard is the engine behind it:
dataflow provenance over a reconstructed conversation graph, live out-of-band
facts, session-wide state, and a locked policy the agent can’t edit — all as one
auditable Datalog policy shared with the SASY SDK, instead of N drifting shell
scripts that each developer maintains and each agent can disable.
Write the hook for rm -rf. Use sasy-guard for everything after it.