Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6fe3db1
fix(agents): stop asking for commands the hooks can prove safe
furedea Oct 19, 2026
2bd208e
fix(agents): close option and rm bypasses found in review
furedea Oct 19, 2026
da72615
fix(agents): judge request options on decoded arguments
furedea Oct 19, 2026
9c80ab8
fix(agents): judge curl and wget on their arguments, not on text
furedea Oct 19, 2026
2b81edd
fix(agents): find spreading expansions in the syntax tree
furedea Oct 19, 2026
14cd610
fix(agents): ask for every remote curl and wget request
furedea Oct 19, 2026
d6f0c0f
fix(agents): give wget its own options and see compound redirects
furedea Oct 19, 2026
31652b6
fix(agents): resolve rm targets against the tool workdir
furedea Oct 19, 2026
060d82b
build(agents): list the new hook libraries as first-party for deptry
furedea Oct 19, 2026
f4892fe
fix(agents): narrow rm, functions, and local requests to what the hoo…
furedea Oct 19, 2026
c142ca6
fix(agents): shrink the relaxations to what the hook can prove
furedea Oct 19, 2026
7d63c1e
fix(agents): ask for line continuations and any unquoted brace
furedea Oct 19, 2026
3e45203
fix(agents): allow rm only for absolute temporary paths
furedea Oct 19, 2026
785b450
fix(agents): trust only plain rm paths, real temp roots, and main's g…
furedea Oct 19, 2026
0016fda
fix(agents): keep main's denials for escaped and unsettled rm trees
furedea Oct 19, 2026
c029c72
fix(agents): take rm out of this change and hold Herdr's new forms to…
furedea Oct 19, 2026
d96ac05
fix(agents): restore main's rm rules and describe the curl and wget a…
furedea Oct 19, 2026
e27369b
fix(agents): treat any expansion as non-literal for the literal_words…
furedea Oct 19, 2026
ad0420d
fix(agents): judge literal_words rules on the whole command text
furedea Oct 19, 2026
690bc01
fix(agents): skip single-quoted text in the literal_words check
furedea Oct 19, 2026
2f3ba45
fix(agents): apply literal_words rules only to one plain gh or herdr …
furedea Oct 19, 2026
f819d44
fix(agents): keep assignments and redirects out of the literal_words …
furedea Oct 19, 2026
4ddab4b
fix(agents): close Herdr's global options and local requests that lea…
furedea Oct 19, 2026
734a0fe
fix(agents): ask for every wget and allow local curl only as one plai…
furedea Oct 19, 2026
b0c98da
fix(agents): let local curl clusters hold only value-less flags
furedea Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions agents/claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@
"allowedDomains": [
"api.day.app",
"channels.nixos.org",
"files.pythonhosted.org",
"pypi.org",
"registry.npmjs.org"
],
"allowUnixSockets": [
Expand Down
40 changes: 38 additions & 2 deletions agents/command_permissions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 requests to local servers; the command hook asks before any remote request, which can send local data."
},
{
"decision": "allow",
"prefix": [
"wget"
],
"justification": "Allow requests to local servers only; the sandbox and the precise rules keep remote fetches out."
"justification": "Allow requests to local servers; the command hook asks before any remote request, which can send local data."
},
{
"decision": "allow",
Expand Down Expand Up @@ -2403,6 +2403,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": [
Expand Down
145 changes: 119 additions & 26 deletions agents/hooks/guard_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
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
import re
import sys


Expand All @@ -19,17 +20,43 @@

import audit_log
import command_policy
import herdr_grammar
import hook_decision
from hook_decision import Verdict
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.
# 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.
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)


@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)
Expand Down Expand Up @@ -69,37 +96,74 @@ 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.
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)
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
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: 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):
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 command_policy.regex_reason(candidate.static_words_text, rules):
if governed is None or (
not candidate.appends_input and not candidate.hides_options and is_listed(candidate, policy.listing_rules)
):
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 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"{REMOTE_REASON}\n\nCommand: {shell_syntax.command_preview(command)}", "settings")


def decide(kind: str, payload: dict, directory: Path = ROOT) -> Verdict:
"""Decide for an already decoded provider payload, recording every denial and escalation."""
command = ""
Expand All @@ -108,21 +172,50 @@ 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:
verdicts = (objection(kind, item, policy) for item in commands)
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}")
record(verdict, Attempt(kind, command, payload))
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.

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:
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 not allows_literal_rules(command, commands):
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":
return settings_objection(command, commands)
return verdict


def allows_literal_rules(command: str, commands: tuple[shell_syntax.Command, ...]) -> bool:
"""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
try:
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 False


def record(verdict: Verdict, attempt: Attempt) -> None:
if verdict.decision == "allow":
return
Expand Down
7 changes: 7 additions & 0 deletions agents/hooks/lib/command_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", ()))
Expand Down
85 changes: 85 additions & 0 deletions agents/hooks/lib/herdr_grammar.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading