A guard hook that blocks risky tool calls
This page shows a plugin whose only capability is a PreToolUse hook. After
reading it you can write a hook that inspects a tool call before it runs,
block the call with one line of shell, and test the hook with fixtures before
you open a session.
Purpose
Section titled “Purpose”The guard-hook plugin watches every tool call. When the tool is a shell tool
and the command text either contains rm -rf or writes under a path listed
in a small protected-paths file, the hook exits 2 with a one-line reason on
stderr. Muse Code then refuses to run that tool call and passes the reason to
the model. Every other tool call passes through untouched.
The plugin has no skills or commands, so nothing about it works until you approve the hook.
Directory tree
Section titled “Directory tree”guard-hook/├── .muse-plugin/│ └── plugin.json├── hooks/│ ├── guard.sh│ └── protected-paths.txt└── fixtures/ ├── allow-ls.json ├── block-protected-path.json └── block-rm-rf.jsonThe fixtures/ directory is not referenced by the manifest. It ships with the
example so that muse plugins hook test has something to run; the installed
copy simply carries it along.
The files
Section titled “The files”.muse-plugin/plugin.json
Section titled “.muse-plugin/plugin.json”{ "schemaVersion": 1, "name": "guard-hook", "displayName": "Guard hook", "version": "0.1.0", "description": "Example plugin: a PreToolUse hook that blocks shell commands which contain rm -rf or write under a protected path.", "compat": { "source": "native", "manifestDir": ".muse-plugin" }, "capabilities": { "skills": [], "commands": [], "hooks": [ { "id": "guard", "event": "PreToolUse", "command": ["sh", "hooks/guard.sh"], "timeoutMs": 5000, "statusMessage": "Checking shell command" } ], "mcpServers": [], "reminders": [] }}What to look at:
eventisPreToolUse, the event that runs before a tool call and may block it. There is nomatcherfield: native hooks key on the event, and the script decides which tools it cares about.commandis an argv array run without a shell. Muse Code rewrites exactly one element,hooks/guard.sh, to the installed copy of that file. The script reaches its other file,protected-paths.txt, throughMUSE_PLUGIN_ROOT.timeoutMsis 5000. A blocking hook should be fast; the default of ten minutes is far too long for a check that runs before every tool call.
hooks/protected-paths.txt
Section titled “hooks/protected-paths.txt”# One path prefix per line. A shell command that writes under any of these# is blocked. Lines starting with # are ignored. A leading ~/ also matches# the same path with $HOME spelled out.~/.ssh~/.gnupg~/.config/muse/etc.git/What to look at:
- The list is data, not code, so you can extend it without touching the script.
- A
~/prefix is matched twice: literally, and withHOMEexpanded, so both~/.sshand the same path with your home directory spelled out are caught.
hooks/guard.sh
Section titled “hooks/guard.sh”#!/bin/sh# PreToolUse hook: block shell commands that contain "rm -rf" or that write# under a protected path. Reads the JSON payload from stdin. Exit 2 with one# line on stderr blocks the tool call; exit 0 lets it through.## This is string matching on the command text. It is advisory, not a sandbox:# a command can spell the same action in a way this script does not recognise.set -u
payload=$(cat)
# Only shell tools carry a command string. Everything else passes.tool=$(printf '%s' "$payload" | sed -n -E 's/.*"tool_name"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p' | head -n 1)case "$tool" in bash|powershell) ;; *) exit 0 ;;esac
# The command is tool_input.command. Keep backslash escapes intact; they do# not matter for substring matching.command=$(printf '%s' "$payload" | sed -n -E 's/.*"command"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p' | head -n 1)[ -n "$command" ] || exit 0
block() { printf 'guard-hook blocked this command: %s\n' "$1" >&2 exit 2}
# Rule 1: recursive force delete, in either flag order.case "$command" in *"rm -rf"*|*"rm -fr"*) block "it contains rm -rf" ;;esac
# Rule 2: a write under a protected path. The list ships with the plugin, so# read it from the installed copy through MUSE_PLUGIN_ROOT. Fall back to the# script's own directory when run by hand.root=${MUSE_PLUGIN_ROOT:-$(dirname "$0")/..}list="$root/hooks/protected-paths.txt"[ -r "$list" ] || exit 0
# Writing verbs and redirections. A command that merely reads a protected# path (cat, ls, grep) is not blocked.case "$command" in *">"*|*"tee "*|*"rm "*|*"mv "*|*"cp "*|*"touch "*|*"mkdir "*|*"chmod "*|*"sed -i"*) writes=1 ;; *) writes=0 ;;esac[ "$writes" -eq 1 ] || exit 0
while IFS= read -r prefix || [ -n "$prefix" ]; do case "$prefix" in ""|"#"*) continue ;; esac case "$command" in *"$prefix"*) block "it writes under protected path $prefix" ;; esac case "$prefix" in "~/"*) expanded="${HOME:-}/${prefix#\~/}" case "$command" in *"$expanded"*) block "it writes under protected path $prefix" ;; esac ;; esacdone < "$list"
exit 0What to look at:
- Payload first. The script reads all of stdin into
payloadbefore doing anything else. APreToolUsepayload carrieshook_event_name,session_id,cwd,tool_name,tool_inputandtool_use_id, among other keys. The script usestool_nameandtool_input.command. - Tool names. The shell tool is
bashon macOS and Linux andpowershellon Windows. Any other tool exits 0 immediately, so the hook costs almost nothing on file reads and edits. - Plain
sedinstead of a JSON parser. The hook runs with a cleared environment plusPATH, so it should not depend on tools that may be missing. The-Epattern forcommandkeeps escaped quotes inside the string, which is whyecho "hi" > /etc/motdis still caught. blockis the whole blocking mechanism. One line on stderr, exit 2. There is no JSON to write for this case.MUSE_PLUGIN_ROOTpoints at the read-only installed copy of the package. The fallback to the directory of the script lets you run it by hand while you develop it.- Read-only commands pass.
ls -la ~/.sshmentions a protected path but has no writing verb, so it is allowed.
The fixtures
Section titled “The fixtures”Each fixture is the input muse plugins hook test feeds to the hook: the
event name and the object the hook would read on stdin. Two are meant to be
blocked and one is meant to pass.
{ "event": "PreToolUse", "stdin": { "hook_event_name": "PreToolUse", "session_id": "00000000-0000-0000-0000-000000000001", "cwd": "/tmp/scratch", "tool_name": "bash", "tool_input": { "command": "rm -rf build/" }, "tool_use_id": "call_1" }}{ "event": "PreToolUse", "stdin": { "hook_event_name": "PreToolUse", "session_id": "00000000-0000-0000-0000-000000000001", "cwd": "/tmp/scratch", "tool_name": "bash", "tool_input": { "command": "cat id_rsa.pub >> ~/.ssh/authorized_keys" }, "tool_use_id": "call_2" }}{ "event": "PreToolUse", "stdin": { "hook_event_name": "PreToolUse", "session_id": "00000000-0000-0000-0000-000000000001", "cwd": "/tmp/scratch", "tool_name": "bash", "tool_input": { "command": "ls -la ~/.ssh" }, "tool_use_id": "call_3" }}The fixture keys are event, stdin, and optionally cwd and
matcher_input. The event must match the event the hook is declared for.
How blocking works
Section titled “How blocking works”On PreToolUse, Muse Code reads the hook’s exit code and stderr:
| Hook result | What happens to the tool call |
|---|---|
| Exit 0, no output | Runs normally. |
| Exit 2 with text on stderr | Does not run. The stderr text is the block reason and reaches the model, which can choose another approach or explain the refusal. |
| Exit 2 with empty stderr, or any other non-zero exit | The hook is recorded as failed. The tool call proceeds and the session continues. |
| Timeout | Same as a failure. |
A hook can also answer with a JSON object on stdout, for example to attach context to the tool call instead of blocking it. Hook events and payloads documents that schema. For a plain yes-or-no check, exit codes are simpler and harder to get wrong.
Limits to keep in mind:
- The hook sees the command text the model produced, not what the shell will do with it. Variables, aliases, script files and unusual spellings all bypass a string match.
- The check runs only for tool calls in a session on this machine, and only after you approve the hook and start a new session.
- The hook runs with your user privileges. It does not restrict what an allowed command can do.
- A failed or timed-out hook does not block. Keep the script simple so that it cannot fail on the calls you most want to catch.
How it was verified
Section titled “How it was verified”The commands below were run against Muse Code 1.3.0 with an empty plugin
store, from the directory that holds the guard-hook copy. Outputs are
trimmed and the store path is shown as <data dir>.
Validate:
muse plugins validate guard-hookvalid guard-hook native skills=0 commands=0 hooks=1 mcp=0 reminders=0 diagnostics=0The --json form shows how the hook entry was read, including the defaults
the manifest did not set:
muse plugins validate guard-hook --json{ "valid": true, "source_path": "guard-hook", "plugin": { "id": "guard-hook", "display_name": "Guard hook", "version": "0.1.0", "manifest_family": "native", "compatibility": { "summary": "full", "declarations": [ { "id": "hook:guard", "kind": "hook", "classification": "supported" } ] } }, "capabilities": { "skills": [], "hooks": [ { "id": "guard", "event": "PreToolUse", "matcher": null, "command": ["sh", "hooks/guard.sh"], "shell_command": null, "source_relative_path": "hooks/guard.sh", "timeout_ms": 5000, "async": false, "compatibility_name": null } ], "mcp_servers": [], "commands": [], "reminders": [] }, "diagnostics": []}Install and inspect:
muse plugins install guard-hookmuse plugins inspect guard-hookinstalled guard-hook 0.1.0 enabled=true trust=user-local provenance=native-local cache=<data dir>/plugins/cache/local/guard-hook/4c21946e…/packagewarning third-party plugin: hooks require review before activationguard-hook 0.1.0 enabled=true active=true trust=user-local valid=true skills=0 commands=0 hooks=1 mcp=0 reminders=0 cache=<data dir>/plugins/cache/local/guard-hook/4c21946e…/packagewarning third-party plugin: hooks require review before activationruntime-capability plugin:guard-hook:hook:guard status=review_neededThe plugin is installed and enabled, but its only capability is waiting for
review. inspect --json shows what an approval will bind to: a definition
hash that covers the hook entry as it will be run, the package digest and the
install location; your value will differ from the one shown.
muse plugins inspect guard-hook --json{ "record": { "id": "guard-hook", "version": "0.1.0", "enabled": true, "trust": "user-local", "source": { "provenance": "native-local", "path": "<absolute path to guard-hook>" }, "manifest_sha256": "sha256:e4ae9f68…", "package_sha256": "sha256:4c21946e…" }, "warning": "third-party plugin: hooks require review before activation", "valid": true, "active": true, "runtime_capabilities": [ { "candidate": { "kind": "hook", "plugin_id": "guard-hook", "capability_id": "guard", "stable_id": "plugin:guard-hook:hook:guard", "display_path": "plugin://guard-hook/hook/guard", "definition_hash": "sha256:7a5e8fd2…", "source_digest": "sha256:4c21946e…" }, "status": "review_needed", "diagnostic": null } ]}Approve the hook. The plugin has one runtime capability, so approving the
plugin id and approving guard-hook:hook:guard do the same thing:
muse plugins approve guard-hookmuse plugins inspect guard-hookapprove plugin:guard-hook:hook:guardguard-hook 0.1.0 enabled=true active=true trust=user-local valid=true skills=0 commands=0 hooks=1 mcp=0 reminders=0 cache=<data dir>/plugins/cache/local/guard-hook/4c21946e…/packagewarning third-party plugin: hooks require review before activationruntime-capability plugin:guard-hook:hook:guard status=trusted_enabledThe warning line is printed for every plugin that declares hooks, approved
or not; the runtime-capability row is the real state.
Now run the hook against the blocking fixture:
muse plugins hook test guard-hook:guard --fixture guard-hook/fixtures/block-rm-rf.jsonhook-test guard-hook:guard status=blockedThe --json form shows the decision Muse Code derived and the raw terminal
record, including the exit code and the stderr line that became the reason:
muse plugins hook test guard-hook:guard --fixture guard-hook/fixtures/block-rm-rf.json --json{ "decision": { "should_block": true, "block_reason": "guard-hook blocked this command: it contains rm -rf", "additional_contexts": [], "updated_input": null, "permission_decision": null, "feedback_message": null, "should_stop": false, "stop_reason": null }, "terminals": [ { "run_id": "plugin:guard-hook:guard:1", "hook_key": "plugin:guard-hook:guard", "event": "pre_tool_use", "status_message": "Checking shell command", "system_message": null, "status": "blocked", "duration_ms": 226, "exit_code": 2, "effects": ["blocked"], "stdout": "", "stderr": "guard-hook blocked this command: it contains rm -rf\n", "error": null } ], "records": 2}The protected-path fixture is blocked the same way:
muse plugins hook test guard-hook:guard --fixture guard-hook/fixtures/block-protected-path.jsonhook-test guard-hook:guard status=blockedAnd the passing fixture goes through:
muse plugins hook test guard-hook:guard --fixture guard-hook/fixtures/allow-ls.jsonmuse plugins hook test guard-hook:guard --fixture guard-hook/fixtures/allow-ls.json --jsonhook-test guard-hook:guard status=completed{ "decision": { "should_block": false, "block_reason": null, "additional_contexts": [], "updated_input": null, "permission_decision": null, "feedback_message": null, "should_stop": false, "stop_reason": null }, "terminals": [ { "run_id": "plugin:guard-hook:guard:1", "hook_key": "plugin:guard-hook:guard", "event": "pre_tool_use", "status_message": "Checking shell command", "system_message": null, "status": "completed", "duration_ms": 346, "exit_code": 0, "effects": [], "stdout": "", "stderr": "", "error": null } ], "records": 2}To see the hook in a session, start a new session after approving and ask
for something that runs rm -rf. The tool call is refused and the model
receives the reason line, which is what the decision above records.
Adapt it
Section titled “Adapt it”- To protect more paths, add lines to
hooks/protected-paths.txt, then runmuse plugins update guard-hook. The package digest changes, so the hook returns tomodified; approve it again and start a new session. - To block other patterns, add a
casearm next to therm -rfrule. Keep the stderr message to one line that says what was matched. - To attach advice without blocking, write a JSON object to stdout on exit 0 instead. The schema is closed, so read Hook events and payloads before you try it.
Remove it
Section titled “Remove it”muse plugins remove guard-hookremoved guard-hookThis plugin never writes to its data directory, so --delete-data is not
needed. Removing forgets the approval; a reinstalled plugin starts at
review_needed.
Next steps
Section titled “Next steps”- Hooks explains the hook process environment, the events that can block, and how exit codes and JSON output are read.
- Hook events and payloads lists every event, the stdin keys it delivers and the output fields it accepts.
- Trust, review and scopes explains why the hook needed approval and what the approval binds to.
- The weather plugin shows a
PostToolUsehook that observes instead of blocking. - The
muse pluginscommand documentshook testand every other command used on this page.