diff --git a/agentwire/hooks/damage-control/bash-tool-damage-control.py b/agentwire/hooks/damage-control/bash-tool-damage-control.py index 2b0617bc..7ffdb384 100755 --- a/agentwire/hooks/damage-control/bash-tool-damage-control.py +++ b/agentwire/hooks/damage-control/bash-tool-damage-control.py @@ -34,7 +34,7 @@ def log_escape(*args, **kwargs): pass # === BEGIN AGENTWIRE HOOK STAMP (generated — do not edit) === -AGENTWIRE_HOOK_STAMP = {"core_sha256": "8ac718b4dcd736c47aa732dbdc05bef73d068422fe67aa7dd835b99b6ce92833", "generated_at": "2026-10-19T00:12:37Z"} +AGENTWIRE_HOOK_STAMP = {"core_sha256": "33f11ebb4e4472aedb37164dcb4c5d1010c5472bc516028d9aaa2c12070bdebc", "generated_at": "2026-10-19T00:29:08Z"} # === END AGENTWIRE HOOK STAMP === # === BEGIN GENERATED FROM agentwire/safety/_core.py === """ @@ -63,7 +63,7 @@ def log_escape(*args, **kwargs): pass import sys import threading from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Iterator, List, Optional, Tuple try: import yaml @@ -97,7 +97,8 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": (r'>\s*{path}', "write"), (r'\btee\s+(?!.*-a).*{path}', "write"), (r'\bdd\s+.*\bof=\S*{path}', "write"), - (r'\brsync\s+.*\s+{path}', "write"), + # rsync writes its DESTINATION: the last operand (options may trail it) + (r'\brsync\b[^\n|;&]*\s["\']?{path}[^\s|;&]*(?:\s+--?[\w-]+(?:=\S*)?(?:\s+[^-\s]\S*)?)*\s*(?:$|[;&|#]|\d*[<>])', "write"), (r'\btar\s+(-c|--create)\S*\s+.*{path}', "write"), (r'\binstall\s+.*\s+{path}', "write"), ] @@ -263,9 +264,10 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": r')\s+(?:-\S+\s+|\d+[smhd]?\s+)*)*(?:\S*/)?' ) PROTECTED_WRITER_PATTERNS = [ - # ln/link replace the TARGET (last operand); using the path as the source - # (``ln -s /tmp/l``) is a read. - (r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{path}[^\s|;&]*\s*(?:$|[;&|#]|\d*[<>])', "write"), + # ln/link naming the path on EITHER side: as the target it is replaced, as + # the source it becomes a symlink / hardlink alias that a later write + # (``echo x > /tmp/l``) reaches the file through, outside this scan's view. + (r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{path}', "write"), # downloaders: output file flags (incl. log/header/cookie-jar sinks) (r'\bcurl\b' + _SEG + r'(?:\s-(?-i:[A-Za-z0-9]*[oDc])\s*|\s--(?:output|dump-header' r'|cookie-jar|trace|trace-ascii|stderr|etag-save)[\s=])["\']?{path}', "write"), @@ -315,6 +317,8 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": r'\bunzip\b' + _SEG + r'\s-d\s*["\']?{dir}' + _DIR_END, r'\b(?:cp|mv|install|ln)\b' + _SEG + r'(?:\s-(?-i:[A-Za-z]*t)\s*|\s--target-directory[=\s])["\']?{dir}' + _DIR_END, + # a link to the directory (``ln -s /tmp/d``) is an alias for every file in it + r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{dir}' + _DIR_END, ] _CONCAT_RE = _rx( @@ -3171,10 +3175,11 @@ def _extracts_into_protected_dir(command: str) -> Optional[str]: # concatenate (``'E'OF`` is the delimiter ``EOF``), and ANY quote or backslash # in it makes the body literal. _HEREDOC_WORD = ( - r"(?:'[^']*'|\"(?:[^\"\\]|\\.)*\"|\\.|\$'[^']*'|[^\s;&|<>()'\"\\$]+|\$(?!['\"]))+" + r"(?:'[^']*'|\$?\"(?:[^\"\\]|\\.)*\"|\\.|\$'[^']*'|[^\s;&|<>()'\"\\$]+|\$(?!['\"]))+" ) _HEREDOC_OPEN_RE = _rx(r"(?-?)[ \t]*(?P" + _HEREDOC_WORD + r")") _HEREDOC_PART = _rx(r"'([^']*)'|\"((?:[^\"\\]|\\.)*)\"|\\(.)|\$'([^']*)'|([^'\"\\]+)") +_LOCALE_QUOTE = _rx(r"'[^']*'|\$(?=\")") _RESCAN_DEPTH = 3 _RESCAN_FANOUT = 32 _RESCAN_BUDGET = 200 # derived commands scanned per top-level check @@ -3182,6 +3187,8 @@ def _extracts_into_protected_dir(command: str) -> Optional[str]: def _heredoc_delimiter(word: str) -> Tuple[str, bool]: """``(delimiter, body_is_literal)`` for a heredoc delimiter word.""" + # ``$"EOF"`` is a locale-translated string: bash quotes it like ``"EOF"`` + word = _LOCALE_QUOTE.sub(lambda m: "" if m.group(0) == '$' else m.group(0), word) parts = _HEREDOC_PART.findall(word) delim = "".join("".join(p) for p in parts) return delim, any(p[0] or p[1] or p[2] or p[3] for p in parts) or "'" in word or '"' in word @@ -4480,7 +4487,31 @@ def _brace_variants_of(command: str) -> List[str]: return brace_variants(command) or [] -_GLOB_TOKEN = _rx(r"(?()]*(?:[*?\[]|\$(?:\w|\{))[^\s'\"`;|&<>()]*") +_GLOB_RUN = _rx(r"[^\s'\"`;|&<>()]+") +_GLOB_MARK = _rx(r"[*?\[]|\$(?:\w|\{)") +_NOT_WORD_OR_DOLLAR = _rx(r"[^\w$]") +_WORD_OR_DOLLAR = _rx(r"[\w$'\"]") + + +def _glob_tokens(command: str) -> "Iterator[str]": + """Each unquoted word holding a glob / expansion mark, from where a word may start (#1132). + + One linear pass over the word runs. A single regex of the form + ``[^delims]*(?:mark)[^delims]*`` retries from every offset of a long run + that holds no mark, which made a 20 KB run of ``../`` or ``\\`` cost seconds + (#1111). The result is the same text: a word preceded by a word character, + ``$`` or a quote starts at its first character after a non-word one. + """ + for m in _GLOB_RUN.finditer(command): + run, begin = m.group(0), m.start() + start = 0 + if begin and _WORD_OR_DOLLAR.match(command[begin - 1]): + gap = _NOT_WORD_OR_DOLLAR.search(run) + if gap is None: + continue + start = gap.start() + 1 + if _GLOB_MARK.search(run, start): + yield run[start:] _EXPANSION = _rx(r"\$(?:\w+|\{[^}]*\})") _GLOB_PATH_CAP = 32 @@ -4546,19 +4577,19 @@ def _glob_variants(command: str) -> List[str]: run of 3+ characters (``rm *`` names nothing in particular). A component that is only an expansion (``~/.agentwire/$f``) stays a declared residual. """ - if not _GLOB_TOKEN.search(command): + tokens = list(_glob_tokens(command)) + if not tokens: return [] _, project = _protected_glob_candidates(os.path.expanduser("~")) out: List[str] = [] - for m in _GLOB_TOKEN.finditer(command): - tok = m.group(0) + for tok in tokens: if tok.startswith("/"): hits = _glob_hits(tok) else: rx = _token_regex(tok) hits = [] if rx is not None and re.search( - r"[A-Za-z0-9_.\-]{3,}", _EXPANSION.sub("", re.sub(r"[*?\[\]]", " ", tok)) + r"[A-Za-z0-9_.\-]{3,}", _EXPANSION.sub("", re.sub(r"\[[^\]]*\]|[*?\[\]]", " ", tok)) ): hits = [p for p in project if rx.fullmatch(p) or rx.fullmatch("x" + p)] for hit in hits: @@ -4581,9 +4612,21 @@ def _glob_hits(tok: str) -> List[str]: return hits +# A command's start: line start or after a separator / reserved word, then any +# wrappers and ``VAR=value`` prefixes. A prefix value may hold whitespace inside +# ``${…}`` / quotes (``A=${x:-\n} cd dir``), which a bare ``\S*`` cut short. +_CMD_START = ( + r"(?:^\s*|(?:\b(?:then|do|else|elif|if|while|until|time|coproc)\b|[;&|\n(){!])\s*)" + r"(?:(?:time|nohup|command|builtin|env|exec|sudo|!|\w+=(?:\$\{[^}]*\}|\"[^\"]*\"|'[^']*'|[^\s\"'$;&|()]|\$)*)\s+(?:-\S+\s+)*)*" +) +# ``cd dir >/dev/null 2>&1 &&``: redirects between the target and the separator +_CD_REDIRECTS = r"(?:\s+(?:\d*|&)(?:>>?|<&?|>&|>\|)\s*[^\s;&|()<>]+)*" _CD_RE = _rx( - r"(?:^\s*|(?:\b(?:then|do|else|elif|if|while|until|time|coproc)\b|[;&|\n(){!])\s*)(?:(?:time|nohup|command|builtin|env|exec|sudo|!|\w+=\S*)\s+(?:-\S+\s+)*)*(?:cd|pushd)\s+(?:--\s+)?(?P\"[^\"]*\"|'[^']*'|[^\s;&|()]+)\s*(?=&&|;|\|\||\n)" + _CMD_START + + r"(?:cd|pushd)\s+(?:--\s+)?(?P\"[^\"]*\"|'[^']*'|[^\s;&|()<>]+)" + _CD_REDIRECTS + r"\s*(?=&&|;|\|\||\n)" ) +# ``popd`` returns to a directory the command cannot see: an unresolvable cwd. +_POPD_RE = _rx(_CMD_START + r"popd\b[^\n;&|()]*\s*(?=&&|;|\|\||\n)") _REL_WORD = _rx(r"(?'\"=])([A-Za-z0-9_.][^\s;&|()'\"<>]*)") @@ -4656,6 +4699,302 @@ def _absolutize(text: str, cwd: str) -> str: return " ".join(w if _PUNCT_WORD.fullmatch(w) else _quote_word(w) for w in resolved) +_ASSIGN_STMT = _rx( + r"(?:^|[;&|\n({]|\b(?:then|do|else))[ \t]*(?:(?:export|local|declare|readonly)[ \t]+)?(?P[A-Za-z_]\w*)=(?P(?:'[^']*'|\"[^\"]*\"|[^\s;&|()<>'\"])*)(?=[ \t]*(?:[;&|\n]|$))" +) +_VAR_REF = _rx(r"\$(?:(?P[A-Za-z_]\w*)|\{(?P[A-Za-z_]\w*)\})") + + +def _literal_vars(prefix: str) -> Dict[str, Optional[str]]: + """``NAME=value`` statements in ``prefix``: the literal value, or None when it is computed. + + The last assignment wins, so a later ``d=$(…)`` makes ``d`` unknown again. + """ + out: Dict[str, Optional[str]] = {} + for m in _ASSIGN_STMT.finditer(prefix): + lit = re.sub(r"['\"]", "", m.group("v")) + out[m.group("n")] = None if re.search(r"[$`]", lit) else lit + return out + + +def _expand_literal_vars(text: str, known: Dict[str, Optional[str]]) -> str: + """``text`` with each ``$name`` / ``${name}`` a same-command literal assignment defines replaced.""" + def _sub(m: "re.Match[str]") -> str: + val = known.get(m.group("a") or m.group("b")) + return m.group(0) if val is None else val + + return _VAR_REF.sub(_sub, text) + + +# Options that make the command run in another directory, per command word: +# ``(short options, long options, stop-at-first-operand)``. ``git -C`` / ``env +# -C`` / ``patch -d`` change the cwd of the command itself, which is what a +# relative operand (or a diff's own target paths) is resolved against. +_CHDIR_OPTIONS = { + "git": (("-C",), ("--work-tree",), False), + "env": (("-C",), ("--chdir",), True), + "patch": (("-d",), ("--directory",), False), +} +_CHDIR_HINT = _rx( + r"\bgit\b[^\n]*(?:\s-C\b|--work-tree)|\benv\b[^\n]*(?:\s-[A-Za-z]*C|--chdir)|\bpatch\b[^\n]*(?:\s-d|--directory)" +) + + +_ENV_CLUSTER = _rx(r"-[A-Za-z]+C") +_GROUP_WORDS = frozenset({"{", "}", "!", "then", "do", "else", "elif", "if", "while", "until", "time", "fi", "done"}) + + +def _segment_words(seg: str) -> List[str]: + """Words of one simple command, without the group / reserved words around it.""" + words = _words(seg) + while words and words[0] in _GROUP_WORDS: + words = words[1:] + while words and words[-1] in _GROUP_WORDS: + words = words[:-1] + return words + + +_UNESCAPE_DQ = _rx(r"\\([$`])") +_SHELL_C_FLAG = _rx(r"-[A-Za-z]*c[A-Za-z]*") + + +# Options that take a SEPARATE value, per wrapper. An attached value (``-n5``, +# ``--adjustment=5``, ``-uroot``) is one word and consumes nothing; a separate one +# (``-n 5``) must not be mistaken for the command. A wrapper not listed has no +# value-taking option here. +_WRAPPER_VALUE_OPTS = { + "nice": {"-n", "--adjustment"}, + "ionice": {"-c", "-n", "-p", "-P", "-u", "--class", "--classdata", "--pid", "--pgid", "--uid"}, + "timeout": {"-s", "-k", "--signal", "--kill-after"}, + "env": {"-u", "-C", "-S", "--unset", "--chdir", "--split-string"}, + "sudo": {"-u", "-g", "-h", "-p", "-C", "-D", "-R", "-T", "-U", "-r", "-t", "--user", "--group", "--host", "--prompt", "--chdir", "--role", "--type"}, + "doas": {"-u", "-C"}, + "stdbuf": {"-i", "-o", "-e", "--input", "--output", "--error"}, + "taskset": {"-c", "--cpu-list"}, + "chrt": set(), + "xargs": {"-n", "-I", "-L", "-P", "-s", "-d", "-E", "-a", "--max-args", "--replace", "--max-procs", "--delimiter", "--arg-file"}, + "time": {"-f", "-o", "--format", "--output"}, + "parallel": {"-j", "-n", "-N", "-S", "-a", "--jobs", "--sshlogin", "-I"}, + "sshpass": {"-p", "-f", "-d", "-P"}, +} +_POSITIONAL_ARG = _rx(r"\d+[smhd]?|0[xX][0-9a-fA-F]+") + + +def _wrapped_command_index(words: List[str]) -> Optional[int]: + """Index of the word that is the command, past prefix wrappers, their options and ``VAR=x``. + + ``sudo -u root timeout -s9 5 nice -n5 patch`` -> ``patch``. The one place + that knows which words keep the command position open + (``_POSITION_WRAPPERS`` + ``_PREFIX_WRAPPERS``) and which of their options + take a separate value (``_WRAPPER_VALUE_OPTS``). + """ + wrappers = _POSITION_WRAPPERS | _PREFIX_WRAPPERS + current = "" + skip_value = False + for i, w in enumerate(words): + if skip_value: + skip_value = False + elif os.path.basename(w) in wrappers: + current = os.path.basename(w) + elif _ASSIGN_TOKEN_RE.match(w) or _POSITIONAL_ARG.fullmatch(w): + continue + elif w.startswith("-"): + takes = _WRAPPER_VALUE_OPTS.get(current, ()) + # ``-n`` / ``--adjustment`` exactly, or a short cluster ending in a + # value option (``env -iC dir``); ``-n5`` / ``--adjustment=5`` carry theirs + skip_value = w in takes + if not skip_value and len(w) > 2 and w[1] != "-": + # a cluster: the first value-taking letter ends it, and takes the + # NEXT word only when it is the last letter (``-iC dir``, not ``-uroot``) + for k, ch in enumerate(w[1:], 1): + if "-" + ch in takes: + skip_value = k == len(w) - 1 + break + else: + return i + return None + + +def _wrapper_payload_commands(command: str) -> List[str]: + """Programs a command hands to a shell or ``ssh`` as one argument: ``sh -c '…'``, ``ssh host '…'``. + + The wrapped command is the command: the payload is re-scanned in its own + right, so a ``cd && touch `` inside it is resolved exactly as it + is at the top level (the generators that read directories only recognise a + ``cd`` at a command start, which a quote hides). + """ + if not re.search(r"\b(?:sh|bash|zsh|dash|ksh|su|ssh)\b", command): + return [] + segments, err = _scope_segments(command, grouping_ok=True) + if err: + return [] + out: List[str] = [] + for argv, _op in segments: + _, words = _split_assignments(argv) + words = [w for w in words if w not in _GROUP_WORDS] + remote = _ssh_remote_payload(words) + if remote: + while remote.startswith("-") and " " in remote: + remote = remote.split(" ", 1)[1] + out.append(remote) + i = _wrapped_command_index(words) + if i is not None and os.path.basename(words[i]) in _SHELL_NAMES: + for j in range(i + 1, len(words) - 1): + if _SHELL_C_FLAG.fullmatch(words[j]): + k = j + 1 + while k < len(words) - 1 and words[k] == "--": + k += 1 + out.append(words[k]) + break + return [fixed for payload in out for fixed in (payload, _UNESCAPE_DQ.sub(r"\1", payload)) if fixed] + + +def _chdir_option_split(words: List[str]) -> Tuple[List[str], List[str]]: + """``(directories, remaining words)`` for a command word that takes a chdir option.""" + start = 0 + while start < len(words) and _ASSIGN_WORD.match(words[start]): + start += 1 + kind = os.path.basename(words[start]) if start < len(words) else "" + if kind not in _CHDIR_OPTIONS: + return [], words + short, long_, stop_at_operand = _CHDIR_OPTIONS[kind] + dirs: List[str] = [] + rest: List[str] = [] + i = 0 + while i < len(words): + w = words[i] + if i > start and (w in short or (kind == "env" and _ENV_CLUSTER.fullmatch(w))) and i + 1 < len(words): + dirs.append(words[i + 1]) + i += 2 + continue + if i > start and kind != "git" and w[:2] in short and len(w) > 2 and not w.startswith("--"): + dirs.append(w[2:]) + elif i > start and any(w.startswith(o + "=") for o in long_): + dirs.append(w.partition("=")[2]) + elif i > start and w in long_ and i + 1 < len(words): + dirs.append(words[i + 1]) + i += 1 + else: + if stop_at_operand and i > start and not w.startswith("-") and not _ASSIGN_WORD.match(w): + rest = list(words[i:]) + break + rest.append(w) + i += 1 + return dirs, rest + + +def _cwd_scopes(command: str) -> Tuple[List[Tuple[str, str]], List[str]]: + """``(known, unknown)`` for every place the command changes directory (#1101). + + ``known`` pairs a literal directory with the text that runs in it — + ``cd &&`` (until the next ``cd``), ``git -C`` / ``--work-tree``, + ``env -C`` / ``--chdir`` and ``patch -d`` — with ``d=; cd $d`` resolved + through the same command's literal assignments. ``unknown`` holds the text + that runs after a ``cd`` the command cannot resolve (``cd -``, ``popd``, + ``cd $x`` with ``x`` set from a substitution or not at all). + """ + known: List[Tuple[str, str]] = [] + unknown: List[str] = [] + if re.search(r"\b(?:cd|pushd|popd)\b", command): + events = [(m.start(), m.end(), m.group("d").strip("'\"")) for m in _CD_RE.finditer(command)] + events += [(m.start(), m.end(), "-") for m in _POPD_RE.finditer(command)] + events.sort() + cwd: Optional[str] = None + for k, (start, stop, target) in enumerate(events): + end = events[k + 1][0] if k + 1 < len(events) else len(command) + rest = command[stop:end] + target = _expand_literal_vars(target, _literal_vars(command[:start])) + if target == "-" or re.search(r"[$`]", target): + cwd = None + unknown.append(rest) + continue + cwd = _join_dir(cwd, target) + if cwd: + known.append((cwd, rest)) + else: + unknown.append(rest) + if _CHDIR_HINT.search(command): + for seg in re.split(r"[;&|\n()]", command): + cwd = None + rest = _segment_words(seg) + for _ in range(4): # ``env -C a env -C b cmd`` nests + dirs, rest = _chdir_option_split(rest) + if not dirs: + break + for d in dirs: + cwd = _join_dir(cwd, d) + if cwd is None: + break + if cwd: + known.append((cwd, " ".join(_quote_word(w) for w in rest))) + return known, unknown + + +_PATH_WORD = _rx(r"(?()=])[^\s'\"`;|&<>()$*?\[\]{}]*[^\s'\"`;|&<>()$*?\[\]{}=-]") +_REALPATH_WORD_CAP = 64 + + +@functools.lru_cache(maxsize=4) +def _protected_inodes(home: str) -> Tuple[Tuple[str, "os.stat_result"], ...]: + """``(path, stat)`` of each concrete protected file that exists (hardlink detection).""" + out = [] + for pat in PROTECTED_CONTROL_PLANE_PATHS: + if is_glob_pattern(pat): + continue + path = os.path.expanduser(pat) + try: + out.append((path, os.stat(path))) + except OSError: + continue + return tuple(out) + + +def _under_protected(path: str) -> bool: + """Protected file, or a directory that holds / is inside one.""" + if is_protected_control_plane(path): + return True + for d in _protected_ancestor_dirs(): + e = os.path.expanduser(d) + if path == e or path.startswith(e + "/"): + return True + return False + + +def _realpath_variants(command: str) -> List[str]: + """The command with each path word that is an EXISTING alias of a protected file spelled as that file (#1101). + + The link was made by a separate command, an earlier turn, or someone else: + by the time ``echo x > /tmp/l`` runs, its text names nothing protected. The + hook can ask the filesystem. Every path word is resolved (a symlink anywhere + in it, a directory alias like ``/tmp/d/damagecontrol.yml`` included, and a + hardlink to a protected file); a word that lands on the control plane is + rewritten to where it really goes and the result is scanned as the command + it is. + """ + out: List[str] = [] + seen = set() + home = os.path.expanduser("~") + for m in itertools.islice(_PATH_WORD.finditer(command), _REALPATH_WORD_CAP): + word = m.group(0) + if word in seen or len(word) < 2: + continue + seen.add(word) + path = os.path.expanduser(word) + try: + if "/" not in path and not os.path.lexists(path): + continue + real = os.path.realpath(path) + target = real if real != posixpath.normpath(os.path.abspath(path)) and _under_protected(real) else None + if target is None and os.path.isfile(path) and os.stat(path).st_nlink > 1: + st = os.stat(path) + target = next((p for p, ps in _protected_inodes(home) if os.path.samestat(st, ps)), None) + except (OSError, ValueError): + continue + if target and target != path: + out.append(command.replace(word, _quote_word(target))) + return out + + def _cwd_variants(command: str) -> List[str]: """Relative operands resolved against a literal ``cd &&`` / ``git -C `` (#1101). @@ -4663,46 +5002,115 @@ def _cwd_variants(command: str) -> List[str]: to a directory set earlier in the same command. The literal directory is applied to later words (until the next ``cd``); each word that then names a protected file is rewritten to its absolute path and the result re-scanned. - An unresolvable directory (``cd $d``, a ``$(…)``, a bare ``cd -``) is the - declared residual, as is a cwd known only from the session. + A directory that cannot be resolved is judged by ``unverifiable_target_verdict``. """ - out: List[str] = [] - if re.search(r"\b(?:cd|pushd)\b", command): - cwd: Optional[str] = None - matches = list(_CD_RE.finditer(command)) - for k, m in enumerate(matches): - cwd = _join_dir(cwd, m.group("d").strip("'\"")) - if not cwd: + return [ + new + for cwd, rest in _cwd_scopes(command)[0] + for new in (_absolutize(rest, cwd),) + if new != rest + ] + + +def _protected_dirs() -> List[str]: + """Directories that hold a concrete protected file (``~/.agentwire``, ``~/.claude/hooks``, ...).""" + dirs = { + posixpath.dirname(os.path.expanduser(p)) + for p in PROTECTED_CONTROL_PLANE_PATHS + if not p.startswith("*") + } + return sorted(dirs) + + +# Names that are control plane in ANY directory, or that no other program uses. +# ``settings.json`` / ``config.yaml`` are protected where they sit under +# ``~/.claude`` / ``~/.agentwire`` but are ordinary files everywhere else, so +# a bare one after a ``cd`` the scanner cannot read is not evidence of anything. +_GENERIC_BASENAMES = frozenset({"settings.json", "config.yaml"}) +_PROJECT_PLACEHOLDER = "/hypothetical-project" + + +def _named_control_plane_files() -> List[Tuple[str, "frozenset[str]"]]: + """``(directory, distinctive basenames)`` a bare name after an unreadable ``cd`` could mean.""" + by_dir: Dict[str, set] = {} + for p in PROTECTED_CONTROL_PLANE_PATHS: + if p.startswith("*"): + d, name = _PROJECT_PLACEHOLDER, p[1:] + elif is_glob_pattern(p): + continue + else: + expanded = os.path.expanduser(p) + d, name = posixpath.dirname(expanded), posixpath.basename(expanded) + if name not in _GENERIC_BASENAMES: + by_dir.setdefault(d, set()).add(name) + return [(d, frozenset(names)) for d, names in sorted(by_dir.items())] + + +def unverifiable_target_verdict( + command: str, allowed_paths: List[Dict[str, Any]], depth: int = 0 +) -> Optional[str]: + """Why ``command`` needs a human, when what it writes to is unknowable and the control plane is in play. + + A ``cd`` the scanner cannot resolve (``cd -``, ``popd``, ``cd $x``) leaves a + later bare ``damagecontrol.yml`` pointing at any directory. Each protected + directory is tried as that directory; when the text that follows would be a + refused write there, the answer is an ASK, never a block (the directory may + well be somewhere else). A ``patch`` / ``git apply`` / ``git am`` whose + directory is a protected one is the same question: the files it rewrites + are named only inside the diff. So is a brace expansion past the cap in a + command that names the control plane: it cannot be enumerated, so it cannot + be shown clean. + """ + if depth < _RESCAN_DEPTH: + for inner in _wrapper_payload_commands(command) + _hidden_commands(command): + reason = unverifiable_target_verdict(inner, allowed_paths, depth + 1) + if reason: + return reason + if "{" in command and mentions_control_plane(command) and brace_variants(canonicalize_command(command)) is None: + return "a brace expansion past the cap cannot be enumerated" + known, unknown = _cwd_scopes(command) + for cwd, rest in known: + if cwd in _protected_dirs() or cwd == os.path.expanduser("~"): + if _applies_diff(rest): + return f"`patch` / `git apply` in {cwd} — the files it rewrites are named only in the diff" + for rest in unknown: + for d, names in _named_control_plane_files(): + new = _absolutize(rest, d) + if new == rest or not names.intersection(map(posixpath.basename, _words(new))): continue - end = matches[k + 1].start() if k + 1 < len(matches) else len(command) - rest = command[m.end() : end] - new = _absolutize(rest, cwd) - if new != rest: - out.append(new) - if re.search(r"\bgit\b[^\n]*(?:\s-C\b|--work-tree)", command): - for seg in re.split(r"[;&|\n]", command): - words = _words(seg) - dirs, rest, i = [], [], 0 - while i < len(words): - w = words[i] - if w == "-C" and i + 1 < len(words): - dirs.append(words[i + 1]) - i += 2 - continue - if w.startswith("--work-tree="): - dirs.append(w.partition("=")[2]) - elif w == "--work-tree" and i + 1 < len(words): - dirs.append(words[i + 1]) - i += 1 - else: - rest.append(w) - i += 1 - cwd = None - for d in dirs: - cwd = _join_dir(cwd, d) - if cwd and dirs: - out.append(_absolutize(" ".join(_quote_word(w) for w in rest), cwd)) - return out + if _check_protected_command(new, allowed_paths, 1, [_RESCAN_BUDGET])[0]: + return ( + "a `cd` the scanner cannot resolve precedes a write to a protected " + f"control-plane file name (it would be in {d} if the cd lands there)" + ) + return None + + +_DIFF_READ_ONLY = frozenset({"--dry-run", "--check", "--stat", "--numstat", "--summary"}) + + +def _applies_diff(text: str) -> bool: + """True when some command in ``text`` applies a diff for real (``patch``, ``git apply|am``). + + Judged per simple command: a ``--dry-run`` in one command does not excuse + the next, and the verb is matched by basename (``/usr/bin/patch``). + """ + for seg in re.split(r"[;&|\n()]", text): + words = _segment_words(seg) + i = _wrapped_command_index(words) + if i is None: + continue + head = os.path.basename(words[i]) + if head == "git": + j = i + 1 + while j < len(words) and words[j].startswith("-"): + j += 2 if words[j] in ("-C", "-c") else 1 + applies = j < len(words) and words[j] in ("apply", "am") + else: + applies = head == "patch" + if applies and not _DIFF_READ_ONLY.intersection(words[i:]): + return True + return False def _derived_commands(command: str) -> List[str]: @@ -4715,13 +5123,15 @@ def _derived_commands(command: str) -> List[str]: # judged as the program it is. visible = _hide_heredoc_bodies(command, keep_programs=True) if "<<" in command else command derived = ( - _heredoc_substitution_commands(command) + _wrapper_payload_commands(visible) + + _heredoc_substitution_commands(command) + _hidden_commands(command) + _redirect_variant(visible) + _normalized_path_variant(visible) + _brace_variants_of(visible) + _glob_variants(visible) + _cwd_variants(visible) + + _realpath_variants(visible) ) return list(dict.fromkeys(derived))[:_RESCAN_FANOUT] @@ -8184,6 +8594,20 @@ def _decide_rules( "protected": True, } + # A directory the scanner cannot see: same tier (exempt from the escape + # hatch and kill switch), but an ASK — the command may be harmless, and + # `unverifiable` keeps bypass from demoting it. + target_reason = unverifiable_target_verdict(command, control_plane_allowlist(allowed)) + if target_reason: + return { + "decision": "ask", + "reason": f"Unverifiable control-plane target ({target_reason}) — a human must confirm it (not demoted by bypass)", + "pattern": "ambiguous:control-plane-target", + "command": command, + "id": "core.control-plane-target", + "unverifiable": True, + } + # Parser unavailable ⇒ no rules loaded ⇒ fail closed (block), before the # escape hatch / kill switch can wave it through. if config.get("_parser_unavailable"): diff --git a/agentwire/hooks/damage-control/edit-tool-damage-control.py b/agentwire/hooks/damage-control/edit-tool-damage-control.py index 3a9de748..95519c8e 100755 --- a/agentwire/hooks/damage-control/edit-tool-damage-control.py +++ b/agentwire/hooks/damage-control/edit-tool-damage-control.py @@ -30,7 +30,7 @@ def log_disabled(*args, **kwargs): pass # === BEGIN AGENTWIRE HOOK STAMP (generated — do not edit) === -AGENTWIRE_HOOK_STAMP = {"core_sha256": "8ac718b4dcd736c47aa732dbdc05bef73d068422fe67aa7dd835b99b6ce92833", "generated_at": "2026-10-19T00:12:37Z"} +AGENTWIRE_HOOK_STAMP = {"core_sha256": "33f11ebb4e4472aedb37164dcb4c5d1010c5472bc516028d9aaa2c12070bdebc", "generated_at": "2026-10-19T00:29:08Z"} # === END AGENTWIRE HOOK STAMP === # === BEGIN GENERATED FROM agentwire/safety/_core.py === """ @@ -59,7 +59,7 @@ def log_disabled(*args, **kwargs): pass import sys import threading from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Iterator, List, Optional, Tuple try: import yaml @@ -93,7 +93,8 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": (r'>\s*{path}', "write"), (r'\btee\s+(?!.*-a).*{path}', "write"), (r'\bdd\s+.*\bof=\S*{path}', "write"), - (r'\brsync\s+.*\s+{path}', "write"), + # rsync writes its DESTINATION: the last operand (options may trail it) + (r'\brsync\b[^\n|;&]*\s["\']?{path}[^\s|;&]*(?:\s+--?[\w-]+(?:=\S*)?(?:\s+[^-\s]\S*)?)*\s*(?:$|[;&|#]|\d*[<>])', "write"), (r'\btar\s+(-c|--create)\S*\s+.*{path}', "write"), (r'\binstall\s+.*\s+{path}', "write"), ] @@ -259,9 +260,10 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": r')\s+(?:-\S+\s+|\d+[smhd]?\s+)*)*(?:\S*/)?' ) PROTECTED_WRITER_PATTERNS = [ - # ln/link replace the TARGET (last operand); using the path as the source - # (``ln -s /tmp/l``) is a read. - (r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{path}[^\s|;&]*\s*(?:$|[;&|#]|\d*[<>])', "write"), + # ln/link naming the path on EITHER side: as the target it is replaced, as + # the source it becomes a symlink / hardlink alias that a later write + # (``echo x > /tmp/l``) reaches the file through, outside this scan's view. + (r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{path}', "write"), # downloaders: output file flags (incl. log/header/cookie-jar sinks) (r'\bcurl\b' + _SEG + r'(?:\s-(?-i:[A-Za-z0-9]*[oDc])\s*|\s--(?:output|dump-header' r'|cookie-jar|trace|trace-ascii|stderr|etag-save)[\s=])["\']?{path}', "write"), @@ -311,6 +313,8 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": r'\bunzip\b' + _SEG + r'\s-d\s*["\']?{dir}' + _DIR_END, r'\b(?:cp|mv|install|ln)\b' + _SEG + r'(?:\s-(?-i:[A-Za-z]*t)\s*|\s--target-directory[=\s])["\']?{dir}' + _DIR_END, + # a link to the directory (``ln -s /tmp/d``) is an alias for every file in it + r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{dir}' + _DIR_END, ] _CONCAT_RE = _rx( @@ -3167,10 +3171,11 @@ def _extracts_into_protected_dir(command: str) -> Optional[str]: # concatenate (``'E'OF`` is the delimiter ``EOF``), and ANY quote or backslash # in it makes the body literal. _HEREDOC_WORD = ( - r"(?:'[^']*'|\"(?:[^\"\\]|\\.)*\"|\\.|\$'[^']*'|[^\s;&|<>()'\"\\$]+|\$(?!['\"]))+" + r"(?:'[^']*'|\$?\"(?:[^\"\\]|\\.)*\"|\\.|\$'[^']*'|[^\s;&|<>()'\"\\$]+|\$(?!['\"]))+" ) _HEREDOC_OPEN_RE = _rx(r"(?-?)[ \t]*(?P" + _HEREDOC_WORD + r")") _HEREDOC_PART = _rx(r"'([^']*)'|\"((?:[^\"\\]|\\.)*)\"|\\(.)|\$'([^']*)'|([^'\"\\]+)") +_LOCALE_QUOTE = _rx(r"'[^']*'|\$(?=\")") _RESCAN_DEPTH = 3 _RESCAN_FANOUT = 32 _RESCAN_BUDGET = 200 # derived commands scanned per top-level check @@ -3178,6 +3183,8 @@ def _extracts_into_protected_dir(command: str) -> Optional[str]: def _heredoc_delimiter(word: str) -> Tuple[str, bool]: """``(delimiter, body_is_literal)`` for a heredoc delimiter word.""" + # ``$"EOF"`` is a locale-translated string: bash quotes it like ``"EOF"`` + word = _LOCALE_QUOTE.sub(lambda m: "" if m.group(0) == '$' else m.group(0), word) parts = _HEREDOC_PART.findall(word) delim = "".join("".join(p) for p in parts) return delim, any(p[0] or p[1] or p[2] or p[3] for p in parts) or "'" in word or '"' in word @@ -4476,7 +4483,31 @@ def _brace_variants_of(command: str) -> List[str]: return brace_variants(command) or [] -_GLOB_TOKEN = _rx(r"(?()]*(?:[*?\[]|\$(?:\w|\{))[^\s'\"`;|&<>()]*") +_GLOB_RUN = _rx(r"[^\s'\"`;|&<>()]+") +_GLOB_MARK = _rx(r"[*?\[]|\$(?:\w|\{)") +_NOT_WORD_OR_DOLLAR = _rx(r"[^\w$]") +_WORD_OR_DOLLAR = _rx(r"[\w$'\"]") + + +def _glob_tokens(command: str) -> "Iterator[str]": + """Each unquoted word holding a glob / expansion mark, from where a word may start (#1132). + + One linear pass over the word runs. A single regex of the form + ``[^delims]*(?:mark)[^delims]*`` retries from every offset of a long run + that holds no mark, which made a 20 KB run of ``../`` or ``\\`` cost seconds + (#1111). The result is the same text: a word preceded by a word character, + ``$`` or a quote starts at its first character after a non-word one. + """ + for m in _GLOB_RUN.finditer(command): + run, begin = m.group(0), m.start() + start = 0 + if begin and _WORD_OR_DOLLAR.match(command[begin - 1]): + gap = _NOT_WORD_OR_DOLLAR.search(run) + if gap is None: + continue + start = gap.start() + 1 + if _GLOB_MARK.search(run, start): + yield run[start:] _EXPANSION = _rx(r"\$(?:\w+|\{[^}]*\})") _GLOB_PATH_CAP = 32 @@ -4542,19 +4573,19 @@ def _glob_variants(command: str) -> List[str]: run of 3+ characters (``rm *`` names nothing in particular). A component that is only an expansion (``~/.agentwire/$f``) stays a declared residual. """ - if not _GLOB_TOKEN.search(command): + tokens = list(_glob_tokens(command)) + if not tokens: return [] _, project = _protected_glob_candidates(os.path.expanduser("~")) out: List[str] = [] - for m in _GLOB_TOKEN.finditer(command): - tok = m.group(0) + for tok in tokens: if tok.startswith("/"): hits = _glob_hits(tok) else: rx = _token_regex(tok) hits = [] if rx is not None and re.search( - r"[A-Za-z0-9_.\-]{3,}", _EXPANSION.sub("", re.sub(r"[*?\[\]]", " ", tok)) + r"[A-Za-z0-9_.\-]{3,}", _EXPANSION.sub("", re.sub(r"\[[^\]]*\]|[*?\[\]]", " ", tok)) ): hits = [p for p in project if rx.fullmatch(p) or rx.fullmatch("x" + p)] for hit in hits: @@ -4577,9 +4608,21 @@ def _glob_hits(tok: str) -> List[str]: return hits +# A command's start: line start or after a separator / reserved word, then any +# wrappers and ``VAR=value`` prefixes. A prefix value may hold whitespace inside +# ``${…}`` / quotes (``A=${x:-\n} cd dir``), which a bare ``\S*`` cut short. +_CMD_START = ( + r"(?:^\s*|(?:\b(?:then|do|else|elif|if|while|until|time|coproc)\b|[;&|\n(){!])\s*)" + r"(?:(?:time|nohup|command|builtin|env|exec|sudo|!|\w+=(?:\$\{[^}]*\}|\"[^\"]*\"|'[^']*'|[^\s\"'$;&|()]|\$)*)\s+(?:-\S+\s+)*)*" +) +# ``cd dir >/dev/null 2>&1 &&``: redirects between the target and the separator +_CD_REDIRECTS = r"(?:\s+(?:\d*|&)(?:>>?|<&?|>&|>\|)\s*[^\s;&|()<>]+)*" _CD_RE = _rx( - r"(?:^\s*|(?:\b(?:then|do|else|elif|if|while|until|time|coproc)\b|[;&|\n(){!])\s*)(?:(?:time|nohup|command|builtin|env|exec|sudo|!|\w+=\S*)\s+(?:-\S+\s+)*)*(?:cd|pushd)\s+(?:--\s+)?(?P\"[^\"]*\"|'[^']*'|[^\s;&|()]+)\s*(?=&&|;|\|\||\n)" + _CMD_START + + r"(?:cd|pushd)\s+(?:--\s+)?(?P\"[^\"]*\"|'[^']*'|[^\s;&|()<>]+)" + _CD_REDIRECTS + r"\s*(?=&&|;|\|\||\n)" ) +# ``popd`` returns to a directory the command cannot see: an unresolvable cwd. +_POPD_RE = _rx(_CMD_START + r"popd\b[^\n;&|()]*\s*(?=&&|;|\|\||\n)") _REL_WORD = _rx(r"(?'\"=])([A-Za-z0-9_.][^\s;&|()'\"<>]*)") @@ -4652,6 +4695,302 @@ def _absolutize(text: str, cwd: str) -> str: return " ".join(w if _PUNCT_WORD.fullmatch(w) else _quote_word(w) for w in resolved) +_ASSIGN_STMT = _rx( + r"(?:^|[;&|\n({]|\b(?:then|do|else))[ \t]*(?:(?:export|local|declare|readonly)[ \t]+)?(?P[A-Za-z_]\w*)=(?P(?:'[^']*'|\"[^\"]*\"|[^\s;&|()<>'\"])*)(?=[ \t]*(?:[;&|\n]|$))" +) +_VAR_REF = _rx(r"\$(?:(?P[A-Za-z_]\w*)|\{(?P[A-Za-z_]\w*)\})") + + +def _literal_vars(prefix: str) -> Dict[str, Optional[str]]: + """``NAME=value`` statements in ``prefix``: the literal value, or None when it is computed. + + The last assignment wins, so a later ``d=$(…)`` makes ``d`` unknown again. + """ + out: Dict[str, Optional[str]] = {} + for m in _ASSIGN_STMT.finditer(prefix): + lit = re.sub(r"['\"]", "", m.group("v")) + out[m.group("n")] = None if re.search(r"[$`]", lit) else lit + return out + + +def _expand_literal_vars(text: str, known: Dict[str, Optional[str]]) -> str: + """``text`` with each ``$name`` / ``${name}`` a same-command literal assignment defines replaced.""" + def _sub(m: "re.Match[str]") -> str: + val = known.get(m.group("a") or m.group("b")) + return m.group(0) if val is None else val + + return _VAR_REF.sub(_sub, text) + + +# Options that make the command run in another directory, per command word: +# ``(short options, long options, stop-at-first-operand)``. ``git -C`` / ``env +# -C`` / ``patch -d`` change the cwd of the command itself, which is what a +# relative operand (or a diff's own target paths) is resolved against. +_CHDIR_OPTIONS = { + "git": (("-C",), ("--work-tree",), False), + "env": (("-C",), ("--chdir",), True), + "patch": (("-d",), ("--directory",), False), +} +_CHDIR_HINT = _rx( + r"\bgit\b[^\n]*(?:\s-C\b|--work-tree)|\benv\b[^\n]*(?:\s-[A-Za-z]*C|--chdir)|\bpatch\b[^\n]*(?:\s-d|--directory)" +) + + +_ENV_CLUSTER = _rx(r"-[A-Za-z]+C") +_GROUP_WORDS = frozenset({"{", "}", "!", "then", "do", "else", "elif", "if", "while", "until", "time", "fi", "done"}) + + +def _segment_words(seg: str) -> List[str]: + """Words of one simple command, without the group / reserved words around it.""" + words = _words(seg) + while words and words[0] in _GROUP_WORDS: + words = words[1:] + while words and words[-1] in _GROUP_WORDS: + words = words[:-1] + return words + + +_UNESCAPE_DQ = _rx(r"\\([$`])") +_SHELL_C_FLAG = _rx(r"-[A-Za-z]*c[A-Za-z]*") + + +# Options that take a SEPARATE value, per wrapper. An attached value (``-n5``, +# ``--adjustment=5``, ``-uroot``) is one word and consumes nothing; a separate one +# (``-n 5``) must not be mistaken for the command. A wrapper not listed has no +# value-taking option here. +_WRAPPER_VALUE_OPTS = { + "nice": {"-n", "--adjustment"}, + "ionice": {"-c", "-n", "-p", "-P", "-u", "--class", "--classdata", "--pid", "--pgid", "--uid"}, + "timeout": {"-s", "-k", "--signal", "--kill-after"}, + "env": {"-u", "-C", "-S", "--unset", "--chdir", "--split-string"}, + "sudo": {"-u", "-g", "-h", "-p", "-C", "-D", "-R", "-T", "-U", "-r", "-t", "--user", "--group", "--host", "--prompt", "--chdir", "--role", "--type"}, + "doas": {"-u", "-C"}, + "stdbuf": {"-i", "-o", "-e", "--input", "--output", "--error"}, + "taskset": {"-c", "--cpu-list"}, + "chrt": set(), + "xargs": {"-n", "-I", "-L", "-P", "-s", "-d", "-E", "-a", "--max-args", "--replace", "--max-procs", "--delimiter", "--arg-file"}, + "time": {"-f", "-o", "--format", "--output"}, + "parallel": {"-j", "-n", "-N", "-S", "-a", "--jobs", "--sshlogin", "-I"}, + "sshpass": {"-p", "-f", "-d", "-P"}, +} +_POSITIONAL_ARG = _rx(r"\d+[smhd]?|0[xX][0-9a-fA-F]+") + + +def _wrapped_command_index(words: List[str]) -> Optional[int]: + """Index of the word that is the command, past prefix wrappers, their options and ``VAR=x``. + + ``sudo -u root timeout -s9 5 nice -n5 patch`` -> ``patch``. The one place + that knows which words keep the command position open + (``_POSITION_WRAPPERS`` + ``_PREFIX_WRAPPERS``) and which of their options + take a separate value (``_WRAPPER_VALUE_OPTS``). + """ + wrappers = _POSITION_WRAPPERS | _PREFIX_WRAPPERS + current = "" + skip_value = False + for i, w in enumerate(words): + if skip_value: + skip_value = False + elif os.path.basename(w) in wrappers: + current = os.path.basename(w) + elif _ASSIGN_TOKEN_RE.match(w) or _POSITIONAL_ARG.fullmatch(w): + continue + elif w.startswith("-"): + takes = _WRAPPER_VALUE_OPTS.get(current, ()) + # ``-n`` / ``--adjustment`` exactly, or a short cluster ending in a + # value option (``env -iC dir``); ``-n5`` / ``--adjustment=5`` carry theirs + skip_value = w in takes + if not skip_value and len(w) > 2 and w[1] != "-": + # a cluster: the first value-taking letter ends it, and takes the + # NEXT word only when it is the last letter (``-iC dir``, not ``-uroot``) + for k, ch in enumerate(w[1:], 1): + if "-" + ch in takes: + skip_value = k == len(w) - 1 + break + else: + return i + return None + + +def _wrapper_payload_commands(command: str) -> List[str]: + """Programs a command hands to a shell or ``ssh`` as one argument: ``sh -c '…'``, ``ssh host '…'``. + + The wrapped command is the command: the payload is re-scanned in its own + right, so a ``cd && touch `` inside it is resolved exactly as it + is at the top level (the generators that read directories only recognise a + ``cd`` at a command start, which a quote hides). + """ + if not re.search(r"\b(?:sh|bash|zsh|dash|ksh|su|ssh)\b", command): + return [] + segments, err = _scope_segments(command, grouping_ok=True) + if err: + return [] + out: List[str] = [] + for argv, _op in segments: + _, words = _split_assignments(argv) + words = [w for w in words if w not in _GROUP_WORDS] + remote = _ssh_remote_payload(words) + if remote: + while remote.startswith("-") and " " in remote: + remote = remote.split(" ", 1)[1] + out.append(remote) + i = _wrapped_command_index(words) + if i is not None and os.path.basename(words[i]) in _SHELL_NAMES: + for j in range(i + 1, len(words) - 1): + if _SHELL_C_FLAG.fullmatch(words[j]): + k = j + 1 + while k < len(words) - 1 and words[k] == "--": + k += 1 + out.append(words[k]) + break + return [fixed for payload in out for fixed in (payload, _UNESCAPE_DQ.sub(r"\1", payload)) if fixed] + + +def _chdir_option_split(words: List[str]) -> Tuple[List[str], List[str]]: + """``(directories, remaining words)`` for a command word that takes a chdir option.""" + start = 0 + while start < len(words) and _ASSIGN_WORD.match(words[start]): + start += 1 + kind = os.path.basename(words[start]) if start < len(words) else "" + if kind not in _CHDIR_OPTIONS: + return [], words + short, long_, stop_at_operand = _CHDIR_OPTIONS[kind] + dirs: List[str] = [] + rest: List[str] = [] + i = 0 + while i < len(words): + w = words[i] + if i > start and (w in short or (kind == "env" and _ENV_CLUSTER.fullmatch(w))) and i + 1 < len(words): + dirs.append(words[i + 1]) + i += 2 + continue + if i > start and kind != "git" and w[:2] in short and len(w) > 2 and not w.startswith("--"): + dirs.append(w[2:]) + elif i > start and any(w.startswith(o + "=") for o in long_): + dirs.append(w.partition("=")[2]) + elif i > start and w in long_ and i + 1 < len(words): + dirs.append(words[i + 1]) + i += 1 + else: + if stop_at_operand and i > start and not w.startswith("-") and not _ASSIGN_WORD.match(w): + rest = list(words[i:]) + break + rest.append(w) + i += 1 + return dirs, rest + + +def _cwd_scopes(command: str) -> Tuple[List[Tuple[str, str]], List[str]]: + """``(known, unknown)`` for every place the command changes directory (#1101). + + ``known`` pairs a literal directory with the text that runs in it — + ``cd &&`` (until the next ``cd``), ``git -C`` / ``--work-tree``, + ``env -C`` / ``--chdir`` and ``patch -d`` — with ``d=; cd $d`` resolved + through the same command's literal assignments. ``unknown`` holds the text + that runs after a ``cd`` the command cannot resolve (``cd -``, ``popd``, + ``cd $x`` with ``x`` set from a substitution or not at all). + """ + known: List[Tuple[str, str]] = [] + unknown: List[str] = [] + if re.search(r"\b(?:cd|pushd|popd)\b", command): + events = [(m.start(), m.end(), m.group("d").strip("'\"")) for m in _CD_RE.finditer(command)] + events += [(m.start(), m.end(), "-") for m in _POPD_RE.finditer(command)] + events.sort() + cwd: Optional[str] = None + for k, (start, stop, target) in enumerate(events): + end = events[k + 1][0] if k + 1 < len(events) else len(command) + rest = command[stop:end] + target = _expand_literal_vars(target, _literal_vars(command[:start])) + if target == "-" or re.search(r"[$`]", target): + cwd = None + unknown.append(rest) + continue + cwd = _join_dir(cwd, target) + if cwd: + known.append((cwd, rest)) + else: + unknown.append(rest) + if _CHDIR_HINT.search(command): + for seg in re.split(r"[;&|\n()]", command): + cwd = None + rest = _segment_words(seg) + for _ in range(4): # ``env -C a env -C b cmd`` nests + dirs, rest = _chdir_option_split(rest) + if not dirs: + break + for d in dirs: + cwd = _join_dir(cwd, d) + if cwd is None: + break + if cwd: + known.append((cwd, " ".join(_quote_word(w) for w in rest))) + return known, unknown + + +_PATH_WORD = _rx(r"(?()=])[^\s'\"`;|&<>()$*?\[\]{}]*[^\s'\"`;|&<>()$*?\[\]{}=-]") +_REALPATH_WORD_CAP = 64 + + +@functools.lru_cache(maxsize=4) +def _protected_inodes(home: str) -> Tuple[Tuple[str, "os.stat_result"], ...]: + """``(path, stat)`` of each concrete protected file that exists (hardlink detection).""" + out = [] + for pat in PROTECTED_CONTROL_PLANE_PATHS: + if is_glob_pattern(pat): + continue + path = os.path.expanduser(pat) + try: + out.append((path, os.stat(path))) + except OSError: + continue + return tuple(out) + + +def _under_protected(path: str) -> bool: + """Protected file, or a directory that holds / is inside one.""" + if is_protected_control_plane(path): + return True + for d in _protected_ancestor_dirs(): + e = os.path.expanduser(d) + if path == e or path.startswith(e + "/"): + return True + return False + + +def _realpath_variants(command: str) -> List[str]: + """The command with each path word that is an EXISTING alias of a protected file spelled as that file (#1101). + + The link was made by a separate command, an earlier turn, or someone else: + by the time ``echo x > /tmp/l`` runs, its text names nothing protected. The + hook can ask the filesystem. Every path word is resolved (a symlink anywhere + in it, a directory alias like ``/tmp/d/damagecontrol.yml`` included, and a + hardlink to a protected file); a word that lands on the control plane is + rewritten to where it really goes and the result is scanned as the command + it is. + """ + out: List[str] = [] + seen = set() + home = os.path.expanduser("~") + for m in itertools.islice(_PATH_WORD.finditer(command), _REALPATH_WORD_CAP): + word = m.group(0) + if word in seen or len(word) < 2: + continue + seen.add(word) + path = os.path.expanduser(word) + try: + if "/" not in path and not os.path.lexists(path): + continue + real = os.path.realpath(path) + target = real if real != posixpath.normpath(os.path.abspath(path)) and _under_protected(real) else None + if target is None and os.path.isfile(path) and os.stat(path).st_nlink > 1: + st = os.stat(path) + target = next((p for p, ps in _protected_inodes(home) if os.path.samestat(st, ps)), None) + except (OSError, ValueError): + continue + if target and target != path: + out.append(command.replace(word, _quote_word(target))) + return out + + def _cwd_variants(command: str) -> List[str]: """Relative operands resolved against a literal ``cd &&`` / ``git -C `` (#1101). @@ -4659,46 +4998,115 @@ def _cwd_variants(command: str) -> List[str]: to a directory set earlier in the same command. The literal directory is applied to later words (until the next ``cd``); each word that then names a protected file is rewritten to its absolute path and the result re-scanned. - An unresolvable directory (``cd $d``, a ``$(…)``, a bare ``cd -``) is the - declared residual, as is a cwd known only from the session. + A directory that cannot be resolved is judged by ``unverifiable_target_verdict``. """ - out: List[str] = [] - if re.search(r"\b(?:cd|pushd)\b", command): - cwd: Optional[str] = None - matches = list(_CD_RE.finditer(command)) - for k, m in enumerate(matches): - cwd = _join_dir(cwd, m.group("d").strip("'\"")) - if not cwd: + return [ + new + for cwd, rest in _cwd_scopes(command)[0] + for new in (_absolutize(rest, cwd),) + if new != rest + ] + + +def _protected_dirs() -> List[str]: + """Directories that hold a concrete protected file (``~/.agentwire``, ``~/.claude/hooks``, ...).""" + dirs = { + posixpath.dirname(os.path.expanduser(p)) + for p in PROTECTED_CONTROL_PLANE_PATHS + if not p.startswith("*") + } + return sorted(dirs) + + +# Names that are control plane in ANY directory, or that no other program uses. +# ``settings.json`` / ``config.yaml`` are protected where they sit under +# ``~/.claude`` / ``~/.agentwire`` but are ordinary files everywhere else, so +# a bare one after a ``cd`` the scanner cannot read is not evidence of anything. +_GENERIC_BASENAMES = frozenset({"settings.json", "config.yaml"}) +_PROJECT_PLACEHOLDER = "/hypothetical-project" + + +def _named_control_plane_files() -> List[Tuple[str, "frozenset[str]"]]: + """``(directory, distinctive basenames)`` a bare name after an unreadable ``cd`` could mean.""" + by_dir: Dict[str, set] = {} + for p in PROTECTED_CONTROL_PLANE_PATHS: + if p.startswith("*"): + d, name = _PROJECT_PLACEHOLDER, p[1:] + elif is_glob_pattern(p): + continue + else: + expanded = os.path.expanduser(p) + d, name = posixpath.dirname(expanded), posixpath.basename(expanded) + if name not in _GENERIC_BASENAMES: + by_dir.setdefault(d, set()).add(name) + return [(d, frozenset(names)) for d, names in sorted(by_dir.items())] + + +def unverifiable_target_verdict( + command: str, allowed_paths: List[Dict[str, Any]], depth: int = 0 +) -> Optional[str]: + """Why ``command`` needs a human, when what it writes to is unknowable and the control plane is in play. + + A ``cd`` the scanner cannot resolve (``cd -``, ``popd``, ``cd $x``) leaves a + later bare ``damagecontrol.yml`` pointing at any directory. Each protected + directory is tried as that directory; when the text that follows would be a + refused write there, the answer is an ASK, never a block (the directory may + well be somewhere else). A ``patch`` / ``git apply`` / ``git am`` whose + directory is a protected one is the same question: the files it rewrites + are named only inside the diff. So is a brace expansion past the cap in a + command that names the control plane: it cannot be enumerated, so it cannot + be shown clean. + """ + if depth < _RESCAN_DEPTH: + for inner in _wrapper_payload_commands(command) + _hidden_commands(command): + reason = unverifiable_target_verdict(inner, allowed_paths, depth + 1) + if reason: + return reason + if "{" in command and mentions_control_plane(command) and brace_variants(canonicalize_command(command)) is None: + return "a brace expansion past the cap cannot be enumerated" + known, unknown = _cwd_scopes(command) + for cwd, rest in known: + if cwd in _protected_dirs() or cwd == os.path.expanduser("~"): + if _applies_diff(rest): + return f"`patch` / `git apply` in {cwd} — the files it rewrites are named only in the diff" + for rest in unknown: + for d, names in _named_control_plane_files(): + new = _absolutize(rest, d) + if new == rest or not names.intersection(map(posixpath.basename, _words(new))): continue - end = matches[k + 1].start() if k + 1 < len(matches) else len(command) - rest = command[m.end() : end] - new = _absolutize(rest, cwd) - if new != rest: - out.append(new) - if re.search(r"\bgit\b[^\n]*(?:\s-C\b|--work-tree)", command): - for seg in re.split(r"[;&|\n]", command): - words = _words(seg) - dirs, rest, i = [], [], 0 - while i < len(words): - w = words[i] - if w == "-C" and i + 1 < len(words): - dirs.append(words[i + 1]) - i += 2 - continue - if w.startswith("--work-tree="): - dirs.append(w.partition("=")[2]) - elif w == "--work-tree" and i + 1 < len(words): - dirs.append(words[i + 1]) - i += 1 - else: - rest.append(w) - i += 1 - cwd = None - for d in dirs: - cwd = _join_dir(cwd, d) - if cwd and dirs: - out.append(_absolutize(" ".join(_quote_word(w) for w in rest), cwd)) - return out + if _check_protected_command(new, allowed_paths, 1, [_RESCAN_BUDGET])[0]: + return ( + "a `cd` the scanner cannot resolve precedes a write to a protected " + f"control-plane file name (it would be in {d} if the cd lands there)" + ) + return None + + +_DIFF_READ_ONLY = frozenset({"--dry-run", "--check", "--stat", "--numstat", "--summary"}) + + +def _applies_diff(text: str) -> bool: + """True when some command in ``text`` applies a diff for real (``patch``, ``git apply|am``). + + Judged per simple command: a ``--dry-run`` in one command does not excuse + the next, and the verb is matched by basename (``/usr/bin/patch``). + """ + for seg in re.split(r"[;&|\n()]", text): + words = _segment_words(seg) + i = _wrapped_command_index(words) + if i is None: + continue + head = os.path.basename(words[i]) + if head == "git": + j = i + 1 + while j < len(words) and words[j].startswith("-"): + j += 2 if words[j] in ("-C", "-c") else 1 + applies = j < len(words) and words[j] in ("apply", "am") + else: + applies = head == "patch" + if applies and not _DIFF_READ_ONLY.intersection(words[i:]): + return True + return False def _derived_commands(command: str) -> List[str]: @@ -4711,13 +5119,15 @@ def _derived_commands(command: str) -> List[str]: # judged as the program it is. visible = _hide_heredoc_bodies(command, keep_programs=True) if "<<" in command else command derived = ( - _heredoc_substitution_commands(command) + _wrapper_payload_commands(visible) + + _heredoc_substitution_commands(command) + _hidden_commands(command) + _redirect_variant(visible) + _normalized_path_variant(visible) + _brace_variants_of(visible) + _glob_variants(visible) + _cwd_variants(visible) + + _realpath_variants(visible) ) return list(dict.fromkeys(derived))[:_RESCAN_FANOUT] @@ -8180,6 +8590,20 @@ def _decide_rules( "protected": True, } + # A directory the scanner cannot see: same tier (exempt from the escape + # hatch and kill switch), but an ASK — the command may be harmless, and + # `unverifiable` keeps bypass from demoting it. + target_reason = unverifiable_target_verdict(command, control_plane_allowlist(allowed)) + if target_reason: + return { + "decision": "ask", + "reason": f"Unverifiable control-plane target ({target_reason}) — a human must confirm it (not demoted by bypass)", + "pattern": "ambiguous:control-plane-target", + "command": command, + "id": "core.control-plane-target", + "unverifiable": True, + } + # Parser unavailable ⇒ no rules loaded ⇒ fail closed (block), before the # escape hatch / kill switch can wave it through. if config.get("_parser_unavailable"): diff --git a/agentwire/hooks/damage-control/mcp-tool-damage-control.py b/agentwire/hooks/damage-control/mcp-tool-damage-control.py index 9681d5c1..498e6f56 100755 --- a/agentwire/hooks/damage-control/mcp-tool-damage-control.py +++ b/agentwire/hooks/damage-control/mcp-tool-damage-control.py @@ -49,7 +49,7 @@ def log_escape(*args, **kwargs): pass # === BEGIN AGENTWIRE HOOK STAMP (generated — do not edit) === -AGENTWIRE_HOOK_STAMP = {"core_sha256": "8ac718b4dcd736c47aa732dbdc05bef73d068422fe67aa7dd835b99b6ce92833", "generated_at": "2026-10-19T00:12:37Z"} +AGENTWIRE_HOOK_STAMP = {"core_sha256": "33f11ebb4e4472aedb37164dcb4c5d1010c5472bc516028d9aaa2c12070bdebc", "generated_at": "2026-10-19T00:29:08Z"} # === END AGENTWIRE HOOK STAMP === # === BEGIN GENERATED FROM agentwire/safety/_core.py === """ @@ -78,7 +78,7 @@ def log_escape(*args, **kwargs): pass import sys import threading from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Iterator, List, Optional, Tuple try: import yaml @@ -112,7 +112,8 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": (r'>\s*{path}', "write"), (r'\btee\s+(?!.*-a).*{path}', "write"), (r'\bdd\s+.*\bof=\S*{path}', "write"), - (r'\brsync\s+.*\s+{path}', "write"), + # rsync writes its DESTINATION: the last operand (options may trail it) + (r'\brsync\b[^\n|;&]*\s["\']?{path}[^\s|;&]*(?:\s+--?[\w-]+(?:=\S*)?(?:\s+[^-\s]\S*)?)*\s*(?:$|[;&|#]|\d*[<>])', "write"), (r'\btar\s+(-c|--create)\S*\s+.*{path}', "write"), (r'\binstall\s+.*\s+{path}', "write"), ] @@ -278,9 +279,10 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": r')\s+(?:-\S+\s+|\d+[smhd]?\s+)*)*(?:\S*/)?' ) PROTECTED_WRITER_PATTERNS = [ - # ln/link replace the TARGET (last operand); using the path as the source - # (``ln -s /tmp/l``) is a read. - (r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{path}[^\s|;&]*\s*(?:$|[;&|#]|\d*[<>])', "write"), + # ln/link naming the path on EITHER side: as the target it is replaced, as + # the source it becomes a symlink / hardlink alias that a later write + # (``echo x > /tmp/l``) reaches the file through, outside this scan's view. + (r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{path}', "write"), # downloaders: output file flags (incl. log/header/cookie-jar sinks) (r'\bcurl\b' + _SEG + r'(?:\s-(?-i:[A-Za-z0-9]*[oDc])\s*|\s--(?:output|dump-header' r'|cookie-jar|trace|trace-ascii|stderr|etag-save)[\s=])["\']?{path}', "write"), @@ -330,6 +332,8 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": r'\bunzip\b' + _SEG + r'\s-d\s*["\']?{dir}' + _DIR_END, r'\b(?:cp|mv|install|ln)\b' + _SEG + r'(?:\s-(?-i:[A-Za-z]*t)\s*|\s--target-directory[=\s])["\']?{dir}' + _DIR_END, + # a link to the directory (``ln -s /tmp/d``) is an alias for every file in it + r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{dir}' + _DIR_END, ] _CONCAT_RE = _rx( @@ -3186,10 +3190,11 @@ def _extracts_into_protected_dir(command: str) -> Optional[str]: # concatenate (``'E'OF`` is the delimiter ``EOF``), and ANY quote or backslash # in it makes the body literal. _HEREDOC_WORD = ( - r"(?:'[^']*'|\"(?:[^\"\\]|\\.)*\"|\\.|\$'[^']*'|[^\s;&|<>()'\"\\$]+|\$(?!['\"]))+" + r"(?:'[^']*'|\$?\"(?:[^\"\\]|\\.)*\"|\\.|\$'[^']*'|[^\s;&|<>()'\"\\$]+|\$(?!['\"]))+" ) _HEREDOC_OPEN_RE = _rx(r"(?-?)[ \t]*(?P" + _HEREDOC_WORD + r")") _HEREDOC_PART = _rx(r"'([^']*)'|\"((?:[^\"\\]|\\.)*)\"|\\(.)|\$'([^']*)'|([^'\"\\]+)") +_LOCALE_QUOTE = _rx(r"'[^']*'|\$(?=\")") _RESCAN_DEPTH = 3 _RESCAN_FANOUT = 32 _RESCAN_BUDGET = 200 # derived commands scanned per top-level check @@ -3197,6 +3202,8 @@ def _extracts_into_protected_dir(command: str) -> Optional[str]: def _heredoc_delimiter(word: str) -> Tuple[str, bool]: """``(delimiter, body_is_literal)`` for a heredoc delimiter word.""" + # ``$"EOF"`` is a locale-translated string: bash quotes it like ``"EOF"`` + word = _LOCALE_QUOTE.sub(lambda m: "" if m.group(0) == '$' else m.group(0), word) parts = _HEREDOC_PART.findall(word) delim = "".join("".join(p) for p in parts) return delim, any(p[0] or p[1] or p[2] or p[3] for p in parts) or "'" in word or '"' in word @@ -4495,7 +4502,31 @@ def _brace_variants_of(command: str) -> List[str]: return brace_variants(command) or [] -_GLOB_TOKEN = _rx(r"(?()]*(?:[*?\[]|\$(?:\w|\{))[^\s'\"`;|&<>()]*") +_GLOB_RUN = _rx(r"[^\s'\"`;|&<>()]+") +_GLOB_MARK = _rx(r"[*?\[]|\$(?:\w|\{)") +_NOT_WORD_OR_DOLLAR = _rx(r"[^\w$]") +_WORD_OR_DOLLAR = _rx(r"[\w$'\"]") + + +def _glob_tokens(command: str) -> "Iterator[str]": + """Each unquoted word holding a glob / expansion mark, from where a word may start (#1132). + + One linear pass over the word runs. A single regex of the form + ``[^delims]*(?:mark)[^delims]*`` retries from every offset of a long run + that holds no mark, which made a 20 KB run of ``../`` or ``\\`` cost seconds + (#1111). The result is the same text: a word preceded by a word character, + ``$`` or a quote starts at its first character after a non-word one. + """ + for m in _GLOB_RUN.finditer(command): + run, begin = m.group(0), m.start() + start = 0 + if begin and _WORD_OR_DOLLAR.match(command[begin - 1]): + gap = _NOT_WORD_OR_DOLLAR.search(run) + if gap is None: + continue + start = gap.start() + 1 + if _GLOB_MARK.search(run, start): + yield run[start:] _EXPANSION = _rx(r"\$(?:\w+|\{[^}]*\})") _GLOB_PATH_CAP = 32 @@ -4561,19 +4592,19 @@ def _glob_variants(command: str) -> List[str]: run of 3+ characters (``rm *`` names nothing in particular). A component that is only an expansion (``~/.agentwire/$f``) stays a declared residual. """ - if not _GLOB_TOKEN.search(command): + tokens = list(_glob_tokens(command)) + if not tokens: return [] _, project = _protected_glob_candidates(os.path.expanduser("~")) out: List[str] = [] - for m in _GLOB_TOKEN.finditer(command): - tok = m.group(0) + for tok in tokens: if tok.startswith("/"): hits = _glob_hits(tok) else: rx = _token_regex(tok) hits = [] if rx is not None and re.search( - r"[A-Za-z0-9_.\-]{3,}", _EXPANSION.sub("", re.sub(r"[*?\[\]]", " ", tok)) + r"[A-Za-z0-9_.\-]{3,}", _EXPANSION.sub("", re.sub(r"\[[^\]]*\]|[*?\[\]]", " ", tok)) ): hits = [p for p in project if rx.fullmatch(p) or rx.fullmatch("x" + p)] for hit in hits: @@ -4596,9 +4627,21 @@ def _glob_hits(tok: str) -> List[str]: return hits +# A command's start: line start or after a separator / reserved word, then any +# wrappers and ``VAR=value`` prefixes. A prefix value may hold whitespace inside +# ``${…}`` / quotes (``A=${x:-\n} cd dir``), which a bare ``\S*`` cut short. +_CMD_START = ( + r"(?:^\s*|(?:\b(?:then|do|else|elif|if|while|until|time|coproc)\b|[;&|\n(){!])\s*)" + r"(?:(?:time|nohup|command|builtin|env|exec|sudo|!|\w+=(?:\$\{[^}]*\}|\"[^\"]*\"|'[^']*'|[^\s\"'$;&|()]|\$)*)\s+(?:-\S+\s+)*)*" +) +# ``cd dir >/dev/null 2>&1 &&``: redirects between the target and the separator +_CD_REDIRECTS = r"(?:\s+(?:\d*|&)(?:>>?|<&?|>&|>\|)\s*[^\s;&|()<>]+)*" _CD_RE = _rx( - r"(?:^\s*|(?:\b(?:then|do|else|elif|if|while|until|time|coproc)\b|[;&|\n(){!])\s*)(?:(?:time|nohup|command|builtin|env|exec|sudo|!|\w+=\S*)\s+(?:-\S+\s+)*)*(?:cd|pushd)\s+(?:--\s+)?(?P\"[^\"]*\"|'[^']*'|[^\s;&|()]+)\s*(?=&&|;|\|\||\n)" + _CMD_START + + r"(?:cd|pushd)\s+(?:--\s+)?(?P\"[^\"]*\"|'[^']*'|[^\s;&|()<>]+)" + _CD_REDIRECTS + r"\s*(?=&&|;|\|\||\n)" ) +# ``popd`` returns to a directory the command cannot see: an unresolvable cwd. +_POPD_RE = _rx(_CMD_START + r"popd\b[^\n;&|()]*\s*(?=&&|;|\|\||\n)") _REL_WORD = _rx(r"(?'\"=])([A-Za-z0-9_.][^\s;&|()'\"<>]*)") @@ -4671,6 +4714,302 @@ def _absolutize(text: str, cwd: str) -> str: return " ".join(w if _PUNCT_WORD.fullmatch(w) else _quote_word(w) for w in resolved) +_ASSIGN_STMT = _rx( + r"(?:^|[;&|\n({]|\b(?:then|do|else))[ \t]*(?:(?:export|local|declare|readonly)[ \t]+)?(?P[A-Za-z_]\w*)=(?P(?:'[^']*'|\"[^\"]*\"|[^\s;&|()<>'\"])*)(?=[ \t]*(?:[;&|\n]|$))" +) +_VAR_REF = _rx(r"\$(?:(?P[A-Za-z_]\w*)|\{(?P[A-Za-z_]\w*)\})") + + +def _literal_vars(prefix: str) -> Dict[str, Optional[str]]: + """``NAME=value`` statements in ``prefix``: the literal value, or None when it is computed. + + The last assignment wins, so a later ``d=$(…)`` makes ``d`` unknown again. + """ + out: Dict[str, Optional[str]] = {} + for m in _ASSIGN_STMT.finditer(prefix): + lit = re.sub(r"['\"]", "", m.group("v")) + out[m.group("n")] = None if re.search(r"[$`]", lit) else lit + return out + + +def _expand_literal_vars(text: str, known: Dict[str, Optional[str]]) -> str: + """``text`` with each ``$name`` / ``${name}`` a same-command literal assignment defines replaced.""" + def _sub(m: "re.Match[str]") -> str: + val = known.get(m.group("a") or m.group("b")) + return m.group(0) if val is None else val + + return _VAR_REF.sub(_sub, text) + + +# Options that make the command run in another directory, per command word: +# ``(short options, long options, stop-at-first-operand)``. ``git -C`` / ``env +# -C`` / ``patch -d`` change the cwd of the command itself, which is what a +# relative operand (or a diff's own target paths) is resolved against. +_CHDIR_OPTIONS = { + "git": (("-C",), ("--work-tree",), False), + "env": (("-C",), ("--chdir",), True), + "patch": (("-d",), ("--directory",), False), +} +_CHDIR_HINT = _rx( + r"\bgit\b[^\n]*(?:\s-C\b|--work-tree)|\benv\b[^\n]*(?:\s-[A-Za-z]*C|--chdir)|\bpatch\b[^\n]*(?:\s-d|--directory)" +) + + +_ENV_CLUSTER = _rx(r"-[A-Za-z]+C") +_GROUP_WORDS = frozenset({"{", "}", "!", "then", "do", "else", "elif", "if", "while", "until", "time", "fi", "done"}) + + +def _segment_words(seg: str) -> List[str]: + """Words of one simple command, without the group / reserved words around it.""" + words = _words(seg) + while words and words[0] in _GROUP_WORDS: + words = words[1:] + while words and words[-1] in _GROUP_WORDS: + words = words[:-1] + return words + + +_UNESCAPE_DQ = _rx(r"\\([$`])") +_SHELL_C_FLAG = _rx(r"-[A-Za-z]*c[A-Za-z]*") + + +# Options that take a SEPARATE value, per wrapper. An attached value (``-n5``, +# ``--adjustment=5``, ``-uroot``) is one word and consumes nothing; a separate one +# (``-n 5``) must not be mistaken for the command. A wrapper not listed has no +# value-taking option here. +_WRAPPER_VALUE_OPTS = { + "nice": {"-n", "--adjustment"}, + "ionice": {"-c", "-n", "-p", "-P", "-u", "--class", "--classdata", "--pid", "--pgid", "--uid"}, + "timeout": {"-s", "-k", "--signal", "--kill-after"}, + "env": {"-u", "-C", "-S", "--unset", "--chdir", "--split-string"}, + "sudo": {"-u", "-g", "-h", "-p", "-C", "-D", "-R", "-T", "-U", "-r", "-t", "--user", "--group", "--host", "--prompt", "--chdir", "--role", "--type"}, + "doas": {"-u", "-C"}, + "stdbuf": {"-i", "-o", "-e", "--input", "--output", "--error"}, + "taskset": {"-c", "--cpu-list"}, + "chrt": set(), + "xargs": {"-n", "-I", "-L", "-P", "-s", "-d", "-E", "-a", "--max-args", "--replace", "--max-procs", "--delimiter", "--arg-file"}, + "time": {"-f", "-o", "--format", "--output"}, + "parallel": {"-j", "-n", "-N", "-S", "-a", "--jobs", "--sshlogin", "-I"}, + "sshpass": {"-p", "-f", "-d", "-P"}, +} +_POSITIONAL_ARG = _rx(r"\d+[smhd]?|0[xX][0-9a-fA-F]+") + + +def _wrapped_command_index(words: List[str]) -> Optional[int]: + """Index of the word that is the command, past prefix wrappers, their options and ``VAR=x``. + + ``sudo -u root timeout -s9 5 nice -n5 patch`` -> ``patch``. The one place + that knows which words keep the command position open + (``_POSITION_WRAPPERS`` + ``_PREFIX_WRAPPERS``) and which of their options + take a separate value (``_WRAPPER_VALUE_OPTS``). + """ + wrappers = _POSITION_WRAPPERS | _PREFIX_WRAPPERS + current = "" + skip_value = False + for i, w in enumerate(words): + if skip_value: + skip_value = False + elif os.path.basename(w) in wrappers: + current = os.path.basename(w) + elif _ASSIGN_TOKEN_RE.match(w) or _POSITIONAL_ARG.fullmatch(w): + continue + elif w.startswith("-"): + takes = _WRAPPER_VALUE_OPTS.get(current, ()) + # ``-n`` / ``--adjustment`` exactly, or a short cluster ending in a + # value option (``env -iC dir``); ``-n5`` / ``--adjustment=5`` carry theirs + skip_value = w in takes + if not skip_value and len(w) > 2 and w[1] != "-": + # a cluster: the first value-taking letter ends it, and takes the + # NEXT word only when it is the last letter (``-iC dir``, not ``-uroot``) + for k, ch in enumerate(w[1:], 1): + if "-" + ch in takes: + skip_value = k == len(w) - 1 + break + else: + return i + return None + + +def _wrapper_payload_commands(command: str) -> List[str]: + """Programs a command hands to a shell or ``ssh`` as one argument: ``sh -c '…'``, ``ssh host '…'``. + + The wrapped command is the command: the payload is re-scanned in its own + right, so a ``cd && touch `` inside it is resolved exactly as it + is at the top level (the generators that read directories only recognise a + ``cd`` at a command start, which a quote hides). + """ + if not re.search(r"\b(?:sh|bash|zsh|dash|ksh|su|ssh)\b", command): + return [] + segments, err = _scope_segments(command, grouping_ok=True) + if err: + return [] + out: List[str] = [] + for argv, _op in segments: + _, words = _split_assignments(argv) + words = [w for w in words if w not in _GROUP_WORDS] + remote = _ssh_remote_payload(words) + if remote: + while remote.startswith("-") and " " in remote: + remote = remote.split(" ", 1)[1] + out.append(remote) + i = _wrapped_command_index(words) + if i is not None and os.path.basename(words[i]) in _SHELL_NAMES: + for j in range(i + 1, len(words) - 1): + if _SHELL_C_FLAG.fullmatch(words[j]): + k = j + 1 + while k < len(words) - 1 and words[k] == "--": + k += 1 + out.append(words[k]) + break + return [fixed for payload in out for fixed in (payload, _UNESCAPE_DQ.sub(r"\1", payload)) if fixed] + + +def _chdir_option_split(words: List[str]) -> Tuple[List[str], List[str]]: + """``(directories, remaining words)`` for a command word that takes a chdir option.""" + start = 0 + while start < len(words) and _ASSIGN_WORD.match(words[start]): + start += 1 + kind = os.path.basename(words[start]) if start < len(words) else "" + if kind not in _CHDIR_OPTIONS: + return [], words + short, long_, stop_at_operand = _CHDIR_OPTIONS[kind] + dirs: List[str] = [] + rest: List[str] = [] + i = 0 + while i < len(words): + w = words[i] + if i > start and (w in short or (kind == "env" and _ENV_CLUSTER.fullmatch(w))) and i + 1 < len(words): + dirs.append(words[i + 1]) + i += 2 + continue + if i > start and kind != "git" and w[:2] in short and len(w) > 2 and not w.startswith("--"): + dirs.append(w[2:]) + elif i > start and any(w.startswith(o + "=") for o in long_): + dirs.append(w.partition("=")[2]) + elif i > start and w in long_ and i + 1 < len(words): + dirs.append(words[i + 1]) + i += 1 + else: + if stop_at_operand and i > start and not w.startswith("-") and not _ASSIGN_WORD.match(w): + rest = list(words[i:]) + break + rest.append(w) + i += 1 + return dirs, rest + + +def _cwd_scopes(command: str) -> Tuple[List[Tuple[str, str]], List[str]]: + """``(known, unknown)`` for every place the command changes directory (#1101). + + ``known`` pairs a literal directory with the text that runs in it — + ``cd &&`` (until the next ``cd``), ``git -C`` / ``--work-tree``, + ``env -C`` / ``--chdir`` and ``patch -d`` — with ``d=; cd $d`` resolved + through the same command's literal assignments. ``unknown`` holds the text + that runs after a ``cd`` the command cannot resolve (``cd -``, ``popd``, + ``cd $x`` with ``x`` set from a substitution or not at all). + """ + known: List[Tuple[str, str]] = [] + unknown: List[str] = [] + if re.search(r"\b(?:cd|pushd|popd)\b", command): + events = [(m.start(), m.end(), m.group("d").strip("'\"")) for m in _CD_RE.finditer(command)] + events += [(m.start(), m.end(), "-") for m in _POPD_RE.finditer(command)] + events.sort() + cwd: Optional[str] = None + for k, (start, stop, target) in enumerate(events): + end = events[k + 1][0] if k + 1 < len(events) else len(command) + rest = command[stop:end] + target = _expand_literal_vars(target, _literal_vars(command[:start])) + if target == "-" or re.search(r"[$`]", target): + cwd = None + unknown.append(rest) + continue + cwd = _join_dir(cwd, target) + if cwd: + known.append((cwd, rest)) + else: + unknown.append(rest) + if _CHDIR_HINT.search(command): + for seg in re.split(r"[;&|\n()]", command): + cwd = None + rest = _segment_words(seg) + for _ in range(4): # ``env -C a env -C b cmd`` nests + dirs, rest = _chdir_option_split(rest) + if not dirs: + break + for d in dirs: + cwd = _join_dir(cwd, d) + if cwd is None: + break + if cwd: + known.append((cwd, " ".join(_quote_word(w) for w in rest))) + return known, unknown + + +_PATH_WORD = _rx(r"(?()=])[^\s'\"`;|&<>()$*?\[\]{}]*[^\s'\"`;|&<>()$*?\[\]{}=-]") +_REALPATH_WORD_CAP = 64 + + +@functools.lru_cache(maxsize=4) +def _protected_inodes(home: str) -> Tuple[Tuple[str, "os.stat_result"], ...]: + """``(path, stat)`` of each concrete protected file that exists (hardlink detection).""" + out = [] + for pat in PROTECTED_CONTROL_PLANE_PATHS: + if is_glob_pattern(pat): + continue + path = os.path.expanduser(pat) + try: + out.append((path, os.stat(path))) + except OSError: + continue + return tuple(out) + + +def _under_protected(path: str) -> bool: + """Protected file, or a directory that holds / is inside one.""" + if is_protected_control_plane(path): + return True + for d in _protected_ancestor_dirs(): + e = os.path.expanduser(d) + if path == e or path.startswith(e + "/"): + return True + return False + + +def _realpath_variants(command: str) -> List[str]: + """The command with each path word that is an EXISTING alias of a protected file spelled as that file (#1101). + + The link was made by a separate command, an earlier turn, or someone else: + by the time ``echo x > /tmp/l`` runs, its text names nothing protected. The + hook can ask the filesystem. Every path word is resolved (a symlink anywhere + in it, a directory alias like ``/tmp/d/damagecontrol.yml`` included, and a + hardlink to a protected file); a word that lands on the control plane is + rewritten to where it really goes and the result is scanned as the command + it is. + """ + out: List[str] = [] + seen = set() + home = os.path.expanduser("~") + for m in itertools.islice(_PATH_WORD.finditer(command), _REALPATH_WORD_CAP): + word = m.group(0) + if word in seen or len(word) < 2: + continue + seen.add(word) + path = os.path.expanduser(word) + try: + if "/" not in path and not os.path.lexists(path): + continue + real = os.path.realpath(path) + target = real if real != posixpath.normpath(os.path.abspath(path)) and _under_protected(real) else None + if target is None and os.path.isfile(path) and os.stat(path).st_nlink > 1: + st = os.stat(path) + target = next((p for p, ps in _protected_inodes(home) if os.path.samestat(st, ps)), None) + except (OSError, ValueError): + continue + if target and target != path: + out.append(command.replace(word, _quote_word(target))) + return out + + def _cwd_variants(command: str) -> List[str]: """Relative operands resolved against a literal ``cd &&`` / ``git -C `` (#1101). @@ -4678,46 +5017,115 @@ def _cwd_variants(command: str) -> List[str]: to a directory set earlier in the same command. The literal directory is applied to later words (until the next ``cd``); each word that then names a protected file is rewritten to its absolute path and the result re-scanned. - An unresolvable directory (``cd $d``, a ``$(…)``, a bare ``cd -``) is the - declared residual, as is a cwd known only from the session. + A directory that cannot be resolved is judged by ``unverifiable_target_verdict``. """ - out: List[str] = [] - if re.search(r"\b(?:cd|pushd)\b", command): - cwd: Optional[str] = None - matches = list(_CD_RE.finditer(command)) - for k, m in enumerate(matches): - cwd = _join_dir(cwd, m.group("d").strip("'\"")) - if not cwd: + return [ + new + for cwd, rest in _cwd_scopes(command)[0] + for new in (_absolutize(rest, cwd),) + if new != rest + ] + + +def _protected_dirs() -> List[str]: + """Directories that hold a concrete protected file (``~/.agentwire``, ``~/.claude/hooks``, ...).""" + dirs = { + posixpath.dirname(os.path.expanduser(p)) + for p in PROTECTED_CONTROL_PLANE_PATHS + if not p.startswith("*") + } + return sorted(dirs) + + +# Names that are control plane in ANY directory, or that no other program uses. +# ``settings.json`` / ``config.yaml`` are protected where they sit under +# ``~/.claude`` / ``~/.agentwire`` but are ordinary files everywhere else, so +# a bare one after a ``cd`` the scanner cannot read is not evidence of anything. +_GENERIC_BASENAMES = frozenset({"settings.json", "config.yaml"}) +_PROJECT_PLACEHOLDER = "/hypothetical-project" + + +def _named_control_plane_files() -> List[Tuple[str, "frozenset[str]"]]: + """``(directory, distinctive basenames)`` a bare name after an unreadable ``cd`` could mean.""" + by_dir: Dict[str, set] = {} + for p in PROTECTED_CONTROL_PLANE_PATHS: + if p.startswith("*"): + d, name = _PROJECT_PLACEHOLDER, p[1:] + elif is_glob_pattern(p): + continue + else: + expanded = os.path.expanduser(p) + d, name = posixpath.dirname(expanded), posixpath.basename(expanded) + if name not in _GENERIC_BASENAMES: + by_dir.setdefault(d, set()).add(name) + return [(d, frozenset(names)) for d, names in sorted(by_dir.items())] + + +def unverifiable_target_verdict( + command: str, allowed_paths: List[Dict[str, Any]], depth: int = 0 +) -> Optional[str]: + """Why ``command`` needs a human, when what it writes to is unknowable and the control plane is in play. + + A ``cd`` the scanner cannot resolve (``cd -``, ``popd``, ``cd $x``) leaves a + later bare ``damagecontrol.yml`` pointing at any directory. Each protected + directory is tried as that directory; when the text that follows would be a + refused write there, the answer is an ASK, never a block (the directory may + well be somewhere else). A ``patch`` / ``git apply`` / ``git am`` whose + directory is a protected one is the same question: the files it rewrites + are named only inside the diff. So is a brace expansion past the cap in a + command that names the control plane: it cannot be enumerated, so it cannot + be shown clean. + """ + if depth < _RESCAN_DEPTH: + for inner in _wrapper_payload_commands(command) + _hidden_commands(command): + reason = unverifiable_target_verdict(inner, allowed_paths, depth + 1) + if reason: + return reason + if "{" in command and mentions_control_plane(command) and brace_variants(canonicalize_command(command)) is None: + return "a brace expansion past the cap cannot be enumerated" + known, unknown = _cwd_scopes(command) + for cwd, rest in known: + if cwd in _protected_dirs() or cwd == os.path.expanduser("~"): + if _applies_diff(rest): + return f"`patch` / `git apply` in {cwd} — the files it rewrites are named only in the diff" + for rest in unknown: + for d, names in _named_control_plane_files(): + new = _absolutize(rest, d) + if new == rest or not names.intersection(map(posixpath.basename, _words(new))): continue - end = matches[k + 1].start() if k + 1 < len(matches) else len(command) - rest = command[m.end() : end] - new = _absolutize(rest, cwd) - if new != rest: - out.append(new) - if re.search(r"\bgit\b[^\n]*(?:\s-C\b|--work-tree)", command): - for seg in re.split(r"[;&|\n]", command): - words = _words(seg) - dirs, rest, i = [], [], 0 - while i < len(words): - w = words[i] - if w == "-C" and i + 1 < len(words): - dirs.append(words[i + 1]) - i += 2 - continue - if w.startswith("--work-tree="): - dirs.append(w.partition("=")[2]) - elif w == "--work-tree" and i + 1 < len(words): - dirs.append(words[i + 1]) - i += 1 - else: - rest.append(w) - i += 1 - cwd = None - for d in dirs: - cwd = _join_dir(cwd, d) - if cwd and dirs: - out.append(_absolutize(" ".join(_quote_word(w) for w in rest), cwd)) - return out + if _check_protected_command(new, allowed_paths, 1, [_RESCAN_BUDGET])[0]: + return ( + "a `cd` the scanner cannot resolve precedes a write to a protected " + f"control-plane file name (it would be in {d} if the cd lands there)" + ) + return None + + +_DIFF_READ_ONLY = frozenset({"--dry-run", "--check", "--stat", "--numstat", "--summary"}) + + +def _applies_diff(text: str) -> bool: + """True when some command in ``text`` applies a diff for real (``patch``, ``git apply|am``). + + Judged per simple command: a ``--dry-run`` in one command does not excuse + the next, and the verb is matched by basename (``/usr/bin/patch``). + """ + for seg in re.split(r"[;&|\n()]", text): + words = _segment_words(seg) + i = _wrapped_command_index(words) + if i is None: + continue + head = os.path.basename(words[i]) + if head == "git": + j = i + 1 + while j < len(words) and words[j].startswith("-"): + j += 2 if words[j] in ("-C", "-c") else 1 + applies = j < len(words) and words[j] in ("apply", "am") + else: + applies = head == "patch" + if applies and not _DIFF_READ_ONLY.intersection(words[i:]): + return True + return False def _derived_commands(command: str) -> List[str]: @@ -4730,13 +5138,15 @@ def _derived_commands(command: str) -> List[str]: # judged as the program it is. visible = _hide_heredoc_bodies(command, keep_programs=True) if "<<" in command else command derived = ( - _heredoc_substitution_commands(command) + _wrapper_payload_commands(visible) + + _heredoc_substitution_commands(command) + _hidden_commands(command) + _redirect_variant(visible) + _normalized_path_variant(visible) + _brace_variants_of(visible) + _glob_variants(visible) + _cwd_variants(visible) + + _realpath_variants(visible) ) return list(dict.fromkeys(derived))[:_RESCAN_FANOUT] @@ -8199,6 +8609,20 @@ def _decide_rules( "protected": True, } + # A directory the scanner cannot see: same tier (exempt from the escape + # hatch and kill switch), but an ASK — the command may be harmless, and + # `unverifiable` keeps bypass from demoting it. + target_reason = unverifiable_target_verdict(command, control_plane_allowlist(allowed)) + if target_reason: + return { + "decision": "ask", + "reason": f"Unverifiable control-plane target ({target_reason}) — a human must confirm it (not demoted by bypass)", + "pattern": "ambiguous:control-plane-target", + "command": command, + "id": "core.control-plane-target", + "unverifiable": True, + } + # Parser unavailable ⇒ no rules loaded ⇒ fail closed (block), before the # escape hatch / kill switch can wave it through. if config.get("_parser_unavailable"): diff --git a/agentwire/hooks/damage-control/read-tool-damage-control.py b/agentwire/hooks/damage-control/read-tool-damage-control.py index 02d67cb2..3c045776 100644 --- a/agentwire/hooks/damage-control/read-tool-damage-control.py +++ b/agentwire/hooks/damage-control/read-tool-damage-control.py @@ -36,7 +36,7 @@ def log_disabled(*args, **kwargs): pass # === BEGIN AGENTWIRE HOOK STAMP (generated — do not edit) === -AGENTWIRE_HOOK_STAMP = {"core_sha256": "8ac718b4dcd736c47aa732dbdc05bef73d068422fe67aa7dd835b99b6ce92833", "generated_at": "2026-10-19T00:12:37Z"} +AGENTWIRE_HOOK_STAMP = {"core_sha256": "33f11ebb4e4472aedb37164dcb4c5d1010c5472bc516028d9aaa2c12070bdebc", "generated_at": "2026-10-19T00:29:08Z"} # === END AGENTWIRE HOOK STAMP === # === BEGIN GENERATED FROM agentwire/safety/_core.py === """ @@ -65,7 +65,7 @@ def log_disabled(*args, **kwargs): pass import sys import threading from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Iterator, List, Optional, Tuple try: import yaml @@ -99,7 +99,8 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": (r'>\s*{path}', "write"), (r'\btee\s+(?!.*-a).*{path}', "write"), (r'\bdd\s+.*\bof=\S*{path}', "write"), - (r'\brsync\s+.*\s+{path}', "write"), + # rsync writes its DESTINATION: the last operand (options may trail it) + (r'\brsync\b[^\n|;&]*\s["\']?{path}[^\s|;&]*(?:\s+--?[\w-]+(?:=\S*)?(?:\s+[^-\s]\S*)?)*\s*(?:$|[;&|#]|\d*[<>])', "write"), (r'\btar\s+(-c|--create)\S*\s+.*{path}', "write"), (r'\binstall\s+.*\s+{path}', "write"), ] @@ -265,9 +266,10 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": r')\s+(?:-\S+\s+|\d+[smhd]?\s+)*)*(?:\S*/)?' ) PROTECTED_WRITER_PATTERNS = [ - # ln/link replace the TARGET (last operand); using the path as the source - # (``ln -s /tmp/l``) is a read. - (r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{path}[^\s|;&]*\s*(?:$|[;&|#]|\d*[<>])', "write"), + # ln/link naming the path on EITHER side: as the target it is replaced, as + # the source it becomes a symlink / hardlink alias that a later write + # (``echo x > /tmp/l``) reaches the file through, outside this scan's view. + (r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{path}', "write"), # downloaders: output file flags (incl. log/header/cookie-jar sinks) (r'\bcurl\b' + _SEG + r'(?:\s-(?-i:[A-Za-z0-9]*[oDc])\s*|\s--(?:output|dump-header' r'|cookie-jar|trace|trace-ascii|stderr|etag-save)[\s=])["\']?{path}', "write"), @@ -317,6 +319,8 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": r'\bunzip\b' + _SEG + r'\s-d\s*["\']?{dir}' + _DIR_END, r'\b(?:cp|mv|install|ln)\b' + _SEG + r'(?:\s-(?-i:[A-Za-z]*t)\s*|\s--target-directory[=\s])["\']?{dir}' + _DIR_END, + # a link to the directory (``ln -s /tmp/d``) is an alias for every file in it + r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{dir}' + _DIR_END, ] _CONCAT_RE = _rx( @@ -3173,10 +3177,11 @@ def _extracts_into_protected_dir(command: str) -> Optional[str]: # concatenate (``'E'OF`` is the delimiter ``EOF``), and ANY quote or backslash # in it makes the body literal. _HEREDOC_WORD = ( - r"(?:'[^']*'|\"(?:[^\"\\]|\\.)*\"|\\.|\$'[^']*'|[^\s;&|<>()'\"\\$]+|\$(?!['\"]))+" + r"(?:'[^']*'|\$?\"(?:[^\"\\]|\\.)*\"|\\.|\$'[^']*'|[^\s;&|<>()'\"\\$]+|\$(?!['\"]))+" ) _HEREDOC_OPEN_RE = _rx(r"(?-?)[ \t]*(?P" + _HEREDOC_WORD + r")") _HEREDOC_PART = _rx(r"'([^']*)'|\"((?:[^\"\\]|\\.)*)\"|\\(.)|\$'([^']*)'|([^'\"\\]+)") +_LOCALE_QUOTE = _rx(r"'[^']*'|\$(?=\")") _RESCAN_DEPTH = 3 _RESCAN_FANOUT = 32 _RESCAN_BUDGET = 200 # derived commands scanned per top-level check @@ -3184,6 +3189,8 @@ def _extracts_into_protected_dir(command: str) -> Optional[str]: def _heredoc_delimiter(word: str) -> Tuple[str, bool]: """``(delimiter, body_is_literal)`` for a heredoc delimiter word.""" + # ``$"EOF"`` is a locale-translated string: bash quotes it like ``"EOF"`` + word = _LOCALE_QUOTE.sub(lambda m: "" if m.group(0) == '$' else m.group(0), word) parts = _HEREDOC_PART.findall(word) delim = "".join("".join(p) for p in parts) return delim, any(p[0] or p[1] or p[2] or p[3] for p in parts) or "'" in word or '"' in word @@ -4482,7 +4489,31 @@ def _brace_variants_of(command: str) -> List[str]: return brace_variants(command) or [] -_GLOB_TOKEN = _rx(r"(?()]*(?:[*?\[]|\$(?:\w|\{))[^\s'\"`;|&<>()]*") +_GLOB_RUN = _rx(r"[^\s'\"`;|&<>()]+") +_GLOB_MARK = _rx(r"[*?\[]|\$(?:\w|\{)") +_NOT_WORD_OR_DOLLAR = _rx(r"[^\w$]") +_WORD_OR_DOLLAR = _rx(r"[\w$'\"]") + + +def _glob_tokens(command: str) -> "Iterator[str]": + """Each unquoted word holding a glob / expansion mark, from where a word may start (#1132). + + One linear pass over the word runs. A single regex of the form + ``[^delims]*(?:mark)[^delims]*`` retries from every offset of a long run + that holds no mark, which made a 20 KB run of ``../`` or ``\\`` cost seconds + (#1111). The result is the same text: a word preceded by a word character, + ``$`` or a quote starts at its first character after a non-word one. + """ + for m in _GLOB_RUN.finditer(command): + run, begin = m.group(0), m.start() + start = 0 + if begin and _WORD_OR_DOLLAR.match(command[begin - 1]): + gap = _NOT_WORD_OR_DOLLAR.search(run) + if gap is None: + continue + start = gap.start() + 1 + if _GLOB_MARK.search(run, start): + yield run[start:] _EXPANSION = _rx(r"\$(?:\w+|\{[^}]*\})") _GLOB_PATH_CAP = 32 @@ -4548,19 +4579,19 @@ def _glob_variants(command: str) -> List[str]: run of 3+ characters (``rm *`` names nothing in particular). A component that is only an expansion (``~/.agentwire/$f``) stays a declared residual. """ - if not _GLOB_TOKEN.search(command): + tokens = list(_glob_tokens(command)) + if not tokens: return [] _, project = _protected_glob_candidates(os.path.expanduser("~")) out: List[str] = [] - for m in _GLOB_TOKEN.finditer(command): - tok = m.group(0) + for tok in tokens: if tok.startswith("/"): hits = _glob_hits(tok) else: rx = _token_regex(tok) hits = [] if rx is not None and re.search( - r"[A-Za-z0-9_.\-]{3,}", _EXPANSION.sub("", re.sub(r"[*?\[\]]", " ", tok)) + r"[A-Za-z0-9_.\-]{3,}", _EXPANSION.sub("", re.sub(r"\[[^\]]*\]|[*?\[\]]", " ", tok)) ): hits = [p for p in project if rx.fullmatch(p) or rx.fullmatch("x" + p)] for hit in hits: @@ -4583,9 +4614,21 @@ def _glob_hits(tok: str) -> List[str]: return hits +# A command's start: line start or after a separator / reserved word, then any +# wrappers and ``VAR=value`` prefixes. A prefix value may hold whitespace inside +# ``${…}`` / quotes (``A=${x:-\n} cd dir``), which a bare ``\S*`` cut short. +_CMD_START = ( + r"(?:^\s*|(?:\b(?:then|do|else|elif|if|while|until|time|coproc)\b|[;&|\n(){!])\s*)" + r"(?:(?:time|nohup|command|builtin|env|exec|sudo|!|\w+=(?:\$\{[^}]*\}|\"[^\"]*\"|'[^']*'|[^\s\"'$;&|()]|\$)*)\s+(?:-\S+\s+)*)*" +) +# ``cd dir >/dev/null 2>&1 &&``: redirects between the target and the separator +_CD_REDIRECTS = r"(?:\s+(?:\d*|&)(?:>>?|<&?|>&|>\|)\s*[^\s;&|()<>]+)*" _CD_RE = _rx( - r"(?:^\s*|(?:\b(?:then|do|else|elif|if|while|until|time|coproc)\b|[;&|\n(){!])\s*)(?:(?:time|nohup|command|builtin|env|exec|sudo|!|\w+=\S*)\s+(?:-\S+\s+)*)*(?:cd|pushd)\s+(?:--\s+)?(?P\"[^\"]*\"|'[^']*'|[^\s;&|()]+)\s*(?=&&|;|\|\||\n)" + _CMD_START + + r"(?:cd|pushd)\s+(?:--\s+)?(?P\"[^\"]*\"|'[^']*'|[^\s;&|()<>]+)" + _CD_REDIRECTS + r"\s*(?=&&|;|\|\||\n)" ) +# ``popd`` returns to a directory the command cannot see: an unresolvable cwd. +_POPD_RE = _rx(_CMD_START + r"popd\b[^\n;&|()]*\s*(?=&&|;|\|\||\n)") _REL_WORD = _rx(r"(?'\"=])([A-Za-z0-9_.][^\s;&|()'\"<>]*)") @@ -4658,6 +4701,302 @@ def _absolutize(text: str, cwd: str) -> str: return " ".join(w if _PUNCT_WORD.fullmatch(w) else _quote_word(w) for w in resolved) +_ASSIGN_STMT = _rx( + r"(?:^|[;&|\n({]|\b(?:then|do|else))[ \t]*(?:(?:export|local|declare|readonly)[ \t]+)?(?P[A-Za-z_]\w*)=(?P(?:'[^']*'|\"[^\"]*\"|[^\s;&|()<>'\"])*)(?=[ \t]*(?:[;&|\n]|$))" +) +_VAR_REF = _rx(r"\$(?:(?P[A-Za-z_]\w*)|\{(?P[A-Za-z_]\w*)\})") + + +def _literal_vars(prefix: str) -> Dict[str, Optional[str]]: + """``NAME=value`` statements in ``prefix``: the literal value, or None when it is computed. + + The last assignment wins, so a later ``d=$(…)`` makes ``d`` unknown again. + """ + out: Dict[str, Optional[str]] = {} + for m in _ASSIGN_STMT.finditer(prefix): + lit = re.sub(r"['\"]", "", m.group("v")) + out[m.group("n")] = None if re.search(r"[$`]", lit) else lit + return out + + +def _expand_literal_vars(text: str, known: Dict[str, Optional[str]]) -> str: + """``text`` with each ``$name`` / ``${name}`` a same-command literal assignment defines replaced.""" + def _sub(m: "re.Match[str]") -> str: + val = known.get(m.group("a") or m.group("b")) + return m.group(0) if val is None else val + + return _VAR_REF.sub(_sub, text) + + +# Options that make the command run in another directory, per command word: +# ``(short options, long options, stop-at-first-operand)``. ``git -C`` / ``env +# -C`` / ``patch -d`` change the cwd of the command itself, which is what a +# relative operand (or a diff's own target paths) is resolved against. +_CHDIR_OPTIONS = { + "git": (("-C",), ("--work-tree",), False), + "env": (("-C",), ("--chdir",), True), + "patch": (("-d",), ("--directory",), False), +} +_CHDIR_HINT = _rx( + r"\bgit\b[^\n]*(?:\s-C\b|--work-tree)|\benv\b[^\n]*(?:\s-[A-Za-z]*C|--chdir)|\bpatch\b[^\n]*(?:\s-d|--directory)" +) + + +_ENV_CLUSTER = _rx(r"-[A-Za-z]+C") +_GROUP_WORDS = frozenset({"{", "}", "!", "then", "do", "else", "elif", "if", "while", "until", "time", "fi", "done"}) + + +def _segment_words(seg: str) -> List[str]: + """Words of one simple command, without the group / reserved words around it.""" + words = _words(seg) + while words and words[0] in _GROUP_WORDS: + words = words[1:] + while words and words[-1] in _GROUP_WORDS: + words = words[:-1] + return words + + +_UNESCAPE_DQ = _rx(r"\\([$`])") +_SHELL_C_FLAG = _rx(r"-[A-Za-z]*c[A-Za-z]*") + + +# Options that take a SEPARATE value, per wrapper. An attached value (``-n5``, +# ``--adjustment=5``, ``-uroot``) is one word and consumes nothing; a separate one +# (``-n 5``) must not be mistaken for the command. A wrapper not listed has no +# value-taking option here. +_WRAPPER_VALUE_OPTS = { + "nice": {"-n", "--adjustment"}, + "ionice": {"-c", "-n", "-p", "-P", "-u", "--class", "--classdata", "--pid", "--pgid", "--uid"}, + "timeout": {"-s", "-k", "--signal", "--kill-after"}, + "env": {"-u", "-C", "-S", "--unset", "--chdir", "--split-string"}, + "sudo": {"-u", "-g", "-h", "-p", "-C", "-D", "-R", "-T", "-U", "-r", "-t", "--user", "--group", "--host", "--prompt", "--chdir", "--role", "--type"}, + "doas": {"-u", "-C"}, + "stdbuf": {"-i", "-o", "-e", "--input", "--output", "--error"}, + "taskset": {"-c", "--cpu-list"}, + "chrt": set(), + "xargs": {"-n", "-I", "-L", "-P", "-s", "-d", "-E", "-a", "--max-args", "--replace", "--max-procs", "--delimiter", "--arg-file"}, + "time": {"-f", "-o", "--format", "--output"}, + "parallel": {"-j", "-n", "-N", "-S", "-a", "--jobs", "--sshlogin", "-I"}, + "sshpass": {"-p", "-f", "-d", "-P"}, +} +_POSITIONAL_ARG = _rx(r"\d+[smhd]?|0[xX][0-9a-fA-F]+") + + +def _wrapped_command_index(words: List[str]) -> Optional[int]: + """Index of the word that is the command, past prefix wrappers, their options and ``VAR=x``. + + ``sudo -u root timeout -s9 5 nice -n5 patch`` -> ``patch``. The one place + that knows which words keep the command position open + (``_POSITION_WRAPPERS`` + ``_PREFIX_WRAPPERS``) and which of their options + take a separate value (``_WRAPPER_VALUE_OPTS``). + """ + wrappers = _POSITION_WRAPPERS | _PREFIX_WRAPPERS + current = "" + skip_value = False + for i, w in enumerate(words): + if skip_value: + skip_value = False + elif os.path.basename(w) in wrappers: + current = os.path.basename(w) + elif _ASSIGN_TOKEN_RE.match(w) or _POSITIONAL_ARG.fullmatch(w): + continue + elif w.startswith("-"): + takes = _WRAPPER_VALUE_OPTS.get(current, ()) + # ``-n`` / ``--adjustment`` exactly, or a short cluster ending in a + # value option (``env -iC dir``); ``-n5`` / ``--adjustment=5`` carry theirs + skip_value = w in takes + if not skip_value and len(w) > 2 and w[1] != "-": + # a cluster: the first value-taking letter ends it, and takes the + # NEXT word only when it is the last letter (``-iC dir``, not ``-uroot``) + for k, ch in enumerate(w[1:], 1): + if "-" + ch in takes: + skip_value = k == len(w) - 1 + break + else: + return i + return None + + +def _wrapper_payload_commands(command: str) -> List[str]: + """Programs a command hands to a shell or ``ssh`` as one argument: ``sh -c '…'``, ``ssh host '…'``. + + The wrapped command is the command: the payload is re-scanned in its own + right, so a ``cd && touch `` inside it is resolved exactly as it + is at the top level (the generators that read directories only recognise a + ``cd`` at a command start, which a quote hides). + """ + if not re.search(r"\b(?:sh|bash|zsh|dash|ksh|su|ssh)\b", command): + return [] + segments, err = _scope_segments(command, grouping_ok=True) + if err: + return [] + out: List[str] = [] + for argv, _op in segments: + _, words = _split_assignments(argv) + words = [w for w in words if w not in _GROUP_WORDS] + remote = _ssh_remote_payload(words) + if remote: + while remote.startswith("-") and " " in remote: + remote = remote.split(" ", 1)[1] + out.append(remote) + i = _wrapped_command_index(words) + if i is not None and os.path.basename(words[i]) in _SHELL_NAMES: + for j in range(i + 1, len(words) - 1): + if _SHELL_C_FLAG.fullmatch(words[j]): + k = j + 1 + while k < len(words) - 1 and words[k] == "--": + k += 1 + out.append(words[k]) + break + return [fixed for payload in out for fixed in (payload, _UNESCAPE_DQ.sub(r"\1", payload)) if fixed] + + +def _chdir_option_split(words: List[str]) -> Tuple[List[str], List[str]]: + """``(directories, remaining words)`` for a command word that takes a chdir option.""" + start = 0 + while start < len(words) and _ASSIGN_WORD.match(words[start]): + start += 1 + kind = os.path.basename(words[start]) if start < len(words) else "" + if kind not in _CHDIR_OPTIONS: + return [], words + short, long_, stop_at_operand = _CHDIR_OPTIONS[kind] + dirs: List[str] = [] + rest: List[str] = [] + i = 0 + while i < len(words): + w = words[i] + if i > start and (w in short or (kind == "env" and _ENV_CLUSTER.fullmatch(w))) and i + 1 < len(words): + dirs.append(words[i + 1]) + i += 2 + continue + if i > start and kind != "git" and w[:2] in short and len(w) > 2 and not w.startswith("--"): + dirs.append(w[2:]) + elif i > start and any(w.startswith(o + "=") for o in long_): + dirs.append(w.partition("=")[2]) + elif i > start and w in long_ and i + 1 < len(words): + dirs.append(words[i + 1]) + i += 1 + else: + if stop_at_operand and i > start and not w.startswith("-") and not _ASSIGN_WORD.match(w): + rest = list(words[i:]) + break + rest.append(w) + i += 1 + return dirs, rest + + +def _cwd_scopes(command: str) -> Tuple[List[Tuple[str, str]], List[str]]: + """``(known, unknown)`` for every place the command changes directory (#1101). + + ``known`` pairs a literal directory with the text that runs in it — + ``cd &&`` (until the next ``cd``), ``git -C`` / ``--work-tree``, + ``env -C`` / ``--chdir`` and ``patch -d`` — with ``d=; cd $d`` resolved + through the same command's literal assignments. ``unknown`` holds the text + that runs after a ``cd`` the command cannot resolve (``cd -``, ``popd``, + ``cd $x`` with ``x`` set from a substitution or not at all). + """ + known: List[Tuple[str, str]] = [] + unknown: List[str] = [] + if re.search(r"\b(?:cd|pushd|popd)\b", command): + events = [(m.start(), m.end(), m.group("d").strip("'\"")) for m in _CD_RE.finditer(command)] + events += [(m.start(), m.end(), "-") for m in _POPD_RE.finditer(command)] + events.sort() + cwd: Optional[str] = None + for k, (start, stop, target) in enumerate(events): + end = events[k + 1][0] if k + 1 < len(events) else len(command) + rest = command[stop:end] + target = _expand_literal_vars(target, _literal_vars(command[:start])) + if target == "-" or re.search(r"[$`]", target): + cwd = None + unknown.append(rest) + continue + cwd = _join_dir(cwd, target) + if cwd: + known.append((cwd, rest)) + else: + unknown.append(rest) + if _CHDIR_HINT.search(command): + for seg in re.split(r"[;&|\n()]", command): + cwd = None + rest = _segment_words(seg) + for _ in range(4): # ``env -C a env -C b cmd`` nests + dirs, rest = _chdir_option_split(rest) + if not dirs: + break + for d in dirs: + cwd = _join_dir(cwd, d) + if cwd is None: + break + if cwd: + known.append((cwd, " ".join(_quote_word(w) for w in rest))) + return known, unknown + + +_PATH_WORD = _rx(r"(?()=])[^\s'\"`;|&<>()$*?\[\]{}]*[^\s'\"`;|&<>()$*?\[\]{}=-]") +_REALPATH_WORD_CAP = 64 + + +@functools.lru_cache(maxsize=4) +def _protected_inodes(home: str) -> Tuple[Tuple[str, "os.stat_result"], ...]: + """``(path, stat)`` of each concrete protected file that exists (hardlink detection).""" + out = [] + for pat in PROTECTED_CONTROL_PLANE_PATHS: + if is_glob_pattern(pat): + continue + path = os.path.expanduser(pat) + try: + out.append((path, os.stat(path))) + except OSError: + continue + return tuple(out) + + +def _under_protected(path: str) -> bool: + """Protected file, or a directory that holds / is inside one.""" + if is_protected_control_plane(path): + return True + for d in _protected_ancestor_dirs(): + e = os.path.expanduser(d) + if path == e or path.startswith(e + "/"): + return True + return False + + +def _realpath_variants(command: str) -> List[str]: + """The command with each path word that is an EXISTING alias of a protected file spelled as that file (#1101). + + The link was made by a separate command, an earlier turn, or someone else: + by the time ``echo x > /tmp/l`` runs, its text names nothing protected. The + hook can ask the filesystem. Every path word is resolved (a symlink anywhere + in it, a directory alias like ``/tmp/d/damagecontrol.yml`` included, and a + hardlink to a protected file); a word that lands on the control plane is + rewritten to where it really goes and the result is scanned as the command + it is. + """ + out: List[str] = [] + seen = set() + home = os.path.expanduser("~") + for m in itertools.islice(_PATH_WORD.finditer(command), _REALPATH_WORD_CAP): + word = m.group(0) + if word in seen or len(word) < 2: + continue + seen.add(word) + path = os.path.expanduser(word) + try: + if "/" not in path and not os.path.lexists(path): + continue + real = os.path.realpath(path) + target = real if real != posixpath.normpath(os.path.abspath(path)) and _under_protected(real) else None + if target is None and os.path.isfile(path) and os.stat(path).st_nlink > 1: + st = os.stat(path) + target = next((p for p, ps in _protected_inodes(home) if os.path.samestat(st, ps)), None) + except (OSError, ValueError): + continue + if target and target != path: + out.append(command.replace(word, _quote_word(target))) + return out + + def _cwd_variants(command: str) -> List[str]: """Relative operands resolved against a literal ``cd &&`` / ``git -C `` (#1101). @@ -4665,46 +5004,115 @@ def _cwd_variants(command: str) -> List[str]: to a directory set earlier in the same command. The literal directory is applied to later words (until the next ``cd``); each word that then names a protected file is rewritten to its absolute path and the result re-scanned. - An unresolvable directory (``cd $d``, a ``$(…)``, a bare ``cd -``) is the - declared residual, as is a cwd known only from the session. + A directory that cannot be resolved is judged by ``unverifiable_target_verdict``. """ - out: List[str] = [] - if re.search(r"\b(?:cd|pushd)\b", command): - cwd: Optional[str] = None - matches = list(_CD_RE.finditer(command)) - for k, m in enumerate(matches): - cwd = _join_dir(cwd, m.group("d").strip("'\"")) - if not cwd: + return [ + new + for cwd, rest in _cwd_scopes(command)[0] + for new in (_absolutize(rest, cwd),) + if new != rest + ] + + +def _protected_dirs() -> List[str]: + """Directories that hold a concrete protected file (``~/.agentwire``, ``~/.claude/hooks``, ...).""" + dirs = { + posixpath.dirname(os.path.expanduser(p)) + for p in PROTECTED_CONTROL_PLANE_PATHS + if not p.startswith("*") + } + return sorted(dirs) + + +# Names that are control plane in ANY directory, or that no other program uses. +# ``settings.json`` / ``config.yaml`` are protected where they sit under +# ``~/.claude`` / ``~/.agentwire`` but are ordinary files everywhere else, so +# a bare one after a ``cd`` the scanner cannot read is not evidence of anything. +_GENERIC_BASENAMES = frozenset({"settings.json", "config.yaml"}) +_PROJECT_PLACEHOLDER = "/hypothetical-project" + + +def _named_control_plane_files() -> List[Tuple[str, "frozenset[str]"]]: + """``(directory, distinctive basenames)`` a bare name after an unreadable ``cd`` could mean.""" + by_dir: Dict[str, set] = {} + for p in PROTECTED_CONTROL_PLANE_PATHS: + if p.startswith("*"): + d, name = _PROJECT_PLACEHOLDER, p[1:] + elif is_glob_pattern(p): + continue + else: + expanded = os.path.expanduser(p) + d, name = posixpath.dirname(expanded), posixpath.basename(expanded) + if name not in _GENERIC_BASENAMES: + by_dir.setdefault(d, set()).add(name) + return [(d, frozenset(names)) for d, names in sorted(by_dir.items())] + + +def unverifiable_target_verdict( + command: str, allowed_paths: List[Dict[str, Any]], depth: int = 0 +) -> Optional[str]: + """Why ``command`` needs a human, when what it writes to is unknowable and the control plane is in play. + + A ``cd`` the scanner cannot resolve (``cd -``, ``popd``, ``cd $x``) leaves a + later bare ``damagecontrol.yml`` pointing at any directory. Each protected + directory is tried as that directory; when the text that follows would be a + refused write there, the answer is an ASK, never a block (the directory may + well be somewhere else). A ``patch`` / ``git apply`` / ``git am`` whose + directory is a protected one is the same question: the files it rewrites + are named only inside the diff. So is a brace expansion past the cap in a + command that names the control plane: it cannot be enumerated, so it cannot + be shown clean. + """ + if depth < _RESCAN_DEPTH: + for inner in _wrapper_payload_commands(command) + _hidden_commands(command): + reason = unverifiable_target_verdict(inner, allowed_paths, depth + 1) + if reason: + return reason + if "{" in command and mentions_control_plane(command) and brace_variants(canonicalize_command(command)) is None: + return "a brace expansion past the cap cannot be enumerated" + known, unknown = _cwd_scopes(command) + for cwd, rest in known: + if cwd in _protected_dirs() or cwd == os.path.expanduser("~"): + if _applies_diff(rest): + return f"`patch` / `git apply` in {cwd} — the files it rewrites are named only in the diff" + for rest in unknown: + for d, names in _named_control_plane_files(): + new = _absolutize(rest, d) + if new == rest or not names.intersection(map(posixpath.basename, _words(new))): continue - end = matches[k + 1].start() if k + 1 < len(matches) else len(command) - rest = command[m.end() : end] - new = _absolutize(rest, cwd) - if new != rest: - out.append(new) - if re.search(r"\bgit\b[^\n]*(?:\s-C\b|--work-tree)", command): - for seg in re.split(r"[;&|\n]", command): - words = _words(seg) - dirs, rest, i = [], [], 0 - while i < len(words): - w = words[i] - if w == "-C" and i + 1 < len(words): - dirs.append(words[i + 1]) - i += 2 - continue - if w.startswith("--work-tree="): - dirs.append(w.partition("=")[2]) - elif w == "--work-tree" and i + 1 < len(words): - dirs.append(words[i + 1]) - i += 1 - else: - rest.append(w) - i += 1 - cwd = None - for d in dirs: - cwd = _join_dir(cwd, d) - if cwd and dirs: - out.append(_absolutize(" ".join(_quote_word(w) for w in rest), cwd)) - return out + if _check_protected_command(new, allowed_paths, 1, [_RESCAN_BUDGET])[0]: + return ( + "a `cd` the scanner cannot resolve precedes a write to a protected " + f"control-plane file name (it would be in {d} if the cd lands there)" + ) + return None + + +_DIFF_READ_ONLY = frozenset({"--dry-run", "--check", "--stat", "--numstat", "--summary"}) + + +def _applies_diff(text: str) -> bool: + """True when some command in ``text`` applies a diff for real (``patch``, ``git apply|am``). + + Judged per simple command: a ``--dry-run`` in one command does not excuse + the next, and the verb is matched by basename (``/usr/bin/patch``). + """ + for seg in re.split(r"[;&|\n()]", text): + words = _segment_words(seg) + i = _wrapped_command_index(words) + if i is None: + continue + head = os.path.basename(words[i]) + if head == "git": + j = i + 1 + while j < len(words) and words[j].startswith("-"): + j += 2 if words[j] in ("-C", "-c") else 1 + applies = j < len(words) and words[j] in ("apply", "am") + else: + applies = head == "patch" + if applies and not _DIFF_READ_ONLY.intersection(words[i:]): + return True + return False def _derived_commands(command: str) -> List[str]: @@ -4717,13 +5125,15 @@ def _derived_commands(command: str) -> List[str]: # judged as the program it is. visible = _hide_heredoc_bodies(command, keep_programs=True) if "<<" in command else command derived = ( - _heredoc_substitution_commands(command) + _wrapper_payload_commands(visible) + + _heredoc_substitution_commands(command) + _hidden_commands(command) + _redirect_variant(visible) + _normalized_path_variant(visible) + _brace_variants_of(visible) + _glob_variants(visible) + _cwd_variants(visible) + + _realpath_variants(visible) ) return list(dict.fromkeys(derived))[:_RESCAN_FANOUT] @@ -8186,6 +8596,20 @@ def _decide_rules( "protected": True, } + # A directory the scanner cannot see: same tier (exempt from the escape + # hatch and kill switch), but an ASK — the command may be harmless, and + # `unverifiable` keeps bypass from demoting it. + target_reason = unverifiable_target_verdict(command, control_plane_allowlist(allowed)) + if target_reason: + return { + "decision": "ask", + "reason": f"Unverifiable control-plane target ({target_reason}) — a human must confirm it (not demoted by bypass)", + "pattern": "ambiguous:control-plane-target", + "command": command, + "id": "core.control-plane-target", + "unverifiable": True, + } + # Parser unavailable ⇒ no rules loaded ⇒ fail closed (block), before the # escape hatch / kill switch can wave it through. if config.get("_parser_unavailable"): diff --git a/agentwire/hooks/damage-control/write-tool-damage-control.py b/agentwire/hooks/damage-control/write-tool-damage-control.py index 6b5ad022..582b235e 100755 --- a/agentwire/hooks/damage-control/write-tool-damage-control.py +++ b/agentwire/hooks/damage-control/write-tool-damage-control.py @@ -29,7 +29,7 @@ def log_disabled(*args, **kwargs): pass # === BEGIN AGENTWIRE HOOK STAMP (generated — do not edit) === -AGENTWIRE_HOOK_STAMP = {"core_sha256": "8ac718b4dcd736c47aa732dbdc05bef73d068422fe67aa7dd835b99b6ce92833", "generated_at": "2026-10-19T00:12:37Z"} +AGENTWIRE_HOOK_STAMP = {"core_sha256": "33f11ebb4e4472aedb37164dcb4c5d1010c5472bc516028d9aaa2c12070bdebc", "generated_at": "2026-10-19T00:29:08Z"} # === END AGENTWIRE HOOK STAMP === # === BEGIN GENERATED FROM agentwire/safety/_core.py === """ @@ -58,7 +58,7 @@ def log_disabled(*args, **kwargs): pass import sys import threading from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Iterator, List, Optional, Tuple try: import yaml @@ -92,7 +92,8 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": (r'>\s*{path}', "write"), (r'\btee\s+(?!.*-a).*{path}', "write"), (r'\bdd\s+.*\bof=\S*{path}', "write"), - (r'\brsync\s+.*\s+{path}', "write"), + # rsync writes its DESTINATION: the last operand (options may trail it) + (r'\brsync\b[^\n|;&]*\s["\']?{path}[^\s|;&]*(?:\s+--?[\w-]+(?:=\S*)?(?:\s+[^-\s]\S*)?)*\s*(?:$|[;&|#]|\d*[<>])', "write"), (r'\btar\s+(-c|--create)\S*\s+.*{path}', "write"), (r'\binstall\s+.*\s+{path}', "write"), ] @@ -258,9 +259,10 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": r')\s+(?:-\S+\s+|\d+[smhd]?\s+)*)*(?:\S*/)?' ) PROTECTED_WRITER_PATTERNS = [ - # ln/link replace the TARGET (last operand); using the path as the source - # (``ln -s /tmp/l``) is a read. - (r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{path}[^\s|;&]*\s*(?:$|[;&|#]|\d*[<>])', "write"), + # ln/link naming the path on EITHER side: as the target it is replaced, as + # the source it becomes a symlink / hardlink alias that a later write + # (``echo x > /tmp/l``) reaches the file through, outside this scan's view. + (r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{path}', "write"), # downloaders: output file flags (incl. log/header/cookie-jar sinks) (r'\bcurl\b' + _SEG + r'(?:\s-(?-i:[A-Za-z0-9]*[oDc])\s*|\s--(?:output|dump-header' r'|cookie-jar|trace|trace-ascii|stderr|etag-save)[\s=])["\']?{path}', "write"), @@ -310,6 +312,8 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": r'\bunzip\b' + _SEG + r'\s-d\s*["\']?{dir}' + _DIR_END, r'\b(?:cp|mv|install|ln)\b' + _SEG + r'(?:\s-(?-i:[A-Za-z]*t)\s*|\s--target-directory[=\s])["\']?{dir}' + _DIR_END, + # a link to the directory (``ln -s /tmp/d``) is an alias for every file in it + r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{dir}' + _DIR_END, ] _CONCAT_RE = _rx( @@ -3166,10 +3170,11 @@ def _extracts_into_protected_dir(command: str) -> Optional[str]: # concatenate (``'E'OF`` is the delimiter ``EOF``), and ANY quote or backslash # in it makes the body literal. _HEREDOC_WORD = ( - r"(?:'[^']*'|\"(?:[^\"\\]|\\.)*\"|\\.|\$'[^']*'|[^\s;&|<>()'\"\\$]+|\$(?!['\"]))+" + r"(?:'[^']*'|\$?\"(?:[^\"\\]|\\.)*\"|\\.|\$'[^']*'|[^\s;&|<>()'\"\\$]+|\$(?!['\"]))+" ) _HEREDOC_OPEN_RE = _rx(r"(?-?)[ \t]*(?P" + _HEREDOC_WORD + r")") _HEREDOC_PART = _rx(r"'([^']*)'|\"((?:[^\"\\]|\\.)*)\"|\\(.)|\$'([^']*)'|([^'\"\\]+)") +_LOCALE_QUOTE = _rx(r"'[^']*'|\$(?=\")") _RESCAN_DEPTH = 3 _RESCAN_FANOUT = 32 _RESCAN_BUDGET = 200 # derived commands scanned per top-level check @@ -3177,6 +3182,8 @@ def _extracts_into_protected_dir(command: str) -> Optional[str]: def _heredoc_delimiter(word: str) -> Tuple[str, bool]: """``(delimiter, body_is_literal)`` for a heredoc delimiter word.""" + # ``$"EOF"`` is a locale-translated string: bash quotes it like ``"EOF"`` + word = _LOCALE_QUOTE.sub(lambda m: "" if m.group(0) == '$' else m.group(0), word) parts = _HEREDOC_PART.findall(word) delim = "".join("".join(p) for p in parts) return delim, any(p[0] or p[1] or p[2] or p[3] for p in parts) or "'" in word or '"' in word @@ -4475,7 +4482,31 @@ def _brace_variants_of(command: str) -> List[str]: return brace_variants(command) or [] -_GLOB_TOKEN = _rx(r"(?()]*(?:[*?\[]|\$(?:\w|\{))[^\s'\"`;|&<>()]*") +_GLOB_RUN = _rx(r"[^\s'\"`;|&<>()]+") +_GLOB_MARK = _rx(r"[*?\[]|\$(?:\w|\{)") +_NOT_WORD_OR_DOLLAR = _rx(r"[^\w$]") +_WORD_OR_DOLLAR = _rx(r"[\w$'\"]") + + +def _glob_tokens(command: str) -> "Iterator[str]": + """Each unquoted word holding a glob / expansion mark, from where a word may start (#1132). + + One linear pass over the word runs. A single regex of the form + ``[^delims]*(?:mark)[^delims]*`` retries from every offset of a long run + that holds no mark, which made a 20 KB run of ``../`` or ``\\`` cost seconds + (#1111). The result is the same text: a word preceded by a word character, + ``$`` or a quote starts at its first character after a non-word one. + """ + for m in _GLOB_RUN.finditer(command): + run, begin = m.group(0), m.start() + start = 0 + if begin and _WORD_OR_DOLLAR.match(command[begin - 1]): + gap = _NOT_WORD_OR_DOLLAR.search(run) + if gap is None: + continue + start = gap.start() + 1 + if _GLOB_MARK.search(run, start): + yield run[start:] _EXPANSION = _rx(r"\$(?:\w+|\{[^}]*\})") _GLOB_PATH_CAP = 32 @@ -4541,19 +4572,19 @@ def _glob_variants(command: str) -> List[str]: run of 3+ characters (``rm *`` names nothing in particular). A component that is only an expansion (``~/.agentwire/$f``) stays a declared residual. """ - if not _GLOB_TOKEN.search(command): + tokens = list(_glob_tokens(command)) + if not tokens: return [] _, project = _protected_glob_candidates(os.path.expanduser("~")) out: List[str] = [] - for m in _GLOB_TOKEN.finditer(command): - tok = m.group(0) + for tok in tokens: if tok.startswith("/"): hits = _glob_hits(tok) else: rx = _token_regex(tok) hits = [] if rx is not None and re.search( - r"[A-Za-z0-9_.\-]{3,}", _EXPANSION.sub("", re.sub(r"[*?\[\]]", " ", tok)) + r"[A-Za-z0-9_.\-]{3,}", _EXPANSION.sub("", re.sub(r"\[[^\]]*\]|[*?\[\]]", " ", tok)) ): hits = [p for p in project if rx.fullmatch(p) or rx.fullmatch("x" + p)] for hit in hits: @@ -4576,9 +4607,21 @@ def _glob_hits(tok: str) -> List[str]: return hits +# A command's start: line start or after a separator / reserved word, then any +# wrappers and ``VAR=value`` prefixes. A prefix value may hold whitespace inside +# ``${…}`` / quotes (``A=${x:-\n} cd dir``), which a bare ``\S*`` cut short. +_CMD_START = ( + r"(?:^\s*|(?:\b(?:then|do|else|elif|if|while|until|time|coproc)\b|[;&|\n(){!])\s*)" + r"(?:(?:time|nohup|command|builtin|env|exec|sudo|!|\w+=(?:\$\{[^}]*\}|\"[^\"]*\"|'[^']*'|[^\s\"'$;&|()]|\$)*)\s+(?:-\S+\s+)*)*" +) +# ``cd dir >/dev/null 2>&1 &&``: redirects between the target and the separator +_CD_REDIRECTS = r"(?:\s+(?:\d*|&)(?:>>?|<&?|>&|>\|)\s*[^\s;&|()<>]+)*" _CD_RE = _rx( - r"(?:^\s*|(?:\b(?:then|do|else|elif|if|while|until|time|coproc)\b|[;&|\n(){!])\s*)(?:(?:time|nohup|command|builtin|env|exec|sudo|!|\w+=\S*)\s+(?:-\S+\s+)*)*(?:cd|pushd)\s+(?:--\s+)?(?P\"[^\"]*\"|'[^']*'|[^\s;&|()]+)\s*(?=&&|;|\|\||\n)" + _CMD_START + + r"(?:cd|pushd)\s+(?:--\s+)?(?P\"[^\"]*\"|'[^']*'|[^\s;&|()<>]+)" + _CD_REDIRECTS + r"\s*(?=&&|;|\|\||\n)" ) +# ``popd`` returns to a directory the command cannot see: an unresolvable cwd. +_POPD_RE = _rx(_CMD_START + r"popd\b[^\n;&|()]*\s*(?=&&|;|\|\||\n)") _REL_WORD = _rx(r"(?'\"=])([A-Za-z0-9_.][^\s;&|()'\"<>]*)") @@ -4651,6 +4694,302 @@ def _absolutize(text: str, cwd: str) -> str: return " ".join(w if _PUNCT_WORD.fullmatch(w) else _quote_word(w) for w in resolved) +_ASSIGN_STMT = _rx( + r"(?:^|[;&|\n({]|\b(?:then|do|else))[ \t]*(?:(?:export|local|declare|readonly)[ \t]+)?(?P[A-Za-z_]\w*)=(?P(?:'[^']*'|\"[^\"]*\"|[^\s;&|()<>'\"])*)(?=[ \t]*(?:[;&|\n]|$))" +) +_VAR_REF = _rx(r"\$(?:(?P[A-Za-z_]\w*)|\{(?P[A-Za-z_]\w*)\})") + + +def _literal_vars(prefix: str) -> Dict[str, Optional[str]]: + """``NAME=value`` statements in ``prefix``: the literal value, or None when it is computed. + + The last assignment wins, so a later ``d=$(…)`` makes ``d`` unknown again. + """ + out: Dict[str, Optional[str]] = {} + for m in _ASSIGN_STMT.finditer(prefix): + lit = re.sub(r"['\"]", "", m.group("v")) + out[m.group("n")] = None if re.search(r"[$`]", lit) else lit + return out + + +def _expand_literal_vars(text: str, known: Dict[str, Optional[str]]) -> str: + """``text`` with each ``$name`` / ``${name}`` a same-command literal assignment defines replaced.""" + def _sub(m: "re.Match[str]") -> str: + val = known.get(m.group("a") or m.group("b")) + return m.group(0) if val is None else val + + return _VAR_REF.sub(_sub, text) + + +# Options that make the command run in another directory, per command word: +# ``(short options, long options, stop-at-first-operand)``. ``git -C`` / ``env +# -C`` / ``patch -d`` change the cwd of the command itself, which is what a +# relative operand (or a diff's own target paths) is resolved against. +_CHDIR_OPTIONS = { + "git": (("-C",), ("--work-tree",), False), + "env": (("-C",), ("--chdir",), True), + "patch": (("-d",), ("--directory",), False), +} +_CHDIR_HINT = _rx( + r"\bgit\b[^\n]*(?:\s-C\b|--work-tree)|\benv\b[^\n]*(?:\s-[A-Za-z]*C|--chdir)|\bpatch\b[^\n]*(?:\s-d|--directory)" +) + + +_ENV_CLUSTER = _rx(r"-[A-Za-z]+C") +_GROUP_WORDS = frozenset({"{", "}", "!", "then", "do", "else", "elif", "if", "while", "until", "time", "fi", "done"}) + + +def _segment_words(seg: str) -> List[str]: + """Words of one simple command, without the group / reserved words around it.""" + words = _words(seg) + while words and words[0] in _GROUP_WORDS: + words = words[1:] + while words and words[-1] in _GROUP_WORDS: + words = words[:-1] + return words + + +_UNESCAPE_DQ = _rx(r"\\([$`])") +_SHELL_C_FLAG = _rx(r"-[A-Za-z]*c[A-Za-z]*") + + +# Options that take a SEPARATE value, per wrapper. An attached value (``-n5``, +# ``--adjustment=5``, ``-uroot``) is one word and consumes nothing; a separate one +# (``-n 5``) must not be mistaken for the command. A wrapper not listed has no +# value-taking option here. +_WRAPPER_VALUE_OPTS = { + "nice": {"-n", "--adjustment"}, + "ionice": {"-c", "-n", "-p", "-P", "-u", "--class", "--classdata", "--pid", "--pgid", "--uid"}, + "timeout": {"-s", "-k", "--signal", "--kill-after"}, + "env": {"-u", "-C", "-S", "--unset", "--chdir", "--split-string"}, + "sudo": {"-u", "-g", "-h", "-p", "-C", "-D", "-R", "-T", "-U", "-r", "-t", "--user", "--group", "--host", "--prompt", "--chdir", "--role", "--type"}, + "doas": {"-u", "-C"}, + "stdbuf": {"-i", "-o", "-e", "--input", "--output", "--error"}, + "taskset": {"-c", "--cpu-list"}, + "chrt": set(), + "xargs": {"-n", "-I", "-L", "-P", "-s", "-d", "-E", "-a", "--max-args", "--replace", "--max-procs", "--delimiter", "--arg-file"}, + "time": {"-f", "-o", "--format", "--output"}, + "parallel": {"-j", "-n", "-N", "-S", "-a", "--jobs", "--sshlogin", "-I"}, + "sshpass": {"-p", "-f", "-d", "-P"}, +} +_POSITIONAL_ARG = _rx(r"\d+[smhd]?|0[xX][0-9a-fA-F]+") + + +def _wrapped_command_index(words: List[str]) -> Optional[int]: + """Index of the word that is the command, past prefix wrappers, their options and ``VAR=x``. + + ``sudo -u root timeout -s9 5 nice -n5 patch`` -> ``patch``. The one place + that knows which words keep the command position open + (``_POSITION_WRAPPERS`` + ``_PREFIX_WRAPPERS``) and which of their options + take a separate value (``_WRAPPER_VALUE_OPTS``). + """ + wrappers = _POSITION_WRAPPERS | _PREFIX_WRAPPERS + current = "" + skip_value = False + for i, w in enumerate(words): + if skip_value: + skip_value = False + elif os.path.basename(w) in wrappers: + current = os.path.basename(w) + elif _ASSIGN_TOKEN_RE.match(w) or _POSITIONAL_ARG.fullmatch(w): + continue + elif w.startswith("-"): + takes = _WRAPPER_VALUE_OPTS.get(current, ()) + # ``-n`` / ``--adjustment`` exactly, or a short cluster ending in a + # value option (``env -iC dir``); ``-n5`` / ``--adjustment=5`` carry theirs + skip_value = w in takes + if not skip_value and len(w) > 2 and w[1] != "-": + # a cluster: the first value-taking letter ends it, and takes the + # NEXT word only when it is the last letter (``-iC dir``, not ``-uroot``) + for k, ch in enumerate(w[1:], 1): + if "-" + ch in takes: + skip_value = k == len(w) - 1 + break + else: + return i + return None + + +def _wrapper_payload_commands(command: str) -> List[str]: + """Programs a command hands to a shell or ``ssh`` as one argument: ``sh -c '…'``, ``ssh host '…'``. + + The wrapped command is the command: the payload is re-scanned in its own + right, so a ``cd && touch `` inside it is resolved exactly as it + is at the top level (the generators that read directories only recognise a + ``cd`` at a command start, which a quote hides). + """ + if not re.search(r"\b(?:sh|bash|zsh|dash|ksh|su|ssh)\b", command): + return [] + segments, err = _scope_segments(command, grouping_ok=True) + if err: + return [] + out: List[str] = [] + for argv, _op in segments: + _, words = _split_assignments(argv) + words = [w for w in words if w not in _GROUP_WORDS] + remote = _ssh_remote_payload(words) + if remote: + while remote.startswith("-") and " " in remote: + remote = remote.split(" ", 1)[1] + out.append(remote) + i = _wrapped_command_index(words) + if i is not None and os.path.basename(words[i]) in _SHELL_NAMES: + for j in range(i + 1, len(words) - 1): + if _SHELL_C_FLAG.fullmatch(words[j]): + k = j + 1 + while k < len(words) - 1 and words[k] == "--": + k += 1 + out.append(words[k]) + break + return [fixed for payload in out for fixed in (payload, _UNESCAPE_DQ.sub(r"\1", payload)) if fixed] + + +def _chdir_option_split(words: List[str]) -> Tuple[List[str], List[str]]: + """``(directories, remaining words)`` for a command word that takes a chdir option.""" + start = 0 + while start < len(words) and _ASSIGN_WORD.match(words[start]): + start += 1 + kind = os.path.basename(words[start]) if start < len(words) else "" + if kind not in _CHDIR_OPTIONS: + return [], words + short, long_, stop_at_operand = _CHDIR_OPTIONS[kind] + dirs: List[str] = [] + rest: List[str] = [] + i = 0 + while i < len(words): + w = words[i] + if i > start and (w in short or (kind == "env" and _ENV_CLUSTER.fullmatch(w))) and i + 1 < len(words): + dirs.append(words[i + 1]) + i += 2 + continue + if i > start and kind != "git" and w[:2] in short and len(w) > 2 and not w.startswith("--"): + dirs.append(w[2:]) + elif i > start and any(w.startswith(o + "=") for o in long_): + dirs.append(w.partition("=")[2]) + elif i > start and w in long_ and i + 1 < len(words): + dirs.append(words[i + 1]) + i += 1 + else: + if stop_at_operand and i > start and not w.startswith("-") and not _ASSIGN_WORD.match(w): + rest = list(words[i:]) + break + rest.append(w) + i += 1 + return dirs, rest + + +def _cwd_scopes(command: str) -> Tuple[List[Tuple[str, str]], List[str]]: + """``(known, unknown)`` for every place the command changes directory (#1101). + + ``known`` pairs a literal directory with the text that runs in it — + ``cd &&`` (until the next ``cd``), ``git -C`` / ``--work-tree``, + ``env -C`` / ``--chdir`` and ``patch -d`` — with ``d=; cd $d`` resolved + through the same command's literal assignments. ``unknown`` holds the text + that runs after a ``cd`` the command cannot resolve (``cd -``, ``popd``, + ``cd $x`` with ``x`` set from a substitution or not at all). + """ + known: List[Tuple[str, str]] = [] + unknown: List[str] = [] + if re.search(r"\b(?:cd|pushd|popd)\b", command): + events = [(m.start(), m.end(), m.group("d").strip("'\"")) for m in _CD_RE.finditer(command)] + events += [(m.start(), m.end(), "-") for m in _POPD_RE.finditer(command)] + events.sort() + cwd: Optional[str] = None + for k, (start, stop, target) in enumerate(events): + end = events[k + 1][0] if k + 1 < len(events) else len(command) + rest = command[stop:end] + target = _expand_literal_vars(target, _literal_vars(command[:start])) + if target == "-" or re.search(r"[$`]", target): + cwd = None + unknown.append(rest) + continue + cwd = _join_dir(cwd, target) + if cwd: + known.append((cwd, rest)) + else: + unknown.append(rest) + if _CHDIR_HINT.search(command): + for seg in re.split(r"[;&|\n()]", command): + cwd = None + rest = _segment_words(seg) + for _ in range(4): # ``env -C a env -C b cmd`` nests + dirs, rest = _chdir_option_split(rest) + if not dirs: + break + for d in dirs: + cwd = _join_dir(cwd, d) + if cwd is None: + break + if cwd: + known.append((cwd, " ".join(_quote_word(w) for w in rest))) + return known, unknown + + +_PATH_WORD = _rx(r"(?()=])[^\s'\"`;|&<>()$*?\[\]{}]*[^\s'\"`;|&<>()$*?\[\]{}=-]") +_REALPATH_WORD_CAP = 64 + + +@functools.lru_cache(maxsize=4) +def _protected_inodes(home: str) -> Tuple[Tuple[str, "os.stat_result"], ...]: + """``(path, stat)`` of each concrete protected file that exists (hardlink detection).""" + out = [] + for pat in PROTECTED_CONTROL_PLANE_PATHS: + if is_glob_pattern(pat): + continue + path = os.path.expanduser(pat) + try: + out.append((path, os.stat(path))) + except OSError: + continue + return tuple(out) + + +def _under_protected(path: str) -> bool: + """Protected file, or a directory that holds / is inside one.""" + if is_protected_control_plane(path): + return True + for d in _protected_ancestor_dirs(): + e = os.path.expanduser(d) + if path == e or path.startswith(e + "/"): + return True + return False + + +def _realpath_variants(command: str) -> List[str]: + """The command with each path word that is an EXISTING alias of a protected file spelled as that file (#1101). + + The link was made by a separate command, an earlier turn, or someone else: + by the time ``echo x > /tmp/l`` runs, its text names nothing protected. The + hook can ask the filesystem. Every path word is resolved (a symlink anywhere + in it, a directory alias like ``/tmp/d/damagecontrol.yml`` included, and a + hardlink to a protected file); a word that lands on the control plane is + rewritten to where it really goes and the result is scanned as the command + it is. + """ + out: List[str] = [] + seen = set() + home = os.path.expanduser("~") + for m in itertools.islice(_PATH_WORD.finditer(command), _REALPATH_WORD_CAP): + word = m.group(0) + if word in seen or len(word) < 2: + continue + seen.add(word) + path = os.path.expanduser(word) + try: + if "/" not in path and not os.path.lexists(path): + continue + real = os.path.realpath(path) + target = real if real != posixpath.normpath(os.path.abspath(path)) and _under_protected(real) else None + if target is None and os.path.isfile(path) and os.stat(path).st_nlink > 1: + st = os.stat(path) + target = next((p for p, ps in _protected_inodes(home) if os.path.samestat(st, ps)), None) + except (OSError, ValueError): + continue + if target and target != path: + out.append(command.replace(word, _quote_word(target))) + return out + + def _cwd_variants(command: str) -> List[str]: """Relative operands resolved against a literal ``cd &&`` / ``git -C `` (#1101). @@ -4658,46 +4997,115 @@ def _cwd_variants(command: str) -> List[str]: to a directory set earlier in the same command. The literal directory is applied to later words (until the next ``cd``); each word that then names a protected file is rewritten to its absolute path and the result re-scanned. - An unresolvable directory (``cd $d``, a ``$(…)``, a bare ``cd -``) is the - declared residual, as is a cwd known only from the session. + A directory that cannot be resolved is judged by ``unverifiable_target_verdict``. """ - out: List[str] = [] - if re.search(r"\b(?:cd|pushd)\b", command): - cwd: Optional[str] = None - matches = list(_CD_RE.finditer(command)) - for k, m in enumerate(matches): - cwd = _join_dir(cwd, m.group("d").strip("'\"")) - if not cwd: + return [ + new + for cwd, rest in _cwd_scopes(command)[0] + for new in (_absolutize(rest, cwd),) + if new != rest + ] + + +def _protected_dirs() -> List[str]: + """Directories that hold a concrete protected file (``~/.agentwire``, ``~/.claude/hooks``, ...).""" + dirs = { + posixpath.dirname(os.path.expanduser(p)) + for p in PROTECTED_CONTROL_PLANE_PATHS + if not p.startswith("*") + } + return sorted(dirs) + + +# Names that are control plane in ANY directory, or that no other program uses. +# ``settings.json`` / ``config.yaml`` are protected where they sit under +# ``~/.claude`` / ``~/.agentwire`` but are ordinary files everywhere else, so +# a bare one after a ``cd`` the scanner cannot read is not evidence of anything. +_GENERIC_BASENAMES = frozenset({"settings.json", "config.yaml"}) +_PROJECT_PLACEHOLDER = "/hypothetical-project" + + +def _named_control_plane_files() -> List[Tuple[str, "frozenset[str]"]]: + """``(directory, distinctive basenames)`` a bare name after an unreadable ``cd`` could mean.""" + by_dir: Dict[str, set] = {} + for p in PROTECTED_CONTROL_PLANE_PATHS: + if p.startswith("*"): + d, name = _PROJECT_PLACEHOLDER, p[1:] + elif is_glob_pattern(p): + continue + else: + expanded = os.path.expanduser(p) + d, name = posixpath.dirname(expanded), posixpath.basename(expanded) + if name not in _GENERIC_BASENAMES: + by_dir.setdefault(d, set()).add(name) + return [(d, frozenset(names)) for d, names in sorted(by_dir.items())] + + +def unverifiable_target_verdict( + command: str, allowed_paths: List[Dict[str, Any]], depth: int = 0 +) -> Optional[str]: + """Why ``command`` needs a human, when what it writes to is unknowable and the control plane is in play. + + A ``cd`` the scanner cannot resolve (``cd -``, ``popd``, ``cd $x``) leaves a + later bare ``damagecontrol.yml`` pointing at any directory. Each protected + directory is tried as that directory; when the text that follows would be a + refused write there, the answer is an ASK, never a block (the directory may + well be somewhere else). A ``patch`` / ``git apply`` / ``git am`` whose + directory is a protected one is the same question: the files it rewrites + are named only inside the diff. So is a brace expansion past the cap in a + command that names the control plane: it cannot be enumerated, so it cannot + be shown clean. + """ + if depth < _RESCAN_DEPTH: + for inner in _wrapper_payload_commands(command) + _hidden_commands(command): + reason = unverifiable_target_verdict(inner, allowed_paths, depth + 1) + if reason: + return reason + if "{" in command and mentions_control_plane(command) and brace_variants(canonicalize_command(command)) is None: + return "a brace expansion past the cap cannot be enumerated" + known, unknown = _cwd_scopes(command) + for cwd, rest in known: + if cwd in _protected_dirs() or cwd == os.path.expanduser("~"): + if _applies_diff(rest): + return f"`patch` / `git apply` in {cwd} — the files it rewrites are named only in the diff" + for rest in unknown: + for d, names in _named_control_plane_files(): + new = _absolutize(rest, d) + if new == rest or not names.intersection(map(posixpath.basename, _words(new))): continue - end = matches[k + 1].start() if k + 1 < len(matches) else len(command) - rest = command[m.end() : end] - new = _absolutize(rest, cwd) - if new != rest: - out.append(new) - if re.search(r"\bgit\b[^\n]*(?:\s-C\b|--work-tree)", command): - for seg in re.split(r"[;&|\n]", command): - words = _words(seg) - dirs, rest, i = [], [], 0 - while i < len(words): - w = words[i] - if w == "-C" and i + 1 < len(words): - dirs.append(words[i + 1]) - i += 2 - continue - if w.startswith("--work-tree="): - dirs.append(w.partition("=")[2]) - elif w == "--work-tree" and i + 1 < len(words): - dirs.append(words[i + 1]) - i += 1 - else: - rest.append(w) - i += 1 - cwd = None - for d in dirs: - cwd = _join_dir(cwd, d) - if cwd and dirs: - out.append(_absolutize(" ".join(_quote_word(w) for w in rest), cwd)) - return out + if _check_protected_command(new, allowed_paths, 1, [_RESCAN_BUDGET])[0]: + return ( + "a `cd` the scanner cannot resolve precedes a write to a protected " + f"control-plane file name (it would be in {d} if the cd lands there)" + ) + return None + + +_DIFF_READ_ONLY = frozenset({"--dry-run", "--check", "--stat", "--numstat", "--summary"}) + + +def _applies_diff(text: str) -> bool: + """True when some command in ``text`` applies a diff for real (``patch``, ``git apply|am``). + + Judged per simple command: a ``--dry-run`` in one command does not excuse + the next, and the verb is matched by basename (``/usr/bin/patch``). + """ + for seg in re.split(r"[;&|\n()]", text): + words = _segment_words(seg) + i = _wrapped_command_index(words) + if i is None: + continue + head = os.path.basename(words[i]) + if head == "git": + j = i + 1 + while j < len(words) and words[j].startswith("-"): + j += 2 if words[j] in ("-C", "-c") else 1 + applies = j < len(words) and words[j] in ("apply", "am") + else: + applies = head == "patch" + if applies and not _DIFF_READ_ONLY.intersection(words[i:]): + return True + return False def _derived_commands(command: str) -> List[str]: @@ -4710,13 +5118,15 @@ def _derived_commands(command: str) -> List[str]: # judged as the program it is. visible = _hide_heredoc_bodies(command, keep_programs=True) if "<<" in command else command derived = ( - _heredoc_substitution_commands(command) + _wrapper_payload_commands(visible) + + _heredoc_substitution_commands(command) + _hidden_commands(command) + _redirect_variant(visible) + _normalized_path_variant(visible) + _brace_variants_of(visible) + _glob_variants(visible) + _cwd_variants(visible) + + _realpath_variants(visible) ) return list(dict.fromkeys(derived))[:_RESCAN_FANOUT] @@ -8179,6 +8589,20 @@ def _decide_rules( "protected": True, } + # A directory the scanner cannot see: same tier (exempt from the escape + # hatch and kill switch), but an ASK — the command may be harmless, and + # `unverifiable` keeps bypass from demoting it. + target_reason = unverifiable_target_verdict(command, control_plane_allowlist(allowed)) + if target_reason: + return { + "decision": "ask", + "reason": f"Unverifiable control-plane target ({target_reason}) — a human must confirm it (not demoted by bypass)", + "pattern": "ambiguous:control-plane-target", + "command": command, + "id": "core.control-plane-target", + "unverifiable": True, + } + # Parser unavailable ⇒ no rules loaded ⇒ fail closed (block), before the # escape hatch / kill switch can wave it through. if config.get("_parser_unavailable"): diff --git a/agentwire/safety/_core.py b/agentwire/safety/_core.py index 7bdfbadf..ba8545c0 100644 --- a/agentwire/safety/_core.py +++ b/agentwire/safety/_core.py @@ -24,7 +24,7 @@ import sys import threading from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Iterator, List, Optional, Tuple try: import yaml @@ -58,7 +58,8 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": (r'>\s*{path}', "write"), (r'\btee\s+(?!.*-a).*{path}', "write"), (r'\bdd\s+.*\bof=\S*{path}', "write"), - (r'\brsync\s+.*\s+{path}', "write"), + # rsync writes its DESTINATION: the last operand (options may trail it) + (r'\brsync\b[^\n|;&]*\s["\']?{path}[^\s|;&]*(?:\s+--?[\w-]+(?:=\S*)?(?:\s+[^-\s]\S*)?)*\s*(?:$|[;&|#]|\d*[<>])', "write"), (r'\btar\s+(-c|--create)\S*\s+.*{path}', "write"), (r'\binstall\s+.*\s+{path}', "write"), ] @@ -224,9 +225,10 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": r')\s+(?:-\S+\s+|\d+[smhd]?\s+)*)*(?:\S*/)?' ) PROTECTED_WRITER_PATTERNS = [ - # ln/link replace the TARGET (last operand); using the path as the source - # (``ln -s /tmp/l``) is a read. - (r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{path}[^\s|;&]*\s*(?:$|[;&|#]|\d*[<>])', "write"), + # ln/link naming the path on EITHER side: as the target it is replaced, as + # the source it becomes a symlink / hardlink alias that a later write + # (``echo x > /tmp/l``) reaches the file through, outside this scan's view. + (r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{path}', "write"), # downloaders: output file flags (incl. log/header/cookie-jar sinks) (r'\bcurl\b' + _SEG + r'(?:\s-(?-i:[A-Za-z0-9]*[oDc])\s*|\s--(?:output|dump-header' r'|cookie-jar|trace|trace-ascii|stderr|etag-save)[\s=])["\']?{path}', "write"), @@ -276,6 +278,8 @@ def _rx(pattern: str, flags: int = re.IGNORECASE) -> "re.Pattern[str]": r'\bunzip\b' + _SEG + r'\s-d\s*["\']?{dir}' + _DIR_END, r'\b(?:cp|mv|install|ln)\b' + _SEG + r'(?:\s-(?-i:[A-Za-z]*t)\s*|\s--target-directory[=\s])["\']?{dir}' + _DIR_END, + # a link to the directory (``ln -s /tmp/d``) is an alias for every file in it + r'\b(?:ln|link)\b' + _SEG + r'\s["\']?{dir}' + _DIR_END, ] _CONCAT_RE = _rx( @@ -3132,10 +3136,11 @@ def _extracts_into_protected_dir(command: str) -> Optional[str]: # concatenate (``'E'OF`` is the delimiter ``EOF``), and ANY quote or backslash # in it makes the body literal. _HEREDOC_WORD = ( - r"(?:'[^']*'|\"(?:[^\"\\]|\\.)*\"|\\.|\$'[^']*'|[^\s;&|<>()'\"\\$]+|\$(?!['\"]))+" + r"(?:'[^']*'|\$?\"(?:[^\"\\]|\\.)*\"|\\.|\$'[^']*'|[^\s;&|<>()'\"\\$]+|\$(?!['\"]))+" ) _HEREDOC_OPEN_RE = _rx(r"(?-?)[ \t]*(?P" + _HEREDOC_WORD + r")") _HEREDOC_PART = _rx(r"'([^']*)'|\"((?:[^\"\\]|\\.)*)\"|\\(.)|\$'([^']*)'|([^'\"\\]+)") +_LOCALE_QUOTE = _rx(r"'[^']*'|\$(?=\")") _RESCAN_DEPTH = 3 _RESCAN_FANOUT = 32 _RESCAN_BUDGET = 200 # derived commands scanned per top-level check @@ -3143,6 +3148,8 @@ def _extracts_into_protected_dir(command: str) -> Optional[str]: def _heredoc_delimiter(word: str) -> Tuple[str, bool]: """``(delimiter, body_is_literal)`` for a heredoc delimiter word.""" + # ``$"EOF"`` is a locale-translated string: bash quotes it like ``"EOF"`` + word = _LOCALE_QUOTE.sub(lambda m: "" if m.group(0) == '$' else m.group(0), word) parts = _HEREDOC_PART.findall(word) delim = "".join("".join(p) for p in parts) return delim, any(p[0] or p[1] or p[2] or p[3] for p in parts) or "'" in word or '"' in word @@ -4441,7 +4448,31 @@ def _brace_variants_of(command: str) -> List[str]: return brace_variants(command) or [] -_GLOB_TOKEN = _rx(r"(?()]*(?:[*?\[]|\$(?:\w|\{))[^\s'\"`;|&<>()]*") +_GLOB_RUN = _rx(r"[^\s'\"`;|&<>()]+") +_GLOB_MARK = _rx(r"[*?\[]|\$(?:\w|\{)") +_NOT_WORD_OR_DOLLAR = _rx(r"[^\w$]") +_WORD_OR_DOLLAR = _rx(r"[\w$'\"]") + + +def _glob_tokens(command: str) -> "Iterator[str]": + """Each unquoted word holding a glob / expansion mark, from where a word may start (#1132). + + One linear pass over the word runs. A single regex of the form + ``[^delims]*(?:mark)[^delims]*`` retries from every offset of a long run + that holds no mark, which made a 20 KB run of ``../`` or ``\\`` cost seconds + (#1111). The result is the same text: a word preceded by a word character, + ``$`` or a quote starts at its first character after a non-word one. + """ + for m in _GLOB_RUN.finditer(command): + run, begin = m.group(0), m.start() + start = 0 + if begin and _WORD_OR_DOLLAR.match(command[begin - 1]): + gap = _NOT_WORD_OR_DOLLAR.search(run) + if gap is None: + continue + start = gap.start() + 1 + if _GLOB_MARK.search(run, start): + yield run[start:] _EXPANSION = _rx(r"\$(?:\w+|\{[^}]*\})") _GLOB_PATH_CAP = 32 @@ -4507,19 +4538,19 @@ def _glob_variants(command: str) -> List[str]: run of 3+ characters (``rm *`` names nothing in particular). A component that is only an expansion (``~/.agentwire/$f``) stays a declared residual. """ - if not _GLOB_TOKEN.search(command): + tokens = list(_glob_tokens(command)) + if not tokens: return [] _, project = _protected_glob_candidates(os.path.expanduser("~")) out: List[str] = [] - for m in _GLOB_TOKEN.finditer(command): - tok = m.group(0) + for tok in tokens: if tok.startswith("/"): hits = _glob_hits(tok) else: rx = _token_regex(tok) hits = [] if rx is not None and re.search( - r"[A-Za-z0-9_.\-]{3,}", _EXPANSION.sub("", re.sub(r"[*?\[\]]", " ", tok)) + r"[A-Za-z0-9_.\-]{3,}", _EXPANSION.sub("", re.sub(r"\[[^\]]*\]|[*?\[\]]", " ", tok)) ): hits = [p for p in project if rx.fullmatch(p) or rx.fullmatch("x" + p)] for hit in hits: @@ -4542,9 +4573,21 @@ def _glob_hits(tok: str) -> List[str]: return hits +# A command's start: line start or after a separator / reserved word, then any +# wrappers and ``VAR=value`` prefixes. A prefix value may hold whitespace inside +# ``${…}`` / quotes (``A=${x:-\n} cd dir``), which a bare ``\S*`` cut short. +_CMD_START = ( + r"(?:^\s*|(?:\b(?:then|do|else|elif|if|while|until|time|coproc)\b|[;&|\n(){!])\s*)" + r"(?:(?:time|nohup|command|builtin|env|exec|sudo|!|\w+=(?:\$\{[^}]*\}|\"[^\"]*\"|'[^']*'|[^\s\"'$;&|()]|\$)*)\s+(?:-\S+\s+)*)*" +) +# ``cd dir >/dev/null 2>&1 &&``: redirects between the target and the separator +_CD_REDIRECTS = r"(?:\s+(?:\d*|&)(?:>>?|<&?|>&|>\|)\s*[^\s;&|()<>]+)*" _CD_RE = _rx( - r"(?:^\s*|(?:\b(?:then|do|else|elif|if|while|until|time|coproc)\b|[;&|\n(){!])\s*)(?:(?:time|nohup|command|builtin|env|exec|sudo|!|\w+=\S*)\s+(?:-\S+\s+)*)*(?:cd|pushd)\s+(?:--\s+)?(?P\"[^\"]*\"|'[^']*'|[^\s;&|()]+)\s*(?=&&|;|\|\||\n)" + _CMD_START + + r"(?:cd|pushd)\s+(?:--\s+)?(?P\"[^\"]*\"|'[^']*'|[^\s;&|()<>]+)" + _CD_REDIRECTS + r"\s*(?=&&|;|\|\||\n)" ) +# ``popd`` returns to a directory the command cannot see: an unresolvable cwd. +_POPD_RE = _rx(_CMD_START + r"popd\b[^\n;&|()]*\s*(?=&&|;|\|\||\n)") _REL_WORD = _rx(r"(?'\"=])([A-Za-z0-9_.][^\s;&|()'\"<>]*)") @@ -4617,6 +4660,302 @@ def _absolutize(text: str, cwd: str) -> str: return " ".join(w if _PUNCT_WORD.fullmatch(w) else _quote_word(w) for w in resolved) +_ASSIGN_STMT = _rx( + r"(?:^|[;&|\n({]|\b(?:then|do|else))[ \t]*(?:(?:export|local|declare|readonly)[ \t]+)?(?P[A-Za-z_]\w*)=(?P(?:'[^']*'|\"[^\"]*\"|[^\s;&|()<>'\"])*)(?=[ \t]*(?:[;&|\n]|$))" +) +_VAR_REF = _rx(r"\$(?:(?P[A-Za-z_]\w*)|\{(?P[A-Za-z_]\w*)\})") + + +def _literal_vars(prefix: str) -> Dict[str, Optional[str]]: + """``NAME=value`` statements in ``prefix``: the literal value, or None when it is computed. + + The last assignment wins, so a later ``d=$(…)`` makes ``d`` unknown again. + """ + out: Dict[str, Optional[str]] = {} + for m in _ASSIGN_STMT.finditer(prefix): + lit = re.sub(r"['\"]", "", m.group("v")) + out[m.group("n")] = None if re.search(r"[$`]", lit) else lit + return out + + +def _expand_literal_vars(text: str, known: Dict[str, Optional[str]]) -> str: + """``text`` with each ``$name`` / ``${name}`` a same-command literal assignment defines replaced.""" + def _sub(m: "re.Match[str]") -> str: + val = known.get(m.group("a") or m.group("b")) + return m.group(0) if val is None else val + + return _VAR_REF.sub(_sub, text) + + +# Options that make the command run in another directory, per command word: +# ``(short options, long options, stop-at-first-operand)``. ``git -C`` / ``env +# -C`` / ``patch -d`` change the cwd of the command itself, which is what a +# relative operand (or a diff's own target paths) is resolved against. +_CHDIR_OPTIONS = { + "git": (("-C",), ("--work-tree",), False), + "env": (("-C",), ("--chdir",), True), + "patch": (("-d",), ("--directory",), False), +} +_CHDIR_HINT = _rx( + r"\bgit\b[^\n]*(?:\s-C\b|--work-tree)|\benv\b[^\n]*(?:\s-[A-Za-z]*C|--chdir)|\bpatch\b[^\n]*(?:\s-d|--directory)" +) + + +_ENV_CLUSTER = _rx(r"-[A-Za-z]+C") +_GROUP_WORDS = frozenset({"{", "}", "!", "then", "do", "else", "elif", "if", "while", "until", "time", "fi", "done"}) + + +def _segment_words(seg: str) -> List[str]: + """Words of one simple command, without the group / reserved words around it.""" + words = _words(seg) + while words and words[0] in _GROUP_WORDS: + words = words[1:] + while words and words[-1] in _GROUP_WORDS: + words = words[:-1] + return words + + +_UNESCAPE_DQ = _rx(r"\\([$`])") +_SHELL_C_FLAG = _rx(r"-[A-Za-z]*c[A-Za-z]*") + + +# Options that take a SEPARATE value, per wrapper. An attached value (``-n5``, +# ``--adjustment=5``, ``-uroot``) is one word and consumes nothing; a separate one +# (``-n 5``) must not be mistaken for the command. A wrapper not listed has no +# value-taking option here. +_WRAPPER_VALUE_OPTS = { + "nice": {"-n", "--adjustment"}, + "ionice": {"-c", "-n", "-p", "-P", "-u", "--class", "--classdata", "--pid", "--pgid", "--uid"}, + "timeout": {"-s", "-k", "--signal", "--kill-after"}, + "env": {"-u", "-C", "-S", "--unset", "--chdir", "--split-string"}, + "sudo": {"-u", "-g", "-h", "-p", "-C", "-D", "-R", "-T", "-U", "-r", "-t", "--user", "--group", "--host", "--prompt", "--chdir", "--role", "--type"}, + "doas": {"-u", "-C"}, + "stdbuf": {"-i", "-o", "-e", "--input", "--output", "--error"}, + "taskset": {"-c", "--cpu-list"}, + "chrt": set(), + "xargs": {"-n", "-I", "-L", "-P", "-s", "-d", "-E", "-a", "--max-args", "--replace", "--max-procs", "--delimiter", "--arg-file"}, + "time": {"-f", "-o", "--format", "--output"}, + "parallel": {"-j", "-n", "-N", "-S", "-a", "--jobs", "--sshlogin", "-I"}, + "sshpass": {"-p", "-f", "-d", "-P"}, +} +_POSITIONAL_ARG = _rx(r"\d+[smhd]?|0[xX][0-9a-fA-F]+") + + +def _wrapped_command_index(words: List[str]) -> Optional[int]: + """Index of the word that is the command, past prefix wrappers, their options and ``VAR=x``. + + ``sudo -u root timeout -s9 5 nice -n5 patch`` -> ``patch``. The one place + that knows which words keep the command position open + (``_POSITION_WRAPPERS`` + ``_PREFIX_WRAPPERS``) and which of their options + take a separate value (``_WRAPPER_VALUE_OPTS``). + """ + wrappers = _POSITION_WRAPPERS | _PREFIX_WRAPPERS + current = "" + skip_value = False + for i, w in enumerate(words): + if skip_value: + skip_value = False + elif os.path.basename(w) in wrappers: + current = os.path.basename(w) + elif _ASSIGN_TOKEN_RE.match(w) or _POSITIONAL_ARG.fullmatch(w): + continue + elif w.startswith("-"): + takes = _WRAPPER_VALUE_OPTS.get(current, ()) + # ``-n`` / ``--adjustment`` exactly, or a short cluster ending in a + # value option (``env -iC dir``); ``-n5`` / ``--adjustment=5`` carry theirs + skip_value = w in takes + if not skip_value and len(w) > 2 and w[1] != "-": + # a cluster: the first value-taking letter ends it, and takes the + # NEXT word only when it is the last letter (``-iC dir``, not ``-uroot``) + for k, ch in enumerate(w[1:], 1): + if "-" + ch in takes: + skip_value = k == len(w) - 1 + break + else: + return i + return None + + +def _wrapper_payload_commands(command: str) -> List[str]: + """Programs a command hands to a shell or ``ssh`` as one argument: ``sh -c '…'``, ``ssh host '…'``. + + The wrapped command is the command: the payload is re-scanned in its own + right, so a ``cd && touch `` inside it is resolved exactly as it + is at the top level (the generators that read directories only recognise a + ``cd`` at a command start, which a quote hides). + """ + if not re.search(r"\b(?:sh|bash|zsh|dash|ksh|su|ssh)\b", command): + return [] + segments, err = _scope_segments(command, grouping_ok=True) + if err: + return [] + out: List[str] = [] + for argv, _op in segments: + _, words = _split_assignments(argv) + words = [w for w in words if w not in _GROUP_WORDS] + remote = _ssh_remote_payload(words) + if remote: + while remote.startswith("-") and " " in remote: + remote = remote.split(" ", 1)[1] + out.append(remote) + i = _wrapped_command_index(words) + if i is not None and os.path.basename(words[i]) in _SHELL_NAMES: + for j in range(i + 1, len(words) - 1): + if _SHELL_C_FLAG.fullmatch(words[j]): + k = j + 1 + while k < len(words) - 1 and words[k] == "--": + k += 1 + out.append(words[k]) + break + return [fixed for payload in out for fixed in (payload, _UNESCAPE_DQ.sub(r"\1", payload)) if fixed] + + +def _chdir_option_split(words: List[str]) -> Tuple[List[str], List[str]]: + """``(directories, remaining words)`` for a command word that takes a chdir option.""" + start = 0 + while start < len(words) and _ASSIGN_WORD.match(words[start]): + start += 1 + kind = os.path.basename(words[start]) if start < len(words) else "" + if kind not in _CHDIR_OPTIONS: + return [], words + short, long_, stop_at_operand = _CHDIR_OPTIONS[kind] + dirs: List[str] = [] + rest: List[str] = [] + i = 0 + while i < len(words): + w = words[i] + if i > start and (w in short or (kind == "env" and _ENV_CLUSTER.fullmatch(w))) and i + 1 < len(words): + dirs.append(words[i + 1]) + i += 2 + continue + if i > start and kind != "git" and w[:2] in short and len(w) > 2 and not w.startswith("--"): + dirs.append(w[2:]) + elif i > start and any(w.startswith(o + "=") for o in long_): + dirs.append(w.partition("=")[2]) + elif i > start and w in long_ and i + 1 < len(words): + dirs.append(words[i + 1]) + i += 1 + else: + if stop_at_operand and i > start and not w.startswith("-") and not _ASSIGN_WORD.match(w): + rest = list(words[i:]) + break + rest.append(w) + i += 1 + return dirs, rest + + +def _cwd_scopes(command: str) -> Tuple[List[Tuple[str, str]], List[str]]: + """``(known, unknown)`` for every place the command changes directory (#1101). + + ``known`` pairs a literal directory with the text that runs in it — + ``cd &&`` (until the next ``cd``), ``git -C`` / ``--work-tree``, + ``env -C`` / ``--chdir`` and ``patch -d`` — with ``d=; cd $d`` resolved + through the same command's literal assignments. ``unknown`` holds the text + that runs after a ``cd`` the command cannot resolve (``cd -``, ``popd``, + ``cd $x`` with ``x`` set from a substitution or not at all). + """ + known: List[Tuple[str, str]] = [] + unknown: List[str] = [] + if re.search(r"\b(?:cd|pushd|popd)\b", command): + events = [(m.start(), m.end(), m.group("d").strip("'\"")) for m in _CD_RE.finditer(command)] + events += [(m.start(), m.end(), "-") for m in _POPD_RE.finditer(command)] + events.sort() + cwd: Optional[str] = None + for k, (start, stop, target) in enumerate(events): + end = events[k + 1][0] if k + 1 < len(events) else len(command) + rest = command[stop:end] + target = _expand_literal_vars(target, _literal_vars(command[:start])) + if target == "-" or re.search(r"[$`]", target): + cwd = None + unknown.append(rest) + continue + cwd = _join_dir(cwd, target) + if cwd: + known.append((cwd, rest)) + else: + unknown.append(rest) + if _CHDIR_HINT.search(command): + for seg in re.split(r"[;&|\n()]", command): + cwd = None + rest = _segment_words(seg) + for _ in range(4): # ``env -C a env -C b cmd`` nests + dirs, rest = _chdir_option_split(rest) + if not dirs: + break + for d in dirs: + cwd = _join_dir(cwd, d) + if cwd is None: + break + if cwd: + known.append((cwd, " ".join(_quote_word(w) for w in rest))) + return known, unknown + + +_PATH_WORD = _rx(r"(?()=])[^\s'\"`;|&<>()$*?\[\]{}]*[^\s'\"`;|&<>()$*?\[\]{}=-]") +_REALPATH_WORD_CAP = 64 + + +@functools.lru_cache(maxsize=4) +def _protected_inodes(home: str) -> Tuple[Tuple[str, "os.stat_result"], ...]: + """``(path, stat)`` of each concrete protected file that exists (hardlink detection).""" + out = [] + for pat in PROTECTED_CONTROL_PLANE_PATHS: + if is_glob_pattern(pat): + continue + path = os.path.expanduser(pat) + try: + out.append((path, os.stat(path))) + except OSError: + continue + return tuple(out) + + +def _under_protected(path: str) -> bool: + """Protected file, or a directory that holds / is inside one.""" + if is_protected_control_plane(path): + return True + for d in _protected_ancestor_dirs(): + e = os.path.expanduser(d) + if path == e or path.startswith(e + "/"): + return True + return False + + +def _realpath_variants(command: str) -> List[str]: + """The command with each path word that is an EXISTING alias of a protected file spelled as that file (#1101). + + The link was made by a separate command, an earlier turn, or someone else: + by the time ``echo x > /tmp/l`` runs, its text names nothing protected. The + hook can ask the filesystem. Every path word is resolved (a symlink anywhere + in it, a directory alias like ``/tmp/d/damagecontrol.yml`` included, and a + hardlink to a protected file); a word that lands on the control plane is + rewritten to where it really goes and the result is scanned as the command + it is. + """ + out: List[str] = [] + seen = set() + home = os.path.expanduser("~") + for m in itertools.islice(_PATH_WORD.finditer(command), _REALPATH_WORD_CAP): + word = m.group(0) + if word in seen or len(word) < 2: + continue + seen.add(word) + path = os.path.expanduser(word) + try: + if "/" not in path and not os.path.lexists(path): + continue + real = os.path.realpath(path) + target = real if real != posixpath.normpath(os.path.abspath(path)) and _under_protected(real) else None + if target is None and os.path.isfile(path) and os.stat(path).st_nlink > 1: + st = os.stat(path) + target = next((p for p, ps in _protected_inodes(home) if os.path.samestat(st, ps)), None) + except (OSError, ValueError): + continue + if target and target != path: + out.append(command.replace(word, _quote_word(target))) + return out + + def _cwd_variants(command: str) -> List[str]: """Relative operands resolved against a literal ``cd &&`` / ``git -C `` (#1101). @@ -4624,46 +4963,115 @@ def _cwd_variants(command: str) -> List[str]: to a directory set earlier in the same command. The literal directory is applied to later words (until the next ``cd``); each word that then names a protected file is rewritten to its absolute path and the result re-scanned. - An unresolvable directory (``cd $d``, a ``$(…)``, a bare ``cd -``) is the - declared residual, as is a cwd known only from the session. + A directory that cannot be resolved is judged by ``unverifiable_target_verdict``. """ - out: List[str] = [] - if re.search(r"\b(?:cd|pushd)\b", command): - cwd: Optional[str] = None - matches = list(_CD_RE.finditer(command)) - for k, m in enumerate(matches): - cwd = _join_dir(cwd, m.group("d").strip("'\"")) - if not cwd: + return [ + new + for cwd, rest in _cwd_scopes(command)[0] + for new in (_absolutize(rest, cwd),) + if new != rest + ] + + +def _protected_dirs() -> List[str]: + """Directories that hold a concrete protected file (``~/.agentwire``, ``~/.claude/hooks``, ...).""" + dirs = { + posixpath.dirname(os.path.expanduser(p)) + for p in PROTECTED_CONTROL_PLANE_PATHS + if not p.startswith("*") + } + return sorted(dirs) + + +# Names that are control plane in ANY directory, or that no other program uses. +# ``settings.json`` / ``config.yaml`` are protected where they sit under +# ``~/.claude`` / ``~/.agentwire`` but are ordinary files everywhere else, so +# a bare one after a ``cd`` the scanner cannot read is not evidence of anything. +_GENERIC_BASENAMES = frozenset({"settings.json", "config.yaml"}) +_PROJECT_PLACEHOLDER = "/hypothetical-project" + + +def _named_control_plane_files() -> List[Tuple[str, "frozenset[str]"]]: + """``(directory, distinctive basenames)`` a bare name after an unreadable ``cd`` could mean.""" + by_dir: Dict[str, set] = {} + for p in PROTECTED_CONTROL_PLANE_PATHS: + if p.startswith("*"): + d, name = _PROJECT_PLACEHOLDER, p[1:] + elif is_glob_pattern(p): + continue + else: + expanded = os.path.expanduser(p) + d, name = posixpath.dirname(expanded), posixpath.basename(expanded) + if name not in _GENERIC_BASENAMES: + by_dir.setdefault(d, set()).add(name) + return [(d, frozenset(names)) for d, names in sorted(by_dir.items())] + + +def unverifiable_target_verdict( + command: str, allowed_paths: List[Dict[str, Any]], depth: int = 0 +) -> Optional[str]: + """Why ``command`` needs a human, when what it writes to is unknowable and the control plane is in play. + + A ``cd`` the scanner cannot resolve (``cd -``, ``popd``, ``cd $x``) leaves a + later bare ``damagecontrol.yml`` pointing at any directory. Each protected + directory is tried as that directory; when the text that follows would be a + refused write there, the answer is an ASK, never a block (the directory may + well be somewhere else). A ``patch`` / ``git apply`` / ``git am`` whose + directory is a protected one is the same question: the files it rewrites + are named only inside the diff. So is a brace expansion past the cap in a + command that names the control plane: it cannot be enumerated, so it cannot + be shown clean. + """ + if depth < _RESCAN_DEPTH: + for inner in _wrapper_payload_commands(command) + _hidden_commands(command): + reason = unverifiable_target_verdict(inner, allowed_paths, depth + 1) + if reason: + return reason + if "{" in command and mentions_control_plane(command) and brace_variants(canonicalize_command(command)) is None: + return "a brace expansion past the cap cannot be enumerated" + known, unknown = _cwd_scopes(command) + for cwd, rest in known: + if cwd in _protected_dirs() or cwd == os.path.expanduser("~"): + if _applies_diff(rest): + return f"`patch` / `git apply` in {cwd} — the files it rewrites are named only in the diff" + for rest in unknown: + for d, names in _named_control_plane_files(): + new = _absolutize(rest, d) + if new == rest or not names.intersection(map(posixpath.basename, _words(new))): continue - end = matches[k + 1].start() if k + 1 < len(matches) else len(command) - rest = command[m.end() : end] - new = _absolutize(rest, cwd) - if new != rest: - out.append(new) - if re.search(r"\bgit\b[^\n]*(?:\s-C\b|--work-tree)", command): - for seg in re.split(r"[;&|\n]", command): - words = _words(seg) - dirs, rest, i = [], [], 0 - while i < len(words): - w = words[i] - if w == "-C" and i + 1 < len(words): - dirs.append(words[i + 1]) - i += 2 - continue - if w.startswith("--work-tree="): - dirs.append(w.partition("=")[2]) - elif w == "--work-tree" and i + 1 < len(words): - dirs.append(words[i + 1]) - i += 1 - else: - rest.append(w) - i += 1 - cwd = None - for d in dirs: - cwd = _join_dir(cwd, d) - if cwd and dirs: - out.append(_absolutize(" ".join(_quote_word(w) for w in rest), cwd)) - return out + if _check_protected_command(new, allowed_paths, 1, [_RESCAN_BUDGET])[0]: + return ( + "a `cd` the scanner cannot resolve precedes a write to a protected " + f"control-plane file name (it would be in {d} if the cd lands there)" + ) + return None + + +_DIFF_READ_ONLY = frozenset({"--dry-run", "--check", "--stat", "--numstat", "--summary"}) + + +def _applies_diff(text: str) -> bool: + """True when some command in ``text`` applies a diff for real (``patch``, ``git apply|am``). + + Judged per simple command: a ``--dry-run`` in one command does not excuse + the next, and the verb is matched by basename (``/usr/bin/patch``). + """ + for seg in re.split(r"[;&|\n()]", text): + words = _segment_words(seg) + i = _wrapped_command_index(words) + if i is None: + continue + head = os.path.basename(words[i]) + if head == "git": + j = i + 1 + while j < len(words) and words[j].startswith("-"): + j += 2 if words[j] in ("-C", "-c") else 1 + applies = j < len(words) and words[j] in ("apply", "am") + else: + applies = head == "patch" + if applies and not _DIFF_READ_ONLY.intersection(words[i:]): + return True + return False def _derived_commands(command: str) -> List[str]: @@ -4676,13 +5084,15 @@ def _derived_commands(command: str) -> List[str]: # judged as the program it is. visible = _hide_heredoc_bodies(command, keep_programs=True) if "<<" in command else command derived = ( - _heredoc_substitution_commands(command) + _wrapper_payload_commands(visible) + + _heredoc_substitution_commands(command) + _hidden_commands(command) + _redirect_variant(visible) + _normalized_path_variant(visible) + _brace_variants_of(visible) + _glob_variants(visible) + _cwd_variants(visible) + + _realpath_variants(visible) ) return list(dict.fromkeys(derived))[:_RESCAN_FANOUT] @@ -8145,6 +8555,20 @@ def _decide_rules( "protected": True, } + # A directory the scanner cannot see: same tier (exempt from the escape + # hatch and kill switch), but an ASK — the command may be harmless, and + # `unverifiable` keeps bypass from demoting it. + target_reason = unverifiable_target_verdict(command, control_plane_allowlist(allowed)) + if target_reason: + return { + "decision": "ask", + "reason": f"Unverifiable control-plane target ({target_reason}) — a human must confirm it (not demoted by bypass)", + "pattern": "ambiguous:control-plane-target", + "command": command, + "id": "core.control-plane-target", + "unverifiable": True, + } + # Parser unavailable ⇒ no rules loaded ⇒ fail closed (block), before the # escape hatch / kill switch can wave it through. if config.get("_parser_unavailable"): diff --git a/docs/wiki/internals/damage-control.md b/docs/wiki/internals/damage-control.md index ebecefb6..357fd3c3 100644 --- a/docs/wiki/internals/damage-control.md +++ b/docs/wiki/internals/damage-control.md @@ -275,12 +275,12 @@ it would unwrapped: terminator is scanned as commands (a rephrase). So is one whose `$(…)`/backtick span closes before the newline (bash runs the next line). A partly quoted delimiter is one word, so `<<'EO'F` ends at `EOF` like `<<'E'OF`, and its literal body is data like any quoted - heredoc. Declared residuals, all scanned rather than hidden (a rephrase, not a - bypass): `<<$"EOF"` / `<<-$"EOF"` (locale-quoted delimiter) is not - recognised, so its body is read as commands; a `case … esac` pattern `)` + heredoc. `<<$"EOF"` / `<<-$"EOF"` is a locale-quoted delimiter: it quotes like + `<<"EOF"`, so its body is data (#1132). Declared residuals, all scanned rather + than hidden (a rephrase, not a + bypass): a `case … esac` pattern `)` inside `$(…)` can end that span early; `<<` inside a nested `$(…)` within - `$((…))` is not walked; a single very long line is quadratic in the rule - path on main and here (tracked in #1108, not part of this change); `cat <<\EOF | xargs sh -c` (a heredoc body fed to a + `$((…))` is not walked; `cat <<\EOF | xargs sh -c` (a heredoc body fed to a data-fed program) is allowed, as it already was for the other quote spellings. `tests/unit/test_heredoc_marker_liveness.py` pins both halves (non-heredoc contexts × dangerous tails must block; real heredocs' prose bodies must keep their decision) and each guard has a mutation that @@ -310,7 +310,10 @@ re-scanned as a command, so the verb still decides (reads stay allowed): - **Redirect operators** `>|`, `>&`, `&>`, `&>>`, `<>` are the same write as `>` (the subcommand splitter otherwise reads `>|` as a pipe). - **Brace expansion** (`damagecontrol.y{m,x}l`) is exact — every expansion is - scanned (a group count past the cap is not expanded: residual). + scanned. A group count past the cap (256 variants) cannot be enumerated, so a + command that names the control plane AND outgrows the cap is an unbypassable + ASK (`core.control-plane-target`, #1132); without a control-plane name it is + left to the zero-access fail-closed block it already hit. - **Globs and partial expansions** (`damage*.yml`, `damagecontrol.y?l`, `damage$x`): the word is matched with shell-glob semantics against the concrete protected paths and the real directory listing, and each hit is @@ -332,6 +335,54 @@ re-scanned as a command, so the verb still decides (reads stay allowed): files in that directory (`rm *.yml` there is refused, `cat d*ntrol.yml` is not). `~` and `$HOME` are directories for `git -C` / `--work-tree` as well (`git -C ~ checkout .agentwire/damagecontrol.yml`). +- **Path resolution / cwd lane (#1132).** The directory a relative name is + read against is resolved from everything the command itself says: + `d=~/.agentwire; cd $d && touch damagecontrol.yml` (a literal assignment + earlier in the same command, last one wins, a `$(…)` value makes it unknown), + `env -C ` / `env --chdir=` and `patch -d `, next to `cd` and + `git -C`. The `cd` chain regex accepts whitespace inside a prefix assignment + (`A=${x:-\n} cd && …`, `A="a b" cd `). A command inside + `sh -c '…'` / `ssh host '…'` is re-scanned as a command in its own right + (`_wrapper_payload_commands`), so the same resolution applies inside the + wrapper — the decision equals the top-level one (payload found through + `timeout 5`, `sudo -u x`, `env -C d`, `--`, `ssh host -t`, and `\$` inside a + double-quoted payload). Redirects on the `cd` (`cd d >/dev/null 2>&1 &&`) do + not hide the directory; `env -iC` clusters and nested `env -C a env -C b` + resolve; a relative `cd` with no known base (`cd ..`) is unresolved. The + unresolvable-`cd` ask is evaluated against the control-plane allowlist, so a + default `/tmp/*` allowance cannot waive it. The `patch`/`git apply` read-only + flags excuse only the command they sit in, and the verb is matched by basename. + Not covered: a symlink alias reached through a `cd` directory the hook's own + cwd cannot see (`cd && touch dirlink/…`), and a bare `cd`. +- **A `cd` the scanner cannot resolve** (`cd -`, `popd`, `cd $x` with `x` not a + literal assignment) followed by a distinctive protected NAME that the rest of + the command would write (`cd - && touch damagecontrol.yml`) is an ASK that + bypass does not demote (`core.control-plane-target`, `unverifiable`; + unattended: a block unless the id is granted). Each directory that holds the + name is tried as the unknown directory and the text after the `cd` is judged + as the refused write it would be there. `settings.json` and `config.yaml` + are not "distinctive" (ordinary files everywhere else): `cd $x && sed -i … + settings.json` stays allowed. Reads stay allowed. +- **`patch` / `git apply` / `git am` run in a protected directory** (`cd + ~/.agentwire && patch -p0 < x.diff`, `git -C ~/.claude apply x.diff`, `patch + -d `; also `~` itself, where a diff names `.agentwire/…`) ask: the files + they rewrite are named only inside the diff. `--dry-run`, `--check`, + `--stat`, `--numstat` and `--summary` are reads and stay allowed. Only a + literal directory counts: the session cwd and a project's `.damagecontrol.yml` + directory are not enumerable. +- **`rsync` writes its destination only** — the last operand, options may trail + it. `rsync -a ~/.agentwire/damagecontrol.yml host:x` reads the file and is + allowed; `rsync -a src ~/.agentwire/damagecontrol.yml` is refused. +- **Aliases.** `ln` / `link` naming a protected file or one of its directories + on EITHER side is refused (a symlink or hardlink is how a later + `echo x > /tmp/l` reaches the file outside the scan's view); a link to a + directory that holds no protected file (`ln -s ~/.agentwire/sessions /tmp/d`) + is allowed. At hook time every path word is resolved against the filesystem + (`_realpath_variants`: a symlink anywhere in the word, a symlinked directory, + or a hardlink to a concrete protected file) and a word that really lands on the + control plane is scanned as that file, so an alias made by an earlier command + or turn is judged by where it goes (reads stay allowed). At most 64 words + per command are resolved. - `xargs -I{}` / `parallel` substitute into an interpreter literal (`… | xargs -I{} python3 -c 'open("{}","w")'`); because the interpreter net is fail-closed for any inline program naming the path, an inline *read* of it is @@ -342,18 +393,20 @@ Declared residuals (not statically knowable): paths assembled at runtime component that is only `$var`), stdin text produced by anything but `echo`/`printf`/a heredoc (`cat file | ssh host`, `bash -c "$(cat x)"` names no path until it runs), text that a stdin reader receives through a stage the -scanner does not model (`… | tee x | ssh host`, `… | ssh host xargs -0 sh`), a -`patch` / `git apply` (including `git -C apply`) -whose target is named only inside the diff, a `cd` written inside interpreter source +scanner does not model (`… | tee x | ssh host`, `… | mosh host`, +`… | ssh host xargs -0 sh`), interpreter source piped to a remote interpreter +(`echo "open('','w')" | ssh host python3` — the text is re-scanned as +shell, not as python), `sftp get`, `ssh host lua` (and +other interpreters outside the list), a `cd` written inside interpreter source (`perl -e 'system("cd && touch ")'`), an sftp batch read from a -file (`sftp -b file`; stdin batches' `get` / `!` lines ARE judged), -`cd && rsync -a host:x` (the absolute spelling -is refused too — rsync's source is not distinguished from its destination), a -`<<$"EOF"` (not recognised as a heredoc, so a body line that is itself -`cd && touch ` blocks; #1106), a directory taken from `$(pwd)` after a `cd` (ask-tier, not -resolved), a cwd known only from the session or set by `cd $dir` / `cd -` / a -subshell the command cannot see, `make -C`/`env -C`-style directory options, -writes through a symlink or hardlink alias, a brace expansion past the cap, a +file (`sftp -b file`; stdin batches' `get` / `!` lines ARE judged), a body piped into a +`sh -c '{}'` placeholder (`cat <<'EOF' | xargs -I{} sh -c '{}'` with the literal +path in the body), a directory taken from `$(pwd)` after a `cd` (ask-tier, not +resolved), a cwd known only from the session or set inside a subshell the +command cannot see (an unresolvable `cd` followed by a distinctive protected +name asks instead — above), `make -C`-style directory options of other tools +(`make -C clean` runs a Makefile nobody can read), a symlink or hardlink +alias created AND written through by the same command from a computed name, a derived-command budget exhausted by a pathological command, and the TOCTOU window. Directory-extraction spellings `/.`, `//`, `/sub/..`, `--one-top-level=` and `bsdtar` are normalized. Pins: @@ -1782,9 +1835,13 @@ whether `SENTINEL` exists. A control hook that exits 2 at once must block. Speed: the protected/zero-access path patterns are prefiltered on the path's required literal and `_PATH_ODDITY` is anchored at token starts. This makes long UNQUOTED tokens linear (10 k chars: 9.8 s -> 0.2 s) and plain quoted -strings linear (30 k chars: 0.2 s). Quoted strings full of path-like text are -still super-linear (30 K of `a/b/` takes ~2 s, a quoted `../` run ~8.5 s); those, -and runs of `\` or `{a,`, are bounded by the clock budget rather than fixed. +strings linear (30 k chars: 0.2 s). The glob-word scan (`_glob_tokens`, #1132) +is one pass over the word runs: the single regex it replaced retried from every +offset of a long run holding no glob mark, which was the whole cost of a 7 000- +fold `../` run (4.9 s), a 20 000-fold `\` run (2.1 s) and a 7 000-fold `{a,` run +(1.8 s) — now 0.6 / 0.3 / 0.5 s, and doubling the input doubles the time for +quoted and unquoted `../`, `a/b/`, `\` and `{a,` runs alike. The clock budget +stays the backstop for a shape not yet found, not the mechanism for these. A writer verb next to a protected path stays blocked with a long quoted string before it, after it, or holding the path, balanced or not (pinned; a differential against main over that slice found no decision differences). Known allow, same @@ -1840,9 +1897,7 @@ sweep, decision equal to top level, in all four kill-switch x unattended states) in `tests/unit/test_compound_command_positions.py`, plus must-allow controls and exact-shape pins for the stripper. -**Declared residuals:** a glob like `[a-z]*` in the same command as a pipe into a -shell can expand onto a control-plane name (`echo [a-z]*; echo hi | sh` blocks at -top level too; false-block direction, not wrapper-specific). A substitution in a +**Declared residuals:** a substitution in a case pattern is ask-tier, never allowed. ### Hidden commands are judged as commands (#1136) @@ -1925,13 +1980,7 @@ Pinned by a differential in `tests/unit/test_damage_control_word_spans.py` decision, plus the kill-switch-off / unattended states), with must-allow controls and scanner-shape pins. -**Declared residuals:** `rm foo -rf /x` (any operand BEFORE the flags) is -allowed at top level too — the `rm` rules require the flags directly after `rm` -(core.yaml), so no construct is needed to hide it; that is a rule-regex gap, not -a tokenizer one. The `cd && …` chain regex (`\w+=\S*` prefix) misses an -assignment prefix whose value or subscript contains whitespace/newline -(`A=${x:-\n} cd ~/.agentwire && touch …`); it lives in the protected-path -resolution lane (#1121). `normalize_subcommands` / `_scope_segments` still split +**Declared residuals:** `normalize_subcommands` / `_scope_segments` still split with shlex punctuation (their consumers are the raw/unanchored haystacks, which see the verb regardless). diff --git a/tests/unit/test_control_plane_writers.py b/tests/unit/test_control_plane_writers.py index a713edbe..364aa306 100644 --- a/tests/unit/test_control_plane_writers.py +++ b/tests/unit/test_control_plane_writers.py @@ -69,6 +69,9 @@ def test_bundled_rules_all_loaded(cfg): MUST_BLOCK = [ # plain writers with no flags: the path is just an operand "ln -sf /tmp/x @P", + # the path as the SOURCE is an alias for it (#1132): a later write reaches the file through the link + "ln -s @P /tmp/link", + "ln @P /tmp/hardlink", "ln /tmp/x @P", "cd /tmp && ln -sfn /tmp/x @P", "curl -o @P http://e.com/x", @@ -339,7 +342,6 @@ def test_project_scoped_control_plane_files_get_the_same_treatment(cfg): "gzip -c @P > /tmp/x.gz", "gzip -dc @P.gz", "zstd -t @P.zst", - "ln -s @P /tmp/link", "tar czf /tmp/bak.tgz @P", "tar tzf /tmp/a.tgz", # xargs / loops / shells whose verb only reads diff --git a/tests/unit/test_heredoc_marker_liveness.py b/tests/unit/test_heredoc_marker_liveness.py index c08297b5..96182a26 100644 --- a/tests/unit/test_heredoc_marker_liveness.py +++ b/tests/unit/test_heredoc_marker_liveness.py @@ -55,7 +55,6 @@ "echo '<; cd $d``, +``env -C``), an alias made before the hook ran (``ln -s``, a hardlink, a +symlinked directory), and a diff that names its own targets (``patch``, +``git apply``). The rest fails closed with an ASK that bypass cannot demote. + +Each test is one operation with a loop over its spellings: a failure lists +every spelling that went the wrong way at once. The decisions are measured +against the bundled rules, never the machine's live copies. +""" + +import os +import random +import re +import shlex +import time +from pathlib import Path + +import pytest + +from agentwire.safety import _core +from agentwire.safety._core import check_command, load_config + +pytestmark = pytest.mark.real_agentwire_home + +REPO = Path(__file__).resolve().parent.parent.parent +RULES_DIR = REPO / "agentwire" / "hooks" / "damage-control" / "rules" +HOME = "/home/cptest" +CTL = f"{HOME}/.agentwire/damagecontrol.yml" +AW = f"{HOME}/.agentwire" + + +@pytest.fixture(autouse=True) +def _fixed_home(monkeypatch): + monkeypatch.setenv("HOME", HOME) + + +@pytest.fixture(scope="module") +def cfg(): + c = load_config(RULES_DIR, REPO / "agentwire" / "tooldefs") + c["safety"] = {"enabled": True, "disabled_rules": [], "unattended_allow": []} + return c + + +def _decide(command, cfg): + return check_command(command, cfg)["decision"] + + +def _wrongly(commands, want, cfg): + """Commands whose decision is not ``want`` (``refuse`` = block or ask).""" + bad = [] + for cmd in commands: + d = _decide(cmd, cfg) + ok = d in ("block", "ask") if want == "refuse" else d == want + if not ok: + bad.append((d, cmd)) + return bad + + +# ---- #15: false blocks ------------------------------------------------------- + + +def test_rsync_writes_only_its_destination(cfg): + sources = [ + f"rsync -a {CTL} host:x", + f"rsync -av {CTL} host:/backup/", + f"cd {AW} && rsync -a damagecontrol.yml host:x", + f"rsync -a --exclude=x {CTL} host:x", + ] + destinations = [ + f"rsync -a src {CTL}", + f"rsync -a src {CTL} --delete", + f"rsync -a src {CTL} --exclude=x", + f"rsync -a src1 src2 {CTL}", + f"cd {AW} && rsync -a src damagecontrol.yml", + f"rsync -a {CTL} {CTL}", + ] + assert not _wrongly(sources, "allow", cfg) + assert not _wrongly(destinations, "refuse", cfg) + + +def test_locale_quoted_heredoc_delimiter_is_a_quoted_heredoc(cfg): + body = f"cd {AW} && touch damagecontrol.yml" + data = [f'cat <<$"EOF"\n{body}\nEOF', f'cat <<-$"EOF"\n\t{body}\n\tEOF', f"cat <<'EOF'\n{body}\nEOF"] + runs = [f'bash <<$"EOF"\ntouch {CTL}\nEOF', f'bash <<-$"EOF"\n\ttouch {CTL}\n\tEOF'] + unquoted_is_still_scanned = [f"cat < /tmp/x.diff", + ] + assert not _wrongly(asks, "ask", cfg) + assert not _wrongly(quiet, "allow", cfg) + + +# ---- #20: brace expansion past the cap ---------------------------------------- + + +def test_brace_expansion_past_the_cap_cannot_hide_the_control_plane(cfg): + groups = "{a,b}" * 10 + over = [ + f"touch {AW}/{groups}damagecontrol.yml", + f"cd {AW} && touch {groups}damagecontrol.yml", + f"rm {HOME}/.claude/settings.json {groups}", + ] + under = [f"touch /tmp/{'{a,b}' * 4}x", f"ls {AW}/{'{a,b}' * 4}"] + off = dict(cfg, safety={"enabled": False, "disabled_rules": []}) + refused = [c for c in over if check_command(c, off)["decision"] not in ("ask", "block")] + assert not refused, refused # kill-switch exempt, like the rest of the protected tier + assert not _wrongly(under, "allow", cfg) + + +# ---- #26: aliases ------------------------------------------------------------ + + +def test_a_link_to_the_control_plane_is_refused(cfg): + refused = [ + f"ln -s {CTL} /tmp/lnk", + f"ln {CTL} /tmp/hl", + f"ln -sf {CTL} /tmp/lnk", + f"ln -s {AW} /tmp/d", + f"ln -s {HOME}/.claude/hooks /tmp/d", + f"cd {AW} && ln -s damagecontrol.yml /tmp/lnk", + f"ln -s /tmp/a {CTL}", + ] + allowed = [ + "ln -s /tmp/a /tmp/b", + f"ln -s {AW}/sessions /tmp/d", + f"ln -s {HOME}/.claude/projects /tmp/d", + f"ls -l {CTL}", + ] + assert not _wrongly(refused, "refuse", cfg) + assert not _wrongly(allowed, "allow", cfg) + + +@pytest.fixture +def real_home(tmp_path, monkeypatch): + home = tmp_path / "home" + (home / ".agentwire").mkdir(parents=True) + (home / ".agentwire" / "damagecontrol.yml").write_text("x") + (home / ".agentwire" / "notes.txt").write_text("x") + work = tmp_path / "work" + work.mkdir() + (work / "symlink").symlink_to(home / ".agentwire" / "damagecontrol.yml") + os.link(home / ".agentwire" / "damagecontrol.yml", work / "hardlink") + (work / "dirlink").symlink_to(home / ".agentwire") + (work / "benign").symlink_to(home / ".agentwire" / "notes.txt") + (work / "plain").write_text("x") + monkeypatch.setenv("HOME", str(home)) + _core._protected_inodes.cache_clear() + yield home, work + _core._protected_inodes.cache_clear() + + +def test_a_path_that_already_aliases_the_control_plane_is_judged_as_the_file(real_home, cfg): + _home, work = real_home + writes = [ + f"echo x > {work}/symlink", + f"echo x >> {work}/symlink", + f"rm {work}/symlink", + f"touch {work}/dirlink/damagecontrol.yml", + f"cp /tmp/a {work}/dirlink/damagecontrol.yml", + f"echo x > {work}/hardlink", + f"tee {work}/hardlink", + ] + fine = [ + f"cat {work}/symlink", + f"cat {work}/hardlink", + f"cat {work}/dirlink/damagecontrol.yml", + f"echo x > {work}/plain", + f"echo x > {work}/benign", + f"echo x > {work}/not-yet-created", + f"ls {work}/dirlink", + ] + assert not _wrongly(writes, "refuse", cfg) + assert not _wrongly(fine, "allow", cfg) + + +# ---- #21: the glob-word scan is linear --------------------------------------- + +# The scan this replaced, verbatim: a regex that retries from every offset of a +# long run of non-delimiters. Kept as the oracle for the decision-preserving +# claim and as the measure of what the replacement avoids. +_OLD_GLOB_TOKEN = re.compile(r"(?()]*(?:[*?\[]|\$(?:\w|\{))[^\s'\"`;|&<>()]*") + + +def test_glob_words_are_the_same_words_the_old_regex_found(): + rng = random.Random(1132) + alphabet = ["a", "b", "_", "$", "*", "?", "[", "]", "{", "/", ".", " ", "'", '"', ";", "(", "é", "-", "x$", "$x"] + differing = [] + for _ in range(4000): + text = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 24))) + old = [m.group(0) for m in _OLD_GLOB_TOKEN.finditer(text)] + if old != list(_core._glob_tokens(text)): + differing.append(text) + assert not differing, differing[:5] + + +def test_glob_word_scan_does_not_retry_from_every_offset_of_a_long_run(): + runs = ["../" * 7000, "\\" * 20000, "{a," * 7000, "x" * 20000] + slow = [] + for run in runs: + start = time.perf_counter() + list(_core._glob_tokens("ls " + run)) + took = time.perf_counter() - start + if took > 0.5: # the old scan took seconds on each; this takes milliseconds + slow.append((run[:6], round(took, 2))) + assert not slow, slow + + +# ---- review round 1: allowance, redirects, per-segment diffs, wrapper gaps ---- + +CARRIERS = [ + lambda c: c, + lambda c: f"bash -c {shlex.quote(c)}", + lambda c: f"({c})", + lambda c: f"{{ {c}; }}", + lambda c: f"ssh host {shlex.quote(c)}", + lambda c: f"timeout 5 bash -c {shlex.quote(c)}", +] + + +def _carried_wrongly(spellings, want, cfg): + bad = [] + for spelling in spellings: + for carry in CARRIERS: + cmd = carry(spelling) + d = _decide(cmd, cfg) + if (d in ("block", "ask")) != (want == "refuse"): + bad.append((d, cmd)) + return bad + + +def test_a_home_under_a_default_allowance_does_not_waive_the_ask(tmp_path, monkeypatch, cfg): + home = tmp_path / "h" # pytest tmp dirs sit under /tmp or /private/var: both are default allowances + (home / ".agentwire").mkdir(parents=True) + monkeypatch.setenv("HOME", str(home)) + for allowed in ("/tmp/*", f"{home}/*", f"{tmp_path}/*"): + local = dict(cfg, allowedPaths=[{"path": allowed, "allow": "all"}]) + asks = ["cd - && touch damagecontrol.yml", "cd $x && touch damagecontrol.yml", f"cd {home}/.agentwire && patch -p0 < x.diff"] + assert not _wrongly(asks, "refuse", local), allowed + + +def test_a_redirect_on_the_cd_does_not_hide_the_directory(cfg): + spellings = [ + f"cd {AW} >/dev/null && touch damagecontrol.yml", + f"cd {AW} 2>/dev/null && touch damagecontrol.yml", + f"cd {AW} > /dev/null 2>&1 && touch damagecontrol.yml", + f"cd {AW} &>/dev/null && touch damagecontrol.yml", + f"d={AW}; cd $d 2>/dev/null && touch damagecontrol.yml", + f"pushd {AW} >/dev/null; touch damagecontrol.yml", + "cd $x 2>/dev/null && touch damagecontrol.yml", + "cd - >/dev/null && touch damagecontrol.yml", + "cd .. && touch damagecontrol.yml", + ] + controls = ["cd /tmp >/dev/null && touch damagecontrol.yml", "cd $x 2>/dev/null && touch notes.txt"] + assert not _carried_wrongly(spellings, "refuse", cfg) + assert not _wrongly(controls, "allow", cfg) + + +def test_a_read_only_flag_excuses_only_its_own_command(cfg): + applies = [ + f"cd {AW} && git apply --stat x.diff; git apply x.diff", + f"cd {AW} && patch --dry-run a; patch b", + f"cd {AW} && patch -p0 < x.diff # --dry-run", + f"cd {AW} && patch -p0 < x.diff | tee f --stat", + f'cd {AW} && git apply "x --check .diff"', + f"cd {AW} && /usr/bin/patch -p0 < x.diff", + f"cd {AW} && /usr/bin/git apply x.diff", + ] + reads = [f"cd {AW} && patch --dry-run -p0 < x.diff", f"cd {AW} && git apply --check x.diff", f"cd {AW} && git apply --stat x.diff"] + assert not _carried_wrongly(applies, "refuse", cfg) + assert not _wrongly(reads, "allow", cfg) + + +def test_wrapper_payloads_are_found_through_options_and_double_quote_escapes(cfg): + inner = f"d={AW}; cd $d && touch damagecontrol.yml" + spellings = [ + 'bash -c "d=%s; cd \\$d && touch damagecontrol.yml"' % AW, + f"bash -c -- {shlex.quote(inner)}", + f"ssh host -t {shlex.quote(inner)}", + f"timeout 5 bash -c {shlex.quote(inner)}", + f"env -C /tmp sh -c {shlex.quote(inner)}", + f"sudo -u root bash -lc {shlex.quote(inner)}", + ] + assert not _wrongly(spellings, "refuse", cfg) + + +def test_env_clusters_and_nesting_resolve_the_directory(cfg): + spellings = [ + f"env -iC {AW} touch damagecontrol.yml", + f"env -C /tmp env -C {AW} touch damagecontrol.yml", + "env -C / env -C home/cptest/.agentwire touch damagecontrol.yml", + ] + controls = [f"env -C {AW} env -C /tmp touch damagecontrol.yml", "env -iC /tmp touch damagecontrol.yml"] + assert not _wrongly(spellings, "refuse", cfg) + assert not _wrongly(controls, "allow", cfg) + + +def test_wrapper_payload_with_a_resolvable_directory_blocks_rather_than_asks(cfg): + """A resolved directory is a BLOCK; falling back to the unresolvable-cd ask means the payload was mis-read.""" + spellings = [ + 'bash -c "d=%s; cd \\$d && touch damagecontrol.yml"' % AW, + f"ssh host -t {shlex.quote(f'cd {AW} && touch damagecontrol.yml')}", + ] + not_blocked = [(_decide(c, cfg), c) for c in spellings if _decide(c, cfg) != "block"] + assert not not_blocked, not_blocked + + +def test_a_prefix_wrapper_does_not_hide_the_diff_applier(cfg): + verbs = ["patch -p0 < x.diff", "git apply x.diff", "git -C . am x.mbox", "/usr/bin/patch -p0 < x.diff"] + prefixes = ["", "sudo ", "sudo -u root ", "nohup ", "env ", "env A=1 ", "xargs ", "timeout 5 ", "command ", "nice -n 5 ", "sudo /usr/bin/", "time "] + bad = [] + for verb in verbs: + for prefix in prefixes: + if prefix.endswith("/") and not verb.startswith(("patch", "git")): + continue + body = verb if not prefix.endswith("/") else prefix + verb + cmd = f"cd {AW} && {body if prefix.endswith('/') else prefix + body}" + base = f"cd {AW} && {verb}" + if _decide(cmd, cfg) != _decide(base, cfg): + bad.append((_decide(base, cfg), _decide(cmd, cfg), cmd)) + assert not bad, bad + reads = [f"cd {AW} && sudo patch --dry-run -p0 < x.diff", f"cd {AW} && nohup git apply --check x.diff"] + assert not _wrongly(reads, "allow", cfg) + + +def test_cd_with_input_redirects_keeps_its_directory(cfg): + spellings = [f"cd {AW} <&- && touch damagecontrol.yml", f"cd {AW} <&0 && touch damagecontrol.yml", f"cd {AW}