Skip to content
Developer Preview

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.

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.

guard-hook/
├── .muse-plugin/
│ └── plugin.json
├── hooks/
│ ├── guard.sh
│ └── protected-paths.txt
└── fixtures/
├── allow-ls.json
├── block-protected-path.json
└── block-rm-rf.json

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

guard-hook/.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:

  • event is PreToolUse, the event that runs before a tool call and may block it. There is no matcher field: native hooks key on the event, and the script decides which tools it cares about.
  • command is 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, through MUSE_PLUGIN_ROOT.
  • timeoutMs is 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.
guard-hook/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 with HOME expanded, so both ~/.ssh and the same path with your home directory spelled out are caught.
guard-hook/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
;;
esac
done < "$list"
exit 0

What to look at:

  • Payload first. The script reads all of stdin into payload before doing anything else. A PreToolUse payload carries hook_event_name, session_id, cwd, tool_name, tool_input and tool_use_id, among other keys. The script uses tool_name and tool_input.command.
  • Tool names. The shell tool is bash on macOS and Linux and powershell on Windows. Any other tool exits 0 immediately, so the hook costs almost nothing on file reads and edits.
  • Plain sed instead of a JSON parser. The hook runs with a cleared environment plus PATH, so it should not depend on tools that may be missing. The -E pattern for command keeps escaped quotes inside the string, which is why echo "hi" > /etc/motd is still caught.
  • block is the whole blocking mechanism. One line on stderr, exit 2. There is no JSON to write for this case.
  • MUSE_PLUGIN_ROOT points 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 ~/.ssh mentions a protected path but has no writing verb, so it is allowed.

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.

guard-hook/fixtures/block-rm-rf.json
{
"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"
}
}
guard-hook/fixtures/block-protected-path.json
{
"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"
}
}
guard-hook/fixtures/allow-ls.json
{
"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.

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.

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:

Terminal window
muse plugins validate guard-hook
valid guard-hook native skills=0 commands=0 hooks=1 mcp=0 reminders=0 diagnostics=0

The --json form shows how the hook entry was read, including the defaults the manifest did not set:

Terminal window
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:

Terminal window
muse plugins install guard-hook
muse plugins inspect guard-hook
installed guard-hook 0.1.0 enabled=true trust=user-local provenance=native-local cache=<data dir>/plugins/cache/local/guard-hook/4c21946e…/package
warning third-party plugin: hooks require review before activation
guard-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…/package
warning third-party plugin: hooks require review before activation
runtime-capability plugin:guard-hook:hook:guard status=review_needed

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

Terminal window
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:

Terminal window
muse plugins approve guard-hook
muse plugins inspect guard-hook
approve plugin:guard-hook:hook:guard
guard-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…/package
warning third-party plugin: hooks require review before activation
runtime-capability plugin:guard-hook:hook:guard status=trusted_enabled

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

Terminal window
muse plugins hook test guard-hook:guard --fixture guard-hook/fixtures/block-rm-rf.json
hook-test guard-hook:guard status=blocked

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

Terminal window
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:

Terminal window
muse plugins hook test guard-hook:guard --fixture guard-hook/fixtures/block-protected-path.json
hook-test guard-hook:guard status=blocked

And the passing fixture goes through:

Terminal window
muse plugins hook test guard-hook:guard --fixture guard-hook/fixtures/allow-ls.json
muse plugins hook test guard-hook:guard --fixture guard-hook/fixtures/allow-ls.json --json
hook-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.

  • To protect more paths, add lines to hooks/protected-paths.txt, then run muse plugins update guard-hook. The package digest changes, so the hook returns to modified; approve it again and start a new session.
  • To block other patterns, add a case arm next to the rm -rf rule. 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.
Terminal window
muse plugins remove guard-hook
removed guard-hook

This plugin never writes to its data directory, so --delete-data is not needed. Removing forgets the approval; a reinstalled plugin starts at review_needed.

  • 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 PostToolUse hook that observes instead of blocking.
  • The muse plugins command documents hook test and every other command used on this page.