Skip to main content

security-guard

Generate or update security guardrail hooks. Use when: /harn:guard, 'update security rules', 'block dangerous commands', 'add guardrail'

Source facts

Repository
sliday/harn
Last source activity
September 1, 2026 at 18:00
Detected SKILL.md language
English
Stars
5
Forks
1

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub