- name
- security-guard
- description
- Generate or update security guardrail hooks. Use when: /harn:guard, 'update security rules', 'block dangerous commands', 'add guardrail'
# Security Guard Generator
Generate or update `scripts/harness/security_guard.py` for the current project — a PreToolUse hook that blocks a narrow set of catastrophic shell commands before they run.
## Scope, stated honestly
This is NOT a shell sandbox. It tokenizes one command line with `shlex`; it does not parse shell programs. What it covers reliably: recursive deletes aimed at filesystem roots and home, pushes to protected branches, a network fetch piped into a shell, truncating redirects onto home dotfiles, and a short list of literal footguns (`mkfs.`, `dd if=`, `chmod 777`). What still slips through: wrapper commands (`env`, `xargs`), variable expansion, globs, and command substitution — closing those needs a real shell AST, which the stdlib-only constraint rules out. The generated stderr says exactly what the guard covers, because overclaiming is worse than the gap.
## Why a rule set, not a regex list
Earlier versions of this template took a list of regex patterns. That shipped two failure modes:
- A bare `rm\s+-rf` pattern blocks `rm -rf build`, `rm -rf node_modules`, and every other legitimate cleanup. A guard that blocks real work gets disabled, and a disabled guard is worse than a narrow one.
- Flag reorderings slip past: `rm -fr /`, `rm -r -f /`, and `rm --recursive /` never match `rm\s+-rf`.
Rules that understand command structure replace the pattern strings. The guard tokenizes the command, strips privilege wrappers (`sudo`/`doas`), splits on separators and newlines, then checks each segment against a fixed set of rules. A recursive delete of a relative or project path is allowed; only deletes aimed at a root or home are blocked.
## Process
1. Read the current `scripts/harness/security_guard.py` if it exists.
2. Generate the script from the template below. Adjust `CATASTROPHIC_RM_TARGETS` and `LITERAL_BLOCKS` for project-specific footguns — those two sets are the extension points.
3. Make it executable.
4. Verify it is wired in `.claude/settings.json` as a PreToolUse hook on the `Bash` matcher.
5. Generate `tests/test_security_guard.py` (see Tests) and run it. Both directions must pass: destructive forms exit 2, ordinary work exits 0.
## What it blocks
- Recursive delete (`rm -rf`, any flag order, `--recursive`, behind `sudo`/`doas`) against `/`, `~`/`$HOME`, `/*`, and system or user roots: `/usr /etc /var /bin /lib /sbin /opt /root /boot /dev /Users /home /System /Library`.
- `git push` targeting `main` or `master` — any refspec, including `HEAD:main`, force `+main`, and pushes past global flags like `git -C dir push`.
- A network fetch piped into a shell: `curl … | sh`, `wget … | bash`.
- A truncating redirect onto a home dotfile or home root: `> ~/.zshrc`, `> $HOME/…`.
- Literal footguns: `mkfs.`, `dd if=`, `chmod 777`.
Unspaced operators (`echo hi&&rm -rf /`) and newlines are split, so a destructive command hidden behind a benign first one is still seen.
## Extending it
- New catastrophic delete target: add its normalized form to `CATASTROPHIC_RM_TARGETS`.
- New literal footgun: add a `(substring, reason)` pair to `LITERAL_BLOCKS`.
- Every added rule needs an ALLOW test proving it does not block legitimate work — false positives are what get a guard turned off.
## Template
Generate `scripts/harness/security_guard.py` using this structure:
```python
#!/usr/bin/env python3
"""PreToolUse hook — blocks a narrow set of catastrophic shell commands.
Why: https://harn.app/kb/safety.html — "Tools should be hard to misuse"
Docs: https://harn.app/kb/safety.html — "Mitigating Prompt Injection Attacks"
SCOPE, stated honestly. This is NOT a shell sandbox and does not try to be.
`shlex` tokenizes command lines; it does not parse shell programs. Wrappers
(`env`, `xargs`), variable expansion (`$X`), globs and command substitution
can still carry a destructive command past this check. Blocking those needs a
real shell AST, which the stdlib-only constraint rules out.
What it DOES cover, reliably: literal catastrophic `rm` against filesystem
roots and home, plus a short list of other literal footguns. That is worth
having as a reflex arc, and overclaiming it would be worse than the gap.
Command separators (`;`, `&&`, `||`, `|`) and newlines ARE split and each
segment is checked, so a destructive command hidden behind a benign one is
still seen. What still slips: wrappers (`env`, `xargs`), variable expansion
(`$X`), globs, and command substitution — those need a real shell AST.
PreToolUse payload decision
───────────────────── ────────────────────────────────
tool_name != Bash ───▶ exit 0 (not our business)
unparseable / no command ───▶ exit 0 (unknown input, never block)
tokens match a rule ───▶ exit 2 + reason on stderr + log
otherwise ───▶ exit 0
"""
from __future__ import annotations
import json
import os
import re
import shlex
import sys
from datetime import datetime, timezone
# Targets that must never be recursively removed. Compared after normalizing
# the token, so "/", "/.", "//" and "~/" all collapse into these.
CATASTROPHIC_RM_TARGETS = {
"/", "~", "/*", "*", ".", "..",
"/usr", "/etc", "/var", "/bin", "/lib", "/sbin",
"/opt", "/root", "/boot", "/dev",
"/Users", "/home", # user-data roots (macOS, Linux)
"/System", "/Library", # macOS system roots
}
RECURSIVE_FLAGS = {"r", "R", "recursive"}
FORCE_FLAGS = {"f", "force"}
# sudo/doas options that consume the FOLLOWING token as their value. Every
# other flag is boolean, so consuming the next token would eat the wrapped
# command name (that was the `sudo -n rm -rf /` bypass).
VALUE_TAKING_SHORT = set("ughpCrtUTRD")
VALUE_TAKING_LONG = {
"user", "group", "host", "prompt", "role", "type",
"other-user", "command-timeout", "chroot", "chdir", "close-from",
}
# Cap on command text written to the block log (see _log_block).
LOG_COMMAND_MAX_CHARS = 500
# Inline secrets to mask before a blocked command is persisted to disk.
_SECRET_RE = re.compile(
r"(?i)(authorization:\s*(?:bearer\s+)?\S+"
r"|bearer\s+\S+"
r"|(?:api[_-]?key|token|password|passwd|secret)[=:]\s*\S+"
r"|https?://[^/\s:@]+:[^/\s@]+@)"
)
# Literal patterns that need no tokenizing to judge. Substring match on the
# raw command, deliberately simple: each is a distinctive string with no
# legitimate use in this project.
LITERAL_BLOCKS = (
("mkfs.", "formats a filesystem"),
("dd if=", "raw device write"),
("chmod 777", "world-writable permissions"),
)
def _redact(command: str) -> str:
"""Mask inline credentials so the block log never persists a live secret."""
return _SECRET_RE.sub("[REDACTED]", command)
def _log_block(command: str, reason: str) -> None:
"""Record the block outside the workspace where the agent cannot edit it.
Diagnostics only. A block count is NOT evidence of harness quality:
an agent that never tries anything destructive produces zero blocks.
The log dir and file are owner-only (0700/0600) and the command is
redacted, because a blocked command can carry a token or password.
"""
log_dir = os.environ.get("HARN_LOG_DIR") or os.path.join(
os.environ.get("TMPDIR", "/tmp"), "harn-logs"
)
try:
os.makedirs(log_dir, exist_ok=True)
try:
os.chmod(log_dir, 0o700)
except OSError:
pass
entry = {
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"hook": "security_guard",
"action": "block",
"reason": reason,
"command": _redact(command)[:LOG_COMMAND_MAX_CHARS],
"run_id": os.environ.get("HARN_RUN_ID", ""),
}
path = os.path.join(log_dir, "blocks.jsonl")
fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
with os.fdopen(fd, "a", encoding="utf-8") as fh:
fh.write(json.dumps(entry) + "\n")
except OSError:
pass # logging must never turn into a second failure mode
def _expand_home(token: str) -> str:
"""Expand a leading ~ or $HOME/${HOME} to the real home directory.
os.path.expanduser only handles ~, so `$HOME/` and `${HOME}/file` are
expanded here too — otherwise `rm -rf $HOME/` slipped past while `${HOME}`
was caught.
"""
home = os.path.expanduser("~")
if token in {"~", "$HOME", "${HOME}"}:
return home
for var in ("${HOME}", "$HOME"):
if token.startswith(var + "/"):
return home + token[len(var):]
return os.path.expanduser(token) if token.startswith("~") else token
def _normalize_target(token: str) -> str:
"""Collapse a path token toward its dangerous canonical form.
Uses normpath so `/.`, `//`, `/etc/` and `/tmp/..` all resolve to the
form the danger set is written in. `/tmp/..` resolving to `/` is not an
accident: recursively deleting it really does target the root.
"""
t = token.strip()
if t == "":
return ""
# Home forms, before normpath (which does not expand ~ or $HOME).
if t in {"~", "$HOME", "${HOME}"}:
return "~"
home = os.path.expanduser("~")
expanded = _expand_home(t)
if expanded == home or expanded.startswith(home):
collapsed = os.path.normpath(expanded)
if collapsed == home or collapsed == os.path.normpath(home):
return "~"
# `~/*` and `~/.` both mean "everything in home"
stripped = collapsed[:-2] if collapsed.endswith("/*") else collapsed
if stripped in {home, home + "/"}:
return "~"
return collapsed
# Preserve a trailing glob, which normpath would eat, then normalize.
glob_suffix = "/*" if t.endswith("/*") else ""
base = t[:-1] if glob_suffix else t # drop the trailing '*' (3.6-safe)
collapsed = os.path.normpath(base)
# normpath("//") returns "//" on POSIX (two leading slashes are
# implementation-defined), so collapse any all-slash result to root.
if collapsed and set(collapsed) == {"/"}:
collapsed = "/"
if glob_suffix and collapsed == "/":
return "/*"
return collapsed
def _strip_prefixes(tokens: list[str]) -> list[str]:
"""Drop leading privilege/env wrappers so `sudo rm -rf /` still matches.
Not a general wrapper defence (see the module docstring) — just the
handful of prefixes that appear in practice ahead of a destructive verb.
"""
out = list(tokens)
while out and os.path.basename(out[0]) in {"sudo", "doas", "command", "nohup"}:
out = out[1:]
# Skip the wrapper's own options. Only consume a following value token
# for flags that actually take one; a boolean flag (`-n`, `-E`) must
# NOT swallow the wrapped command name.
while out and out[0].startswith("-") and out[0] != "--":
tok = out[0]
out = out[1:]
if tok.startswith("--"):
name = tok[2:].split("=", 1)[0]
takes_value = name in VALUE_TAKING_LONG and "=" not in tok
else:
last = tok[-1]
attached = len(tok) > 2 and tok[1] in VALUE_TAKING_SHORT
takes_value = last in VALUE_TAKING_SHORT and not attached
if takes_value and out and not out[0].startswith("-"):
out = out[1:] # consume that flag's value
if out and out[0] == "--":
out = out[1:]
return out
def check_git_push(tokens: list[str]) -> str | None:
"""Block a push whose refspec targets main or master.
AGENTS.md: "Never push to main — create feat/ or fix/ branches."
"""
if len(tokens) < 2 or os.path.basename(tokens[0]) != "git":
return None
if "push" not in tokens[1:]:
return None
# `push` can sit past global options (`git -C dir push`, `git -c k=v push`),
# so locate it rather than assuming position 1. Only the refspec args AFTER
# push name the destination branch.
idx = tokens.index("push")
for tok in tokens[idx + 1:]:
if tok.startswith("-"):
continue
base = tok.rsplit(":", 1)[-1].rsplit("/", 1)[-1].lstrip("+")
if base in {"main", "master"}:
return f"git push targeting {base!r}"
return None
def _split_segments(tokens: list[str], separators: set[str]) -> list[list[str]]:
"""Break a token stream into segments on the given separator tokens."""
segments: list[list[str]] = [[]]
for tok in tokens:
if tok in separators:
segments.append([])
else:
segments[-1].append(tok)
return segments
def check_pipe_to_shell(tokens: list[str]) -> str | None:
"""Block `curl ... | sh` and `wget ... | bash` — remote code execution."""
if "|" not in tokens:
return None
fetchers = {"curl", "wget", "fetch"}
View on GitHub