Skip to main content

security-guard

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

معلومات المصدر

المستودع
sliday/harn
آخر نشاط في المصدر
١ سبتمبر ٢٠٢٦ في ١٨:٠٠
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
٥
التفرعات
١

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
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"}
عرض على GitHub
ملف SKILL.md هذا كبير جدا، لذلك يعرض SkillsMP القسم الاول فقط هنا. عرض على GitHub