diff --git a/src/permissions/bash_mode_validation.py b/src/permissions/bash_mode_validation.py new file mode 100644 index 000000000..acb85080d --- /dev/null +++ b/src/permissions/bash_mode_validation.py @@ -0,0 +1,448 @@ +"""acceptEdits-mode Bash auto-allow — port of +``typescript/src/tools/BashTool/modeValidation.ts`` plus the dangerous-removal +guard from ``typescript/src/tools/BashTool/pathValidation.ts:70-108`` / +``typescript/src/utils/permissions/pathValidation.ts:331-367``. + +In the original, entering acceptEdits (shift+tab) doesn't just auto-accept the +file-edit TOOLS — filesystem-write shell commands (``mkdir touch rm rmdir mv +cp sed``) and redirect-free read-only commands are auto-allowed too, with +``rm``/``rmdir`` still gated on critical paths (``/``, ``~``, direct children +of ``/``). The port previously left every one of these prompting. + +Port adaptation (documented, strictly narrower): TS validates write-command +paths through its ``checkPathConstraints`` engine *before* mode handling; this +port instead requires every path argument of an auto-allowed write command to +resolve inside the allowed roots (same containment substitute the read-only +gate uses) — outside → normal prompt flow. +""" + +from __future__ import annotations + +import os +import re +from typing import Sequence + +from .bash_suggestions import ( + contains_executable_substitution, + contains_unquoted_chaining, + split_chained_command, +) +from .read_only_commands import ( + _contains_unquoted_operator, + _extract_path_candidates, + _tokenize_simple, + check_read_only_constraints, + contains_unquoted_expansion, + is_command_read_only, +) +from .types import ( + PermissionAskDecision, + SafetyCheckDecisionReason, +) + +__all__ = [ + "ACCEPT_EDITS_READ_ONLY_COMMANDS", + "ACCEPT_EDITS_WRITE_COMMANDS", + "check_accept_edits_bash", + "check_dangerous_removal_paths", + "is_dangerous_removal_path", + "rule_allow_path_gate", +] + +# TS modeValidation.ts:11-19 +ACCEPT_EDITS_WRITE_COMMANDS: frozenset[str] = frozenset( + {"mkdir", "touch", "rm", "rmdir", "mv", "cp", "sed"} +) + +# TS modeValidation.ts:22-37 — still must pass the read-only validator so +# redirects and mutating forms fall through to the normal prompt flow. +ACCEPT_EDITS_READ_ONLY_COMMANDS: frozenset[str] = frozenset( + { + "grep", "cat", "ls", "find", "head", "tail", "echo", + "pwd", "wc", "sort", "uniq", "diff", + } +) + + +def is_dangerous_removal_path(resolved_path: str) -> bool: + """Port of ``isDangerousRemovalPath`` (utils/permissions/pathValidation.ts:331). + + Critical targets an ``rm``/``rmdir`` must never touch without an explicit + prompt: ``/``, anything ending in ``/*``, ``$HOME``, and direct children + of ``/`` (``/usr``, ``/tmp``, ``/etc`` — but not ``/usr/local``). + """ + forward = re.sub(r"[\\/]+", "/", resolved_path) + if forward == "*" or forward.endswith("/*"): + return True + normalized = forward if forward == "/" else forward.rstrip("/") or "/" + if normalized == "/": + return True + home = re.sub(r"[\\/]+", "/", os.path.expanduser("~")).rstrip("/") + if normalized == home: + return True + if os.path.dirname(normalized) == "/": + return True + return False + + +def _extract_output_redirect_targets(command: str) -> list[str] | None: + """Targets of unquoted output redirects (``>``, ``>>``, ``>|``, ``&>``, + ``N>``) in ``command``. Returns ``None`` when the command can't be scanned + with certainty (fail closed). ``2>&1``-style fd dups are not file writes + and are skipped. Quote/escape-aware; the target token may be quoted.""" + targets: list[str] = [] + in_single = False + in_double = False + i = 0 + n = len(command) + while i < n: + ch = command[i] + if ch == "\\" and not in_single and i + 1 < n: + i += 2 + continue + if ch == "'" and not in_double: + in_single = not in_single + i += 1 + continue + if ch == '"' and not in_single: + in_double = not in_double + i += 1 + continue + if not in_single and not in_double and ch == ">": + # step past >, >>, >| and any leading fd digit already consumed by + # the caller loop; find the following word (the target). + j = i + 1 + while j < n and command[j] in ">|": + j += 1 + while j < n and command[j] in " \t": + j += 1 + # `>&WORD`: bash treats it as an fd DUP only when WORD is a number + # or `-` (``2>&1``, ``>&-``); otherwise ``>&file`` redirects BOTH + # stdout+stderr to that FILE (must be gated). So consume the `&` + # and fall through to read the target word, unless a digit/`-` + # follows (then it's a dup → skip). + if j < n and command[j] == "&": + k = j + 1 + while k < n and command[k] in " \t": + k += 1 + if k >= n or command[k].isdigit() or command[k] == "-": + # `>&1` / `>&-` fd dup/close — not a file write. + i = k + 1 + continue + j = k # `>&file` — read `file` as the target below + # read the target word (up to whitespace / operator / quote start) + if j >= n: + return None # dangling redirect — cannot resolve + tk_start = j + buf = [] + while j < n: + c = command[j] + if c == "\\" and j + 1 < n: + buf.append(command[j + 1]); j += 2; continue + if c == "'": + k = command.find("'", j + 1) + if k == -1: + return None + buf.append(command[j + 1:k]); j = k + 1; continue + if c == '"': + k = command.find('"', j + 1) + if k == -1: + return None + buf.append(command[j + 1:k]); j = k + 1; continue + if c in " \t\n;|&<>()": + break + buf.append(c); j += 1 + target = "".join(buf) + if not target or tk_start == j: + return None + targets.append(target) + i = j + continue + i += 1 + return targets + + +# Redirecting to these device sinks is not a filesystem write we need to gate — +# `cmd 2>/dev/null` is a ubiquitous idiom. TS treats them as safe. +_SAFE_REDIRECT_TARGETS: frozenset[str] = frozenset({ + "/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty", +}) + + +def _output_redirects_within_roots( + command: str, cwd: str, allowed_roots: Sequence[str] +) -> bool: + targets = _extract_output_redirect_targets(command) + if targets is None: + return False # couldn't scan → fail closed + targets = [t for t in targets if t not in _SAFE_REDIRECT_TARGETS] + if not targets: + return True + # Any dynamic target (`$VAR`, glob) is unresolvable → fail closed. + if any(("$" in t or "*" in t or "?" in t or "`" in t) for t in targets): + return False + resolved_roots = [] + for root in allowed_roots: + try: + resolved_roots.append(os.path.realpath(str(root))) + except OSError: + continue + if not resolved_roots: + return False + for t in targets: + try: + rp = os.path.realpath(os.path.join(cwd, os.path.expanduser(t))) + except OSError: + return False + if not any(rp == r or rp.startswith(r + os.sep) for r in resolved_roots): + return False + return True + + +def _has_unquoted_dollar(command: str) -> bool: + """True if a ``$`` (any expansion form — ``$VAR``, ``${VAR}``, ``$(…)``) + appears outside single quotes. ``contains_unquoted_expansion`` deliberately + mirrors TS and only matches ``$`` + a name char, so it misses ``${VAR}``; + a write TARGET that can't be statically resolved must fail closed, so here + we reject ANY unquoted ``$`` (TS ``isCommandSafeViaFlagParsing`` refuses + every token containing ``$`` for the same reason).""" + in_single = False + in_double = False + i = 0 + n = len(command) + while i < n: + ch = command[i] + if ch == "\\" and not in_single and i + 1 < n: + i += 2 + continue + if ch == "'" and not in_double: + in_single = not in_single + elif ch == '"' and not in_single: + in_double = not in_double + elif ch == "$" and not in_single: + return True + i += 1 + return False + + +def _filter_out_flags(args: list[str]) -> list[str]: + """Positional args with POSIX ``--`` handling (pathValidation.ts:126-139: + ``rm -- -/../x`` must still have its path extracted and validated).""" + out: list[str] = [] + after_double_dash = False + for arg in args: + if after_double_dash: + out.append(arg) + elif arg == "--": + after_double_dash = True + elif not arg.startswith("-"): + out.append(arg) + return out + + +def check_dangerous_removal_paths( + command: str, args: list[str], cwd: str +) -> PermissionAskDecision | None: + """Port of ``checkDangerousRemovalPaths`` (BashTool/pathValidation.ts:70). + + Returns an ask (empty suggestions — never encourage saving a dangerous + command) when any target is critical, else ``None``. + """ + for path in _filter_out_flags(args): + clean = os.path.expanduser(path.strip("'\"")) + # NOTE: deliberately NOT resolving symlinks — /tmp must be caught even + # though it symlinks to /private/tmp on macOS (TS comment, :81-83). + absolute = clean if os.path.isabs(clean) else os.path.normpath( + os.path.join(cwd, clean) + ) + if is_dangerous_removal_path(absolute): + return PermissionAskDecision( + behavior="ask", + message=( + f"Dangerous {command} operation detected: '{absolute}'\n\n" + "This command would remove a critical system directory. " + "This requires explicit approval and cannot be " + "auto-allowed by permission rules." + ), + decision_reason=SafetyCheckDecisionReason( + reason=f"Dangerous {command} operation on critical path: {absolute}", + classifier_approvable=False, + ), + suggestions=(), + ) + return None + + +_REDIRECT_TOKEN = re.compile(r"(?:^|[^<>])(>>?|>\|)(?:[^&]|$)") + + +def _has_shell_redirection(command: str) -> bool: + """Approximation of TS ``hasShellRedirection`` (modeValidation.ts:57-73): + any unquoted output-redirect operator in the ORIGINAL input disqualifies + the read-only auto-allow branch. Reuses the quote-aware operator scanner — + ``<``/``>`` of any form count (TS lists ``> >> >| &> 1> 2>`` …; refusing + input redirects too only re-prompts, never widens).""" + in_single = False + in_double = False + i = 0 + n = len(command) + while i < n: + ch = command[i] + if ch == "\\" and not in_single and i + 1 < n: + i += 2 + continue + if ch == "'" and not in_double: + in_single = not in_single + elif ch == '"' and not in_single: + in_double = not in_double + elif not in_single and not in_double and ch in "<>": + return True + i += 1 + return False + + +def check_accept_edits_bash( + command: str, + *, + cwd: str, + allowed_roots: Sequence[str], +) -> PermissionAskDecision | bool: + """acceptEdits-mode resolution for a Bash command. + + Returns ``True`` (auto-allow: every sub-command is an in-roots filesystem + write command or passes the read-only validator), an ask decision (a + dangerous ``rm``/``rmdir`` target — surfaced even in acceptEdits), or + ``False`` (no mode-specific handling → normal flow). + + TS resolves per sub-command inside its per-sub pipeline; expressing it as + "ALL subs must be auto-allowable" is the same acceptance set for the + compound whole (any non-qualifying sub → False → the normal flow, which + still ends in a prompt). + """ + stripped = command.strip() + if not stripped: + return False + if contains_executable_substitution(stripped): + return False + + if contains_unquoted_chaining(stripped): + subs = split_chained_command(stripped) + if not subs: + return False + else: + subs = [stripped] + + redirection_anywhere = _has_shell_redirection(stripped) + + for sub in subs: + sub = sub.strip() + if ( + _has_unquoted_dollar(sub) + or contains_unquoted_expansion(sub) + or _contains_unquoted_operator(sub) + ): + return False + tokens = _tokenize_simple(sub) + if not tokens: + return False + base = os.path.basename(tokens[0]) + + if base in ACCEPT_EDITS_WRITE_COMMANDS: + if base in ("rm", "rmdir"): + dangerous = check_dangerous_removal_paths(base, tokens[1:], cwd) + if dangerous is not None: + return dangerous + # Containment substitute for TS checkPathConstraints: every path + # argument must stay inside the allowed roots. + if not _write_paths_within_roots(tokens, cwd, allowed_roots): + return False + continue + + if base in ACCEPT_EDITS_READ_ONLY_COMMANDS and not redirection_anywhere: + if is_command_read_only(sub) and check_read_only_constraints( + sub, cwd=cwd, allowed_roots=allowed_roots + ): + continue + return False + + return False + + return True + + +def rule_allow_path_gate( + command: str, *, cwd: str, allowed_roots: Sequence[str] +) -> bool: + """May a matched Bash ALLOW rule actually fire for this (sub-)command? + + The original runs ``checkPathConstraints`` (step 3) BEFORE the allow rule + (step 5) in ``bashToolCheckPermission`` (bashPermissions.ts:1089-1122), so + a saved ``Bash(rm:*)`` can never reach an out-of-workspace or critical + target — those still prompt. Scope: the filesystem-write path-command set + (``mkdir touch rm rmdir mv cp sed``); every other command (reads, ``git``, + ``pytest``, …) is un-gated. + + DOCUMENTED DEVIATION from TS (sanctioned in the design review): TS requires + ``acceptEdits`` mode for these commands and never honors a ``Bash(rm:*)`` + content rule in default mode; this port instead honors an explicit + ``Bash(rm:*)``-style grant for IN-WORKSPACE, non-critical targets (and for + the skill ``allowed-tools`` contract, where a declared ``Bash(touch:*)`` + must run). It is NEVER looser than TS for the cases that matter: an + out-of-roots path or a dangerous-removal target (``/``, ``~``, a direct + child of ``/``) still fails the gate → prompt/deny. + + False = the rule must not fire → the command re-prompts / flows on. + """ + tokens = _tokenize_simple(command.strip()) + if not tokens: + return False # unparseable — fail closed to the prompt + # An output redirect (`>`/`>>`) can write ANY command's stdout to a path — + # ``echo x > /etc/y`` under ``Bash(echo:*)`` would escape the workspace even + # though ``echo`` is not a write command. TS gates redirect targets via + # checkCommandOperatorPermissions; here, a redirect to an unresolvable or + # out-of-roots target fails the gate for EVERY command (the read-only path + # refuses redirects outright — this covers the rule-allow path). Bounded + # stand-in for the full operator subsystem; only ever adds a prompt. + if not _output_redirects_within_roots(command, cwd, allowed_roots): + return False + base = os.path.basename(tokens[0]) + if base not in ACCEPT_EDITS_WRITE_COMMANDS: + return True + # A write command whose target we cannot statically resolve must never + # auto-run: an unquoted ``$VAR``/``${VAR}``/``$(…)`` or a glob (``rm -rf + # $HOME``, ``rm -rf *``, ``rm -rf ${OUT}``) expands at runtime to something + # the containment / dangerous-removal checks below never saw. Fail closed → + # prompt. (The acceptEdits path applies the same guard per sub-command.) + if ( + _has_unquoted_dollar(command) + or contains_unquoted_expansion(command) + or contains_executable_substitution(command) + ): + return False + if base in ("rm", "rmdir"): + if check_dangerous_removal_paths(base, tokens[1:], cwd) is not None: + return False + return _write_paths_within_roots(tokens, cwd, allowed_roots) + + +def _write_paths_within_roots( + tokens: list[str], cwd: str, allowed_roots: Sequence[str] +) -> bool: + resolved_roots = [] + for root in allowed_roots: + try: + resolved_roots.append(os.path.realpath(str(root))) + except OSError: + continue + if not resolved_roots: + return False + + for cand in _extract_path_candidates(tokens) + _filter_out_flags(tokens[1:]): + try: + rp = os.path.realpath(os.path.join(cwd, os.path.expanduser(cand))) + except OSError: + return False + ok = any(rp == r or rp.startswith(r + os.sep) for r in resolved_roots) + if not ok: + return False + return True diff --git a/src/permissions/bash_parser/commands.py b/src/permissions/bash_parser/commands.py index 14ed72799..682c6feff 100644 --- a/src/permissions/bash_parser/commands.py +++ b/src/permissions/bash_parser/commands.py @@ -15,19 +15,25 @@ class CommandSafety(Enum): CommandSafetyLevel = Literal["safe", "read_only", "write", "destructive", "dangerous", "unknown"] +# NB (loosen-permissions round): the eval-like builtins the original refuses +# to reason about (TS utils/bash/ast.ts:2086 EVAL_LIKE_BUILTINS — source, ".", +# command, builtin, trap, hash, bind, enable, alias, let, fc, compgen, +# complete, …) were previously listed SAFE here, which made auto mode +# auto-allow e.g. ``trap 'rm -rf /' EXIT``. They now classify DANGEROUS, +# matching eval/exec. SAFE_COMMANDS: frozenset[str] = frozenset({ "echo", "printf", "true", "false", "test", "[", "[[", "pwd", "whoami", "date", "uname", "basename", "dirname", "seq", "yes", "sleep", "wait", "exit", "return", - "export", "unset", "set", "alias", "unalias", - "source", ".", "cd", "pushd", "popd", "dirs", + "export", "unset", "set", "unalias", + "cd", "pushd", "popd", "dirs", "read", "local", "declare", "typeset", "readonly", - "shift", "getopts", "let", "expr", - "trap", "hash", "times", "builtin", "command", - "type", "help", "compgen", "complete", + "shift", "getopts", "expr", + "times", + "type", "help", "bg", "fg", "jobs", "disown", "suspend", - "ulimit", "umask", "history", "fc", - "bind", "enable", "shopt", + "ulimit", "umask", "history", + "shopt", "nproc", "arch", "lsb_release", "tput", "clear", "reset", "realpath", "readlink", @@ -90,6 +96,10 @@ class CommandSafety(Enum): "nc", "ncat", "netcat", "socat", "git-push", "git-force-push", "eval", "exec", + # eval-like builtins — arguments are shell code (TS EVAL_LIKE_BUILTINS) + "source", ".", "command", "builtin", "trap", "hash", "bind", "enable", + "alias", "let", "fc", "compgen", "complete", "coproc", + "mapfile", "readarray", "noglob", "nocorrect", "python", "python3", "python2", "node", "deno", "tsx", "ruby", "perl", "php", "lua", diff --git a/src/permissions/bash_suggestions.py b/src/permissions/bash_suggestions.py index c30e954f9..db8a41f37 100644 --- a/src/permissions/bash_suggestions.py +++ b/src/permissions/bash_suggestions.py @@ -61,6 +61,57 @@ } ) +# TS utils/bash/ast.ts:2086 EVAL_LIKE_BUILTINS — shell builtins that execute +# or re-parse their arguments as code (`eval`, `source`, `trap 'cmd' EXIT`, +# `hash -p`, `let 'x=a[$(id)]'`, …). The original refuses to reason about +# them (checkSemantics → ask, suggestions: []); a `Bash(eval:*)` rule would +# be ≈ `Bash(*)`. Used both by the structural refusal in the Bash tool check +# and by the suggestion ladder (never mint a prefix OR exact rule for these). +EVAL_LIKE_BUILTINS = frozenset( + { + "eval", + "source", + ".", + "exec", + "command", + "builtin", + "fc", + "coproc", + "noglob", + "nocorrect", + "trap", + "enable", + "mapfile", + "readarray", + "hash", + "bind", + "complete", + "compgen", + "alias", + "let", + } +) + +# TS utils/bash/ast.ts:2060 ZSH_DANGEROUS_BUILTINS — zsh builtins that escape +# the argv abstraction (module load, raw syscalls, zsh/files ``zf_*`` fs ops). +# The harness spawns ``bash -lc`` so these are ``command not found`` in +# practice, but refusing them (like eval-like builtins) completes the +# checkSemantics parity family and is defense-in-depth if a zsh path ever +# becomes reachable. +ZSH_DANGEROUS_BUILTINS = frozenset( + { + "zmodload", "emulate", "sysopen", "sysread", "syswrite", "sysseek", + "zpty", "ztcp", "zsocket", + "zf_rm", "zf_mv", "zf_ln", "zf_chmod", "zf_chown", "zf_mkdir", + "zf_rmdir", "zf_chgrp", + } +) + +# Union used by every "may this first word become a prefix rule?" site. +NEVER_PREFIX_COMMANDS = ( + BARE_SHELL_PREFIXES | EVAL_LIKE_BUILTINS | ZSH_DANGEROUS_BUILTINS +) + # TS bashPermissions.ts:357-410 — env vars that CANNOT execute code or load # libraries; safe to skip when extracting the command name. PATH/LD_*/ # PYTHONPATH/NODE_OPTIONS etc. must never be added here. @@ -362,67 +413,28 @@ def get_simple_command_prefix(command: str) -> str | None: return " ".join(remaining[:2]) -# Commands safe to generalize to a first-word ``Bash(:*)`` rule: each is -# read-only with respect to its OWN arguments — it cannot write a file, exec -# another program, or mutate system state via any flag/positional, regardless of -# arguments. This is the deterministic stand-in for TS's ``getFirstWordPrefix`` -# (bashPermissions.ts:222), which TS surfaces as an *editable* dialog default the -# user can narrow; this port has no such field, so it must never auto-grant a -# generalization the user can't take back — hence the allowlist rather than -# "any first word". -# -# DELIBERATELY EXCLUDED (would be unsafe to generalize — and, crucially, a -# rule-matched ``Bash(:*)`` allow is NOT re-screened by the bash safety -# check at match time, so the allowlist is the entire boundary): find / fd -# (``-exec`` / ``-delete`` / ``-x``), sort / uniq / tee / xxd / base64 / info -# (write via an output-file arg or flag — ``xxd in out``, ``base64 -o``, -# ``info --output``), cp / mv / dd / install / ln / truncate (write), command / -# env / xargs / shells (exec their args — also in BARE_SHELL_PREFIXES), sed / awk -# (``-i`` in-place / ``system()``), git / npm / make (mutating subcommands — -# read-only ``git`` subcommands still get the 2-word ``git status:*`` prefix), -# and ``date`` (``-s`` sets the clock). -# -# KNOWN LIMITATION (pre-existing for ALL Bash prefix rules — including the -# existing 2-word ``git status:*`` form — and shared with TS): an output -# redirect like ``ls > FILE`` is matched by ``Bash(ls:*)`` and can clobber FILE. -# Closing that needs a change to the rule MATCHER (refuse redirected commands), -# which applies to every prefix rule and is out of scope for this UX fix. -SAFE_PREFIX_COMMANDS: frozenset[str] = frozenset({ - # listing / navigation / fs metadata - "ls", "pwd", "tree", "stat", "file", "basename", "dirname", "realpath", - "readlink", "du", "df", "free", - # file contents → stdout (no in-place / output-file arg). NB: xxd is - # EXCLUDED — ``xxd in out`` / ``xxd -r in out`` writes the second positional. - "cat", "head", "tail", "nl", "tac", "rev", "wc", "od", "hexdump", - "strings", - # text transforms: stdin/args → stdout (no output-file arg). NB: base64 is - # EXCLUDED — BSD/macOS ``base64 -o out`` writes a file. - "cut", "paste", "column", "fold", "expand", "unexpand", "fmt", "numfmt", - "tr", "comm", "cmp", "diff", "jq", - # read-only search (NO find/fd — they exec/delete) - "grep", "egrep", "fgrep", "rg", "locate", - # system / process / network info - "whoami", "hostname", "uname", "arch", "id", "groups", "nproc", "uptime", - "cal", "locale", "getconf", "printenv", - "ps", "pgrep", "lsof", "netstat", "ss", "which", "type", - # hashing (read → digest on stdout) - "sha256sum", "sha1sum", "md5sum", "cksum", - # pure / trivial. NB: info is EXCLUDED — GNU texinfo ``info --output=FILE`` - # writes a file; ``man`` has no such flag (and the pager shell-escape is - # neutralized by the Bash tool's stdin=DEVNULL), so man stays. - "echo", "printf", "seq", "expr", "test", "true", "false", "sleep", - "man", "help", "tput", -}) - - def get_safe_first_word_prefix(command: str) -> str | None: - """``'ls demos/'`` → ``'ls'``; ``None`` unless the first word is read-only - w.r.t. its arguments (:data:`SAFE_PREFIX_COMMANDS`). - - Lets a single ``Bash(ls:*)`` grant cover ``ls`` of any path instead of a - path-specific exact rule that re-prompts for every directory. The safe-set - restriction is the security control — see the set's comment for why a bare - ``getFirstWordPrefix`` port (TS) would be unsafe here. + """``'pytest -q'`` → ``'pytest'``; ``None`` for bare shells/wrappers. + + Faithful port of TS ``getFirstWordPrefix`` (bashPermissions.ts:222): when + the 2-word prefix declines, offer the first word alone as the (editable) + "don't ask again" rule — ``Bash(pytest:*)`` covers every future pytest + invocation instead of an exact rule that re-prompts on each new flag. + TS's comment: "as an editable starting point it's what users expect" — + and the TUI approval box HAS an editable rule field (prompts.tsx), so the + user sees exactly what they are granting and can narrow it before saving. + + The only refusals are the same shape/safety gates TS applies: + - :data:`SAFE_ENV_VARS`-only env prefixes (a rule minted past an unsafe + env assignment could never match at check time); + - the subcommand shape regex (no paths, flags, numbers); + - :data:`BARE_SHELL_PREFIXES` — ``bash:*`` ≈ ``Bash(*)`` via ``-c``, and + wrapper prefixes (env/xargs/nice/…/sudo) exec their arguments. + + (An earlier revision gated this rung on a read-only allowlist because the + dialog had no editable field yet; the field shipped in round 6, so the + gate now just forces exact rules for everyday dev tools — the exact + over-prompting this change removes.) """ tokens = [t for t in command.strip().split() if t] remaining = _skip_safe_env_assignments(tokens) @@ -433,7 +445,7 @@ def get_safe_first_word_prefix(command: str) -> str | None: # flags, and numbers — only a bare command name may generalize. if not _SUBCOMMAND_RE.match(cmd): return None - if cmd not in SAFE_PREFIX_COMMANDS: + if cmd in NEVER_PREFIX_COMMANDS: return None return cmd @@ -461,6 +473,26 @@ def _extract_prefix_before_heredoc(command: str) -> str | None: return " ".join(remaining[:2]) +def _mentions_eval_like(command: str) -> bool: + """True when the command — or any sub-command of a compound — invokes an + eval-like builtin as its first word (env assignments skipped). Used by the + suggestion ladder; the authoritative match-time refusal lives in the Bash + tool's structural check (which also strips safe wrappers).""" + subs: list[str] | None = [command] + if contains_unquoted_chaining(command): + subs = split_chained_command(command) + if subs is None: + return True # unsplittable — do not reason about it + for sub in subs: + tokens = [t for t in sub.strip().split() if t] + remaining = _skip_safe_env_assignments(tokens) + first = (remaining if remaining is not None else tokens) + head = first[0] if first else "" + if head in EVAL_LIKE_BUILTINS or head in ZSH_DANGEROUS_BUILTINS: + return True + return False + + def suggestion_for_prefix(prefix: str) -> list[PermissionUpdate]: """``prefix`` → ``[addRules Bash(prefix:*) → localSettings]`` (TS shellRuleMatching.ts:211-227).""" @@ -518,7 +550,10 @@ def _rule_value_for_subcommand(sub: str) -> PermissionRuleValue | None: tokens = [t for t in sub.split() if t] remaining = _skip_safe_env_assignments(tokens) first = (remaining or tokens)[0] if (remaining or tokens) else "" - if first in BARE_SHELL_PREFIXES: + if first in NEVER_PREFIX_COMMANDS: + # Bare shells AND eval-like builtins: an "exact" rule is + # exact-OR-word-prefix at match time, so even the exact form would + # over-match; and eval-likes should never be encouraged for saving. return None return PermissionRuleValue(tool_name=BASH_TOOL_NAME, rule_content=sub) @@ -566,6 +601,27 @@ def suggestions_for_bash_command(command: str) -> list[PermissionUpdate]: if not command: return [] + # Never offer to save an injection-suspect or eval-like command: the + # original's structural asks carry ``suggestions: []`` ("Don't suggest + # saving a potentially dangerous command"), and an eval-like rule — + # prefix OR word-prefix-matching exact — would be ≈ ``Bash(*)``. + if contains_executable_substitution(command): + return [] + if _mentions_eval_like(command): + return [] + # A NAME-eval subscript attack (`printf -v 'a[$(id)]'`) or a + # /proc/*/environ read must not mint a savable rule either — a `printf:*` + # / `cat:*` grant would then auto-run/leak. + from .read_only_commands import ( + accesses_proc_environ, + find_name_eval_subscript_attack, + ) + + if find_name_eval_subscript_attack(command) is not None: + return [] + if accesses_proc_environ(command): + return [] + heredoc_idx = command.find("<<") if heredoc_idx != -1: # Heredoc: either a stable prefix before the operator, or nothing — @@ -627,10 +683,11 @@ def suggestions_for_bash_command(command: str) -> list[PermissionUpdate]: __all__ = [ "BARE_SHELL_PREFIXES", "BASH_TOOL_NAME", + "EVAL_LIKE_BUILTINS", "MAX_SUBCOMMANDS", "MAX_SUGGESTED_RULES_FOR_COMPOUND", + "NEVER_PREFIX_COMMANDS", "SAFE_ENV_VARS", - "SAFE_PREFIX_COMMANDS", "contains_executable_substitution", "contains_unquoted_chaining", "get_safe_first_word_prefix", diff --git a/src/permissions/check.py b/src/permissions/check.py index a628815e8..50dfd92a9 100644 --- a/src/permissions/check.py +++ b/src/permissions/check.py @@ -496,17 +496,96 @@ def has_permissions_to_use_tool_inner( decision_reason=ModeDecisionReason(mode="acceptEdits"), ) + # The path-write gate every Bash rule-allow must pass: in the original, + # checkPathConstraints runs BEFORE allow rules (bashPermissions.ts step 3 + # vs step 5), so a matched Bash allow — content-less ``Bash`` / ``Bash(*)`` + # OR a ``Bash(rm:*)`` prefix — never auto-runs a write against an + # out-of-workspace or dangerous-removal target. See + # :func:`rule_allow_path_gate` for the documented in-workspace deviation. + _pwg_state: dict[str, Any] = {} + + def _passes_path_gate(cmd_text: str) -> bool: + from .bash_mode_validation import rule_allow_path_gate + from .bash_suggestions import ( + contains_unquoted_chaining, + split_chained_command, + ) + + if "cwd" not in _pwg_state: + gate_cwd = os.getcwd() + gate_roots: list[str] = [] + if tool_use_context is not None: + try: + gate_cwd = str(tool_use_context.cwd or gate_cwd) + except Exception: + pass + try: + gate_roots = [ + str(r) for r in tool_use_context.allowed_roots() + ] + except Exception: + gate_roots = [] + if not gate_roots: + gate_roots = [gate_cwd] + _pwg_state["cwd"] = gate_cwd + _pwg_state["roots"] = gate_roots + # A content-less allow-all can match a compound (`echo && rm -rf ~`), + # so gate EVERY sub-command's write target, not just the head. A + # splitter refusal fails closed (prompt). + legs = [cmd_text] + if contains_unquoted_chaining(cmd_text): + subs = split_chained_command(cmd_text) + if subs is None: + return False + legs = subs + return all( + rule_allow_path_gate( + leg, cwd=_pwg_state["cwd"], allowed_roots=_pwg_state["roots"] + ) + for leg in legs + ) + always_allowed = tool_always_allowed_rule(context, tool) if always_allowed: - return PermissionAllowDecision( - behavior="allow", - updated_input=_get_updated_input_or_fallback(tool_permission_result, tool_input), - decision_reason=RuleDecisionReason(rule=always_allowed), - ) + # A content-less allow-all for Bash still can't escape the workspace + # with a write (TS: Bash(*) is path-gated too). Non-Bash tools and + # non-write Bash commands pass straight through. + if tool.name != "Bash" or _passes_path_gate( + tool_input.get("command", "") + ): + return PermissionAllowDecision( + behavior="allow", + updated_input=_get_updated_input_or_fallback(tool_permission_result, tool_input), + decision_reason=RuleDecisionReason(rule=always_allowed), + ) content_rules = get_rule_by_contents_for_tool(context, tool.name, "allow") if content_rules and tool.name == "Bash": command = tool_input.get("command", "") + + # Raw exact-string equality fires FIRST, before the substitution + # refusal below — a user who saved the literal rule + # ``Bash(echo "$(date)")`` made a conscious choice to permit that + # specific string, and TS honors exact allows even on + # injection-flagged commands (bashPermissions.ts:2124-2131, + # exactMatchResult in the misparsing gate). Only full-string + # equality gets this bypass; every prefix/wildcard/word-prefix + # matcher still refuses substitution and chaining. The path-write + # gate still applies (TS: exact allow is honored at step 4, AFTER + # step 3's path constraints). + if command: + stripped_cmd = command.strip() + for rule_content, rule in content_rules.items(): + if ( + stripped_cmd + and stripped_cmd == str(rule_content).strip() + and _passes_path_gate(stripped_cmd) + ): + return PermissionAllowDecision( + behavior="allow", + updated_input=tool_input, + decision_reason=RuleDecisionReason(rule=rule), + ) # A content ALLOW rule (e.g. Bash(echo:*)) must never auto-allow a # command that hides an executable substitution — `echo "$(rm -rf /)"` # runs the rm, which the safety analyzer tokenizes away and the string @@ -531,20 +610,23 @@ def has_permissions_to_use_tool_inner( for cand in allow_candidates: for rule_content, rule in content_rules.items(): matcher = prepare_permission_matcher(rule_content) - if matcher(cand): + if matcher(cand) and _passes_path_gate(cand): return PermissionAllowDecision( behavior="allow", updated_input=tool_input, decision_reason=RuleDecisionReason(rule=rule), ) # Compound command: allow iff EVERY sub-command matches some - # allow content rule (TS bashPermissions.ts:2383/2470). Runs - # after the whole-command loop (which refuses chained commands - # by design) and after the tool safety screen above — a - # safety-flagged compound never reaches here. Each sub faces - # the SAME matcher a simple command would, so this never widens - # what one rule can match; it only lets several rules agree. - witness = _all_subcommands_allowed_by_content_rules(context, command) + # allow content rule OR is provably read-only (TS + # bashPermissions.ts:2383/2470 — each sub resolves through the + # full per-sub pipeline, whose step 7 is the read-only allow). + # Runs after the whole-command loop (which refuses chained + # commands by design). Each sub faces the SAME matcher a simple + # command would, so this never widens what one rule can match; + # it only lets several rules (or the read-only validator) agree. + witness = _all_subcommands_allowed_by_content_rules( + context, command, tool_use_context=tool_use_context, + ) if witness is not None: return PermissionAllowDecision( behavior="allow", @@ -947,17 +1029,30 @@ def _match_one(cmd: str) -> "PermissionRule | None": def _all_subcommands_allowed_by_content_rules( context: ToolPermissionContext, command: str, + *, + tool_use_context: Any | None = None, ) -> "PermissionRule | None": """When EVERY sub-command of a chained ``command`` matches some allow - content rule, return a witness rule (the last sub's match); else None. + content rule OR is provably read-only, return a witness rule (the last + sub's rule match); else None. TS parity (bashPermissions.ts:2383/2470): a compound command is allowed - iff all of its sub-commands are individually allowed. The whole-command - matcher (chaining guard) stays authoritative for simple commands; this - runs only for chained ones, each sub matched by the SAME matcher a simple - command would face — so splitting never widens what a single rule can - match, it only requires more rules to agree. Splitter refusal → None - (today's behavior: prompt). + iff all of its sub-commands are individually allowed, where each sub runs + the FULL per-sub pipeline — whose step 7 is the no-rule read-only allow + (bashPermissions.ts:1136). So ``pytest -q && git status`` needs only the + ``pytest:*`` grant; the ``git status`` leg rides the read-only validator. + + The read-only fallback is gated on the compound-level structure guards + (multi-cd / cd+git / bare-repo-git) — without that, two individually + read-only ``cd`` subs would re-admit exactly what the whole-command + read-only gate refuses. A compound where EVERY sub is read-only never + reaches here (the Bash tool's own check allows it first); at least one + sub matched a rule, so a witness always exists on success. + + The whole-command matcher (chaining guard) stays authoritative for simple + commands; this runs only for chained ones, each sub matched by the SAME + matcher a simple command would face — so splitting never widens what a + single rule can match. Splitter refusal → None (today's behavior: prompt). """ if not contains_unquoted_chaining(command): @@ -973,7 +1068,53 @@ def _all_subcommands_allowed_by_content_rules( (prepare_permission_matcher(rule_content), rule) for rule_content, rule in content_rules.items() ] + + # Lazy shared state (cwd/roots resolved once): compound structure guards + # for the read-only fallback, and the path-write gate for rule matches. + _ro_state: dict[str, Any] = {} + + def _ensure_state() -> None: + from .read_only_commands import compound_structure_guards_ok + + if "cwd" in _ro_state: + return + cwd = os.getcwd() + roots: list[str] = [] + if tool_use_context is not None: + try: + cwd = str(tool_use_context.cwd or cwd) + except Exception: + pass + try: + roots = [str(r) for r in tool_use_context.allowed_roots()] + except Exception: + roots = [] + if not roots: + roots = [cwd] + _ro_state["cwd"] = cwd + _ro_state["roots"] = roots + _ro_state["structure_ok"] = compound_structure_guards_ok(subs, cwd) + + def _sub_read_only(sub: str) -> bool: + from .read_only_commands import sub_command_read_only_and_contained + + _ensure_state() + if not _ro_state["structure_ok"]: + return False + return sub_command_read_only_and_contained( + sub, cwd=_ro_state["cwd"], allowed_roots=_ro_state["roots"] + ) + + def _sub_passes_path_gate(cand: str) -> bool: + from .bash_mode_validation import rule_allow_path_gate + + _ensure_state() + return rule_allow_path_gate( + cand, cwd=_ro_state["cwd"], allowed_roots=_ro_state["roots"] + ) + witness = None + matched_any_rule = False for sub in subs: # Strip SAFE_ENV_VARS only (as the suggestion ladder does) so an # accepted rule actually matches the sub it came from. @@ -981,16 +1122,27 @@ def _all_subcommands_allowed_by_content_rules( safe_stripped = _strip_env_assignments(sub, safe_only=True) if safe_stripped and safe_stripped != sub: candidates.append(safe_stripped) + sub_ok = False for cand in candidates: for matcher, rule in matchers: - if matcher(cand): + # Rule matches are still path-gated (TS: checkPathConstraints + # precedes allow rules) — Bash(rm:*) in a compound cannot + # reach out-of-roots/critical targets either. + if matcher(cand) and _sub_passes_path_gate(cand): witness = rule + matched_any_rule = True + sub_ok = True break - else: - continue - break - else: + if sub_ok: + break + if not sub_ok and _sub_read_only(sub): + sub_ok = True + if not sub_ok: return None + # All-read-only compounds are the Bash tool check's job; requiring a rule + # witness here keeps the decision_reason honest (it names a real rule). + if not matched_any_rule: + return None return witness diff --git a/src/permissions/filesystem.py b/src/permissions/filesystem.py index 7b2299c0b..a6f9457c6 100644 --- a/src/permissions/filesystem.py +++ b/src/permissions/filesystem.py @@ -14,6 +14,16 @@ WorkingDirDecisionReason, ) +# TS parity (typescript/src/utils/permissions/filesystem.ts:59-83). The gate +# these lists feed (``check_path_safety_for_auto_edit``) only runs on the +# acceptEdits/auto fast-paths, and only AFTER the working-roots containment +# check — out-of-workspace paths (~/.ssh, ~/.aws, …) never reach it. A +# previous revision padded the lists far beyond the original (lockfiles, +# .env*, Makefile, .npmrc/.netrc/ssh/kube/aws entries, .ssh/.gnupg/.config +# dirs), which meant a user who explicitly opted into "allow all edits during +# this session" still got prompted for everyday in-repo files the original +# auto-accepts. Trimmed back to the original's sets, plus this port's own +# config namespace (``.clawcodex``) in the ``.openclaude`` slot. DANGEROUS_FILES: tuple[str, ...] = ( ".gitconfig", ".gitmodules", @@ -25,19 +35,7 @@ ".ripgreprc", ".mcp.json", ".claude.json", - ".env", - ".npmrc", - ".yarnrc", - ".yarnrc.yml", - ".pypirc", - ".netrc", - ".docker/config.json", - ".kube/config", - ".aws/credentials", - ".ssh/config", - ".ssh/known_hosts", - ".ssh/authorized_keys", - "Makefile", + ".clawcodex.json", ) DANGEROUS_DIRECTORIES: tuple[str, ...] = ( @@ -45,9 +43,7 @@ ".vscode", ".idea", ".claude", - ".ssh", - ".gnupg", - ".config", + ".clawcodex", ) PROTECTED_LOCKFILES: tuple[str, ...] = ( @@ -138,16 +134,33 @@ def is_in_generated_dir(file_path: str, cwd: str | None = None) -> bool: return False +# Structural carve-out (TS filesystem.ts:474-486): ``.claude/worktrees/`` (and +# this port's ``.clawcodex/worktrees/``) is where git worktrees live — a +# ``.claude`` segment immediately followed by ``worktrees`` is infrastructure, +# not a user-created dangerous directory. Without the carve-out, EVERY file in +# a worktree session (…/.claude/worktrees//src/x.py) matched the +# protected-directory gate and acceptEdits was completely defeated there. +_WORKTREE_CARVEOUT_DIRS: frozenset[str] = frozenset({".claude", ".clawcodex"}) + + def check_path_safety_for_auto_edit( file_path: str, cwd: str | None = None, ) -> PermissionResult | None: abs_path = resolve_path(file_path) - normalized = normalize_case_for_comparison(abs_path) + segments = abs_path.replace("\\", "/").split("/") - for dangerous_dir in DANGEROUS_DIRECTORIES: - dir_lower = normalize_case_for_comparison(dangerous_dir) - if f"/{dir_lower}/" in normalized or normalized.endswith(f"/{dir_lower}"): + for i, segment in enumerate(segments): + seg_lower = normalize_case_for_comparison(segment) + for dangerous_dir in DANGEROUS_DIRECTORIES: + if seg_lower != normalize_case_for_comparison(dangerous_dir): + continue + # (TS scans every segment including the last — a target path + # ending in ``.claude``/``.git`` is itself flagged.) + if dangerous_dir in _WORKTREE_CARVEOUT_DIRS: + nxt = segments[i + 1] if i + 1 < len(segments) else "" + if normalize_case_for_comparison(nxt) == "worktrees": + continue # structural worktree path — keep scanning return PermissionAskDecision( behavior="ask", message=f"This file is inside a protected directory ({dangerous_dir}/) and requires confirmation.", @@ -171,26 +184,6 @@ def check_path_safety_for_auto_edit( ), ) - if is_env_file(basename): - return PermissionAskDecision( - behavior="ask", - message=f"Editing {basename} requires confirmation as it may contain secrets.", - decision_reason=SafetyCheckDecisionReason( - reason=f"File is an environment file: {basename}", - classifier_approvable=True, - ), - ) - - if is_lockfile(basename): - return PermissionAskDecision( - behavior="ask", - message=f"Editing lockfile {basename} requires confirmation.", - decision_reason=SafetyCheckDecisionReason( - reason=f"File is a lockfile: {basename}", - classifier_approvable=False, - ), - ) - return None diff --git a/src/permissions/read_only_commands.py b/src/permissions/read_only_commands.py new file mode 100644 index 000000000..8cdd21641 --- /dev/null +++ b/src/permissions/read_only_commands.py @@ -0,0 +1,763 @@ +"""Read-only Bash command validation — the default-mode auto-allow gate. + +Faithful port of the ORIGINAL Claude Code's read-only validator: + +* harness: ``typescript/src/tools/BashTool/readOnlyValidation.ts`` + (``isCommandSafeViaFlagParsing:1180``, ``containsUnquotedExpansion:1534``, + ``isCommandReadOnly:1612``, ``checkReadOnlyConstraints:1810``) and the + shared flag-walking loop ``validateFlags`` + (``typescript/src/utils/shell/readOnlyCommandValidation.ts:1684``); +* data tables: :mod:`src.permissions.read_only_tables` (COMMAND_ALLOWLIST, + READONLY_COMMAND_REGEXES, git/rg/docker/pyright read-only tables). + +This is what lets ``ls``, ``cat``, ``git status``, ``git diff``, ``grep`` … +run with NO prompt and NO rule in default mode (TS bashPermissions.ts:1136: +"Read-only command is allowed"), which the port previously never did. + +Port adaptations, each strictly narrower than TS (a refusal degrades to +"prompt", never to a wider allow): + +* TS detects operators via shell-quote's operator tokens; ``shlex`` has no + operator model, so :func:`_contains_unquoted_operator` pre-refuses any + unquoted ``< > | & ; ( )`` before tokenization. All TS acceptance paths + reject those characters anyway (the allowlist path via operator tokens, the + regex path via ``[^<>()$`|{}&;\\n\\r]`` character classes), so outcomes + match. +* TS splits compounds with ``splitCommand_DEPRECATED``; this port uses + :func:`split_chained_command`, which additionally REFUSES substitution / + subshells / heredocs / ANSI-C quoting (→ not read-only → prompt). +* TS runs ``checkPathConstraints`` (a separate engine this port doesn't have) + BEFORE its read-only allow, so out-of-project reads still prompt. This port + substitutes :func:`_paths_within_roots` — every path-looking argument must + resolve inside the allowed roots, else the command is not auto-allowed. + Slightly stricter than TS (e.g. ``df /`` prompts here); never looser. +* The TS sandbox-specific git guard (git outside the original cwd while + sandboxed) is dropped: the port has no sandbox enforcement, and TS itself + waives the guard when sandboxing is off ("attack is moot"). +* TS's "compound writes git-internal paths then runs git" guard is subsumed: + TS needs it because its splitter STRIPS redirections from sub-commands; this + port's splitter keeps them in the text, so any write redirect already fails + the per-sub read-only check. +""" + +from __future__ import annotations + +import os +import re +import shlex +from typing import Sequence + +from .bash_suggestions import ( + contains_executable_substitution, + contains_unquoted_chaining, + split_chained_command, +) +from .read_only_tables import ( + COMMAND_ALLOWLIST, + CommandConfig, + READONLY_COMMAND_REGEXES, + SAFE_TARGET_COMMANDS_FOR_XARGS, +) + +__all__ = [ + "check_read_only_constraints", + "contains_unquoted_expansion", + "contains_vulnerable_unc_path", + "is_command_read_only", + "is_command_safe_via_flag_parsing", + "is_current_directory_bare_git_repo", + "validate_flag_argument", + "validate_flags", +] + +# TS readOnlyCommandValidation.ts:1645 +FLAG_PATTERN = re.compile(r"^-[a-zA-Z0-9_-]") + + +# --------------------------------------------------------------------------- +# validateFlags — port of readOnlyCommandValidation.ts:1650-1893 +# --------------------------------------------------------------------------- + +def validate_flag_argument(value: str, arg_type: str) -> bool: + """Port of ``validateFlagArgument`` (readOnlyCommandValidation.ts:1650).""" + if arg_type == "none": + return False # should not be called for 'none' + if arg_type == "number": + return re.fullmatch(r"\d+", value) is not None + if arg_type == "string": + return True + if arg_type == "char": + return len(value) == 1 + if arg_type == "{}": + return value == "{}" + if arg_type == "EOF": + return value == "EOF" + return False + + +def validate_flags( + tokens: list[str], + start_index: int, + config: CommandConfig, + *, + command_name: str | None = None, + raw_command: str = "", + xargs_target_commands: Sequence[str] | None = None, +) -> bool: + """Port of ``validateFlags`` (readOnlyCommandValidation.ts:1684-1893). + + Walks the flag/argument tokens after the command words and accepts the + command only when every flag is on the config's safe list with a valid + argument. Preserves the TS security semantics verbatim: ``hasEquals`` + tracking (``-E=`` provides an EMPTY value, it must not consume the next + token), bundled short flags must all be no-arg, git ``-`` shorthand, + grep/rg attached numerics (``-A20``), the xargs safe-target break, and + ``respects_double_dash=False`` tools that keep validating past ``--``. + """ + i = start_index + n = len(tokens) + + while i < n: + token = tokens[i] + if not token: + i += 1 + continue + + # xargs: once the target command is found, stop validating flags. + if ( + xargs_target_commands is not None + and command_name == "xargs" + and (not token.startswith("-") or token == "--") + ): + if token == "--" and i + 1 < n: + i += 1 + token = tokens[i] + if token and token in xargs_target_commands: + break + return False + + if token == "--": + # Only break when the tool respects POSIX `--` (default). Tools + # like pyright treat `--` as a path and keep parsing flags after + # it — breaking would let `pyright -- --createstub os` slip by. + if config.respects_double_dash: + i += 1 + break + i += 1 + continue + + if token.startswith("-") and len(token) > 1 and FLAG_PATTERN.match(token): + # `-E=` has has_equals=True but an EMPTY inline value: GNU getopt + # sees `-E` with ATTACHED arg `=`, so we must NOT consume the next + # token (TS parser-differential fix, :1745-1770). + has_equals = "=" in token + flag, _, inline_value = token.partition("=") + + if not flag: + return False + + flag_arg_type = config.safe_flags.get(flag) + + if flag_arg_type is None: + # git - shorthand for -n . + if command_name == "git" and re.fullmatch(r"-\d+", flag): + i += 1 + continue + + # grep/rg attached numeric args (-A20, -B10). + if ( + command_name in ("grep", "rg") + and flag.startswith("-") + and not flag.startswith("--") + and len(flag) > 2 + ): + potential_flag = flag[:2] + potential_value = flag[2:] + attached_type = config.safe_flags.get(potential_flag) + if attached_type and re.fullmatch(r"\d+", potential_value): + if attached_type in ("number", "string"): + if validate_flag_argument(potential_value, attached_type): + i += 1 + continue + return False + + # Bundled single-letter flags (-nr): ALL must exist and ALL + # must be no-arg. An arg-taking flag inside a bundle consumes + # the NEXT token in GNU getopt, which this walker does not + # model (TS xargs `-rI` RCE fix, :1800-1830). + if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2: + for ch in flag[1:]: + single_type = config.safe_flags.get("-" + ch) + if single_type is None: + return False + if single_type != "none": + return False + i += 1 + continue + return False # unknown flag + + if flag_arg_type == "none": + if has_equals: + return False # `-FLAG=` supplies a value to a no-arg flag + i += 1 + else: + if has_equals: + arg_value = inline_value + i += 1 + else: + nxt = tokens[i + 1] if i + 1 < n else None + if nxt is None or ( + nxt.startswith("-") and len(nxt) > 1 and FLAG_PATTERN.match(nxt) + ): + return False # missing required argument + arg_value = nxt or "" + i += 2 + + # String args must not start with '-' (type-confusion guard); + # git --sort allows a reverse-sort '-key' exception. + if flag_arg_type == "string" and arg_value.startswith("-"): + if not ( + flag == "--sort" + and command_name == "git" + and re.match(r"^-[a-zA-Z]", arg_value) + ): + return False + + if not validate_flag_argument(arg_value, flag_arg_type): + return False + else: + i += 1 # positional argument (revspec, file path, …) + + return True + + +# --------------------------------------------------------------------------- +# UNC paths — port of containsVulnerableUncPath +# (readOnlyCommandValidation.ts:1562-1640) +# --------------------------------------------------------------------------- + +_UNC_PATTERNS = [ + re.compile(r"\\\\[\w.\[\]:@-]+"), # \\server\share, \\server@SSL@8443\ + re.compile(r"(?:^|[\s\"'=(])//[\w.\[\]:-]+\.[\w.\[\]:-]+/"), # //host.tld/share + re.compile(r"DavWWWRoot", re.IGNORECASE), +] + + +def contains_vulnerable_unc_path(path_or_command: str) -> bool: + """UNC / WebDAV path detection (NTLM credential-leak surface). + + Port of ``containsVulnerableUncPath``; POSIX hosts still refuse these so a + pasted Windows-style path never counts as provably read-only. + """ + return any(p.search(path_or_command) for p in _UNC_PATTERNS) + + +# --------------------------------------------------------------------------- +# Unquoted expansion — port of containsUnquotedExpansion +# (readOnlyValidation.ts:1534-1603) +# --------------------------------------------------------------------------- + +_EXPANSION_NEXT = re.compile(r"[A-Za-z_@*#?!$0-9-]") + + +def contains_unquoted_expansion(command: str) -> bool: + """Unquoted glob (``? * [ ]``) or expandable ``$`` outside single quotes. + + Either can expand at runtime into flags/paths the validators never saw + (``python *`` → ``python --help``; ``uniq --skip-chars=0$_`` smuggles + positionals), so such a command is never provably read-only. + """ + in_single = False + in_double = False + escaped = False + n = len(command) + for i in range(n): + ch = command[i] + if escaped: + escaped = False + continue + # Backslash escapes only OUTSIDE single quotes (bash: '\' is literal + # inside single quotes; treating it as an escape desyncs the tracker). + if ch == "\\" and not in_single: + escaped = True + continue + if ch == "'" and not in_double: + in_single = not in_single + continue + if ch == '"' and not in_single: + in_double = not in_double + continue + if in_single: + continue + # `$` expands unquoted AND inside double quotes. + if ch == "$": + nxt = command[i + 1] if i + 1 < n else "" + if nxt and _EXPANSION_NEXT.match(nxt): + return True + if in_double: + continue + # Globs are literal inside both quote kinds; only unquoted ones count. + if ch in "?*[]": + return True + return False + + +# --------------------------------------------------------------------------- +# Unquoted shell operators (port-specific pre-guard; see module docstring) +# --------------------------------------------------------------------------- + +def _contains_unquoted_operator(command: str) -> bool: + """True when an unquoted ``< > | & ; ( )`` appears outside quotes. + + Replaces shell-quote's operator tokens: any redirect/pipe/paren means the + string is not a single simple command, so it cannot be validated by flag + walking or the read-only regexes (whose character classes exclude exactly + these). Backslash-escaped characters are literal (``find \\( … \\)`` is + handled by the find regex on the raw string, not here — the escaped paren + is skipped). + """ + in_single = False + in_double = False + i = 0 + n = len(command) + while i < n: + ch = command[i] + if ch == "\\" and not in_single and i + 1 < n: + i += 2 + continue + if ch == "'" and not in_double: + in_single = not in_single + elif ch == '"' and not in_single: + in_double = not in_double + elif not in_single and not in_double and ch in "<>|&;()": + return True + i += 1 + return False + + +# --------------------------------------------------------------------------- +# isCommandSafeViaFlagParsing — port of readOnlyValidation.ts:1180-1342 +# --------------------------------------------------------------------------- + +def _tokenize_simple(command: str) -> list[str] | None: + """shlex tokenization of an operator-free simple command. + + Callers must have refused unquoted operators/expansion first — + that is what makes ``shlex.split`` faithful to what bash would exec. + """ + try: + tokens = shlex.split(command, posix=True) + except ValueError: + return None + return tokens + + +def is_command_safe_via_flag_parsing(command: str) -> bool: + """Allowlist + strict flag validation (COMMAND_ALLOWLIST path).""" + tokens = _tokenize_simple(command) + if not tokens: + return False + + # Find the config: first table entry (insertion order, multi-word keys + # like "git diff" included) whose words prefix the tokens — TS iterates + # Object.entries and takes the first match. + command_config: CommandConfig | None = None + command_tokens = 0 + for cmd_pattern, cfg in COMMAND_ALLOWLIST.items(): + cmd_words = cmd_pattern.split(" ") + if len(tokens) >= len(cmd_words) and tokens[: len(cmd_words)] == cmd_words: + command_config = cfg + command_tokens = len(cmd_words) + break + if command_config is None: + return False + + # git ls-remote: reject URL/remote-looking args (data exfiltration). + if tokens[0] == "git" and len(tokens) > 1 and tokens[1] == "ls-remote": + for token in tokens[2:]: + if token and not token.startswith("-"): + if "://" in token or "@" in token or ":" in token or "$" in token: + return False + + # Reject `$` in ANY post-command token (runtime expansion defeats both + # the flag walker and the callbacks — TS :1262-1303), and brace-expansion + # obfuscation (`{`+`,` or `{`+`..`). + for token in tokens[command_tokens:]: + if not token: + continue + if "$" in token: + return False + if "{" in token and ("," in token or ".." in token): + return False + + if not validate_flags( + tokens, + command_tokens, + command_config, + command_name=tokens[0], + raw_command=command, + xargs_target_commands=( + SAFE_TARGET_COMMANDS_FOR_XARGS if tokens[0] == "xargs" else None + ), + ): + return False + + if command_config.regex is not None and not command_config.regex.search(command): + return False + if command_config.regex is None and "`" in command: + return False + # Newlines/CRs in grep/rg patterns can be used for injection. + if ( + command_config.regex is None + and tokens[0] in ("rg", "grep") + and re.search(r"[\n\r]", command) + ): + return False + + if command_config.additional_command_is_dangerous is not None: + if command_config.additional_command_is_dangerous( + command, tokens[command_tokens:] + ): + return False + + return True + + +# --------------------------------------------------------------------------- +# isCommandReadOnly — port of readOnlyValidation.ts:1612-1686 +# --------------------------------------------------------------------------- + +_GIT_C_FLAG = re.compile(r"\s-c[\s=]") +_GIT_EXEC_PATH = re.compile(r"\s--exec-path[\s=]") +_GIT_CONFIG_ENV = re.compile(r"\s--config-env[\s=]") + + +def is_command_read_only(command: str) -> bool: + """True when a single (already-split) command is provably read-only.""" + test = command.strip() + if test.endswith(" 2>&1"): + test = test[:-5].strip() + if not test: + return False + + if contains_vulnerable_unc_path(test): + return False + if contains_unquoted_expansion(test): + return False + if _contains_unquoted_operator(test): + return False + + if is_command_safe_via_flag_parsing(test): + return True + + for regex in READONLY_COMMAND_REGEXES: + if regex.search(test): + # git -c / --exec-path / --config-env can execute arbitrary code + # via config (core.fsmonitor, diff.external, …). + if "git" in test and ( + _GIT_C_FLAG.search(test) + or _GIT_EXEC_PATH.search(test) + or _GIT_CONFIG_ENV.search(test) + ): + return False + return True + return False + + +# --------------------------------------------------------------------------- +# cd / git sub-command detection — port of isNormalizedCdCommand / +# isNormalizedGitCommand (bashPermissions.ts:2570-2634) +# --------------------------------------------------------------------------- + +def _strip_env_and_wrappers(command: str) -> str: + from .check import _normalize_for_deny_ask + + return _normalize_for_deny_ask(command) + + +# Builtins that re-parse a NAME operand and ARITHMETICALLY evaluate an +# ``arr[EXPR]`` subscript — so ``printf -v 'a[$(id)]'`` / ``test -v 'a[`id`]'`` +# / ``[[ 'a[$(id)]' -eq 0 ]]`` / ``read -a 'a[$(id)]'`` / ``unset 'a[$(id)]'`` +# / ``wait -p 'a[$(id)]'`` run the substitution even from a SINGLE-QUOTED +# string (which ``contains_executable_substitution`` correctly treats as +# literal for a normal command). Port of TS checkSemantics +# SUBSCRIPT_EVAL_FLAGS / BARE_SUBSCRIPT_NAME_BUILTINS / declaration builtins +# (utils/bash/ast.ts:2143-2185). We take the conservative whole-command view: +# for a sub-command whose head is one of these, a ``[`` together with a +# ``$(`` / backtick / ``${`` anywhere in it is refused. Over-blocks only exotic +# literal-bracket+substitution strings under exactly these builtins (safe — +# just prompts); normal ``printf '%s' x`` / ``test -f x`` / ``read var`` pass. +_NAME_EVAL_BUILTINS: frozenset[str] = frozenset({ + "test", "[", "[[", "printf", "read", "unset", "wait", + "declare", "typeset", "local", "readonly", "getopts", +}) + +_SUBSCRIPT_SUBST_RE = re.compile(r"\[[^\]]*(?:\$\(|`|\$\{)") + + +# /proc/*/environ exposes another process's environment (secrets). TS +# checkSemantics refuses any argv/redirect target matching this REGARDLESS of +# permission rules (utils/bash/ast.ts:2197,2658-2677). ``.*`` (not ``[^/]*``) +# because Linux resolves ``..`` in procfs (``/proc/self/../self/environ``). +_PROC_ENVIRON_RE = re.compile(r"/proc/.*/environ") + + +def accesses_proc_environ(command: str) -> bool: + """True if ``command`` reads ``/proc/*/environ`` (env/secret exfiltration). + + Scans the raw command (covers argv AND redirect targets like + ``cat < /proc/self/environ``). A backslash before ``environ`` is unescaped + first — bash reads ``/proc/self/\\environ`` as ``.../environ`` (TS + ast.ts:1098).""" + unescaped = command.replace("\\", "") + return bool( + _PROC_ENVIRON_RE.search(command) or _PROC_ENVIRON_RE.search(unescaped) + ) + + +def find_name_eval_subscript_attack(command: str) -> str | None: + """Head builtin of a sub-command that would arithmetically evaluate a + ``arr[$(cmd)]`` subscript, or ``None``. Compound-aware; strips env + safe + wrappers before reading the head so ``FOO=1 timeout 5 printf -v 'a[$(id)]'`` + is still caught.""" + subs: list[str] | None + if contains_unquoted_chaining(command): + subs = split_chained_command(command) + if subs is None: + return None + else: + subs = [command] + for sub in subs: + stripped = _strip_env_and_wrappers(sub.strip()) + head = stripped.split(None, 1)[0] if stripped.split() else "" + if head in _NAME_EVAL_BUILTINS and _SUBSCRIPT_SUBST_RE.search(sub): + return head + return None + + +def find_eval_like_builtin(command: str) -> str | None: + """First eval-like builtin invoked by ``command`` (or a sub-command), or + ``None``. Port of the checkSemantics EVAL_LIKE_BUILTINS refusal + (TS utils/bash/ast.ts:2086 → bashPermissions.ts:1780-1803): these + builtins execute or re-parse their arguments as code, so no analyzer + (and no prefix rule) can reason about what actually runs. + + Detection strips env assignments and safe wrappers first (``nohup FOO=1 + eval x`` → ``eval``); the token must equal the builtin exactly — a PATH + binary invoked as ``./eval`` is not the builtin. Splitter refusal is + already handled upstream (too-complex ask), so this only sees splittable + commands. + """ + subs: list[str] | None + if contains_unquoted_chaining(command): + subs = split_chained_command(command) + if subs is None: + return None # unsplittable → the structural too-complex path owns it + else: + subs = [command] + + from .bash_suggestions import EVAL_LIKE_BUILTINS, ZSH_DANGEROUS_BUILTINS + + refuse = EVAL_LIKE_BUILTINS | ZSH_DANGEROUS_BUILTINS + for sub in subs: + stripped = _strip_env_and_wrappers(sub.strip()) + head = stripped.split(None, 1)[0] if stripped.split() else "" + if head in refuse: + return head + return None + + +def is_normalized_git_command(command: str) -> bool: + command = command.strip() + if command == "git" or command.startswith("git "): + return True + stripped = _strip_env_and_wrappers(command) + tokens = _tokenize_simple(stripped) + if tokens: + if tokens[0] == "git": + return True + # `xargs git …` runs git in the cwd — same cd+git surface. + if tokens[0] == "xargs" and "git" in tokens: + return True + return False + return re.match(r"^git(?:\s|$)", stripped) is not None + + +def is_normalized_cd_command(command: str) -> bool: + stripped = _strip_env_and_wrappers(command.strip()) + tokens = _tokenize_simple(stripped) + if tokens: + return tokens[0] in ("cd", "pushd", "popd") + return re.match(r"^(?:cd|pushd|popd)(?:\s|$)", stripped) is not None + + +# --------------------------------------------------------------------------- +# Bare-repo cwd detection — port of isCurrentDirectoryBareGitRepo +# (typescript/src/utils/git.ts:876-925) +# --------------------------------------------------------------------------- + +def is_current_directory_bare_git_repo(cwd: str) -> bool: + """True when ``cwd`` looks like a bare git repo with no valid ``.git``. + + Running git there would treat the cwd as the git directory and execute + attacker-planted hooks (core.fsmonitor etc.), so read-only git commands + must not auto-allow. + """ + git_path = os.path.join(cwd, ".git") + try: + if os.path.isfile(git_path): + return False # worktree/submodule gitdir reference + if os.path.isdir(git_path): + head = os.path.join(git_path, "HEAD") + if os.path.isfile(head): + return False # normal repo + # .git exists but no regular HEAD — fall through. + except OSError: + pass + + for indicator, kind in (("HEAD", "file"), ("objects", "dir"), ("refs", "dir"), ("hooks", "dir")): + target = os.path.join(cwd, indicator) + try: + if kind == "file" and os.path.isfile(target): + return True + if kind == "dir" and os.path.isdir(target): + return True + except OSError: + continue + return False + + +# --------------------------------------------------------------------------- +# Path containment (port-specific substitute for TS checkPathConstraints on +# the read-only branch — see module docstring) +# --------------------------------------------------------------------------- + +_PATHISH = re.compile(r"^(/|\.|~)|/") + + +def _extract_path_candidates(tokens: list[str]) -> list[str]: + """Path-looking arguments of a simple command (flags skipped, ``--`` + honored). Overshooting is harmless: a non-path token that resolves inside + the roots changes nothing; one that resolves outside merely re-prompts.""" + out: list[str] = [] + after_double_dash = False + for token in tokens[1:]: + if not token: + continue + if not after_double_dash and token == "--": + after_double_dash = True + continue + if not after_double_dash and token.startswith("-"): + _, eq, value = token.partition("=") + if eq and value and _PATHISH.search(value): + out.append(value) + continue + if _PATHISH.search(token) or token == "..": + out.append(token) + return out + + +def _paths_within_roots( + subs: list[str], cwd: str, allowed_roots: Sequence[str] +) -> bool: + resolved_roots = [] + for root in allowed_roots: + try: + resolved_roots.append(os.path.realpath(str(root))) + except OSError: + continue + if not resolved_roots: + return False + + def _inside(p: str) -> bool: + try: + rp = os.path.realpath( + os.path.join(cwd, os.path.expanduser(p)) + ) + except OSError: + return False + for root in resolved_roots: + if rp == root or rp.startswith(root + os.sep): + return True + return False + + for sub in subs: + tokens = _tokenize_simple(sub.strip()) + if not tokens: + return False + for cand in _extract_path_candidates(tokens): + if not _inside(cand): + return False + return True + + +# --------------------------------------------------------------------------- +# checkReadOnlyConstraints — port of readOnlyValidation.ts:1810-1924 +# --------------------------------------------------------------------------- + +def compound_structure_guards_ok(subs: Sequence[str], cwd: str) -> bool: + """Compound-level guards that must hold before ANY sub-command may be + treated as read-only: at most one directory change (TS asks on multiple + cds, bashPermissions.ts:2197), never cd+git together (bare-repository + attack: cd into a planted repo, git executes its hooks), and no git at + all when the cwd itself looks like a planted bare repo.""" + cd_count = sum(1 for s in subs if is_normalized_cd_command(s)) + if cd_count > 1: + return False + has_git = any(is_normalized_git_command(s) for s in subs) + if cd_count and has_git: + return False + if has_git and is_current_directory_bare_git_repo(cwd): + return False + return True + + +def sub_command_read_only_and_contained( + sub: str, *, cwd: str, allowed_roots: Sequence[str] +) -> bool: + """One already-split sub-command: provably read-only AND path-contained. + + Callers holding a compound MUST have checked + :func:`compound_structure_guards_ok` over the WHOLE sub list first — + per-sub checks alone cannot see multi-cd / cd+git structure. + """ + return is_command_read_only(sub) and _paths_within_roots( + [sub], cwd, allowed_roots + ) + + +def check_read_only_constraints( + command: str, + *, + cwd: str, + allowed_roots: Sequence[str], +) -> bool: + """True when ``command`` (possibly compound) is provably read-only AND + contained in the allowed roots. False = not provable → normal prompt flow. + """ + stripped = command.strip() + if not stripped: + return False + + # Substitution executes hidden commands the validators never see. + if contains_executable_substitution(stripped): + return False + + if contains_unquoted_chaining(stripped): + subs = split_chained_command(stripped) + if not subs: + return False # splitter refusal (subshell/heredoc/…) → prompt + else: + subs = [stripped] + + if not compound_structure_guards_ok(subs, cwd): + return False + + for sub in subs: + if not is_command_read_only(sub): + return False + + if not _paths_within_roots(subs, cwd, allowed_roots): + return False + + return True diff --git a/src/permissions/read_only_tables.py b/src/permissions/read_only_tables.py new file mode 100644 index 000000000..8ee49c5cb --- /dev/null +++ b/src/permissions/read_only_tables.py @@ -0,0 +1,2599 @@ +"""Faithful Python transcription of the TypeScript read-only command +validation data tables. + +Source (read-only reference, byte-for-byte fidelity is the acceptance bar): + - typescript/src/tools/BashTool/readOnlyValidation.ts + - typescript/src/utils/shell/readOnlyCommandValidation.ts + - typescript/src/tools/BashTool/sedValidation.ts + +This module holds ONLY the declarative tables + the sed allowlist port. +The flag-walking engine (validateFlags), the operator/`$`/brace-expansion +rejection, the `git ls-remote` URL guard, and the compound-command orchestration +(checkReadOnlyConstraints) live in the harness and are intentionally NOT here. + +Stdlib only (re, dataclasses, typing). The single approximation is +`_try_parse_shell_command`, a self-contained quote-aware tokenizer standing in +for the JS `shell-quote` parse() that sedValidation relies on — see its docstring. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Callable, Literal + +# --------------------------------------------------------------------------- +# Types (TS readOnlyCommandValidation.ts:18-24, readOnlyValidation.ts:34-49) +# --------------------------------------------------------------------------- + +# FlagArgType — the complete set of literal arg-type tags used across ALL +# source tables. No other literal values appear in the three source files. +FlagArgType = Literal[ + "none", # No argument (--color, -n) + "number", # Integer argument (--context=3) + "string", # Any string argument (--relative=path) + "char", # Single character (delimiter) + "{}", # Literal "{}" only + "EOF", # Literal "EOF" only +] + + +@dataclass +class CommandConfig: + """Unified command config. TS unifies two shapes: CommandConfig + (readOnlyValidation.ts:34) which additionally carries `regex`, and + ExternalCommandConfig (readOnlyCommandValidation.ts:26) which does not. + Merged here; git/rg/pyright/docker entries simply leave `regex` None. + """ + + # Record mapping the command (e.g. `xargs` or `git diff`) to its safe + # flags and the values they accept. + safe_flags: dict[str, str] + # Optional regex for additional validation beyond flag parsing. + regex: re.Pattern[str] | None = None + # Optional callback. Returns True if the command is DANGEROUS, False if it + # appears safe. Used in conjunction with the safe_flags-based validation. + additional_command_is_dangerous: Callable[[str, list[str]], bool] | None = None + # When False, the tool does NOT respect POSIX `--` end-of-options. + # Default: True (most tools respect `--`). + respects_double_dash: bool = True + + +# =========================================================================== +# sed validation port (TS sedValidation.ts, complete) +# =========================================================================== + + +class _SedParseError(Exception): + """Raised when sed argument parsing fails (mirrors the thrown Errors in + extractSedExpressions).""" + + +@dataclass +class _GlobToken: + """Stand-in for shell-quote's `{op:'glob', pattern}` entry. Only the + glob case of a non-string token is observable to the sed logic.""" + + op: str # always 'glob' + pattern: str + + +def _try_parse_shell_command(command: str) -> tuple[bool, list[object]]: + """Self-contained, stdlib-only approximation of the JS `shell-quote` + parse() that `tryParseShellCommand(withoutSed)` uses in sedValidation.ts. + + Returns (success, tokens) where each token is either a `str` or a + `_GlobToken`. It faithfully reproduces the behaviors the sed logic + depends on: + - quote-aware whitespace splitting (single + double quotes), + - single quotes are fully literal; double quotes honor `\\` before + one of " \\ $ ` (bash semantics), + - an UNQUOTED token containing a glob metacharacter (* ? [ ]) becomes a + _GlobToken (shell-quote's glob entry) — this is what makes a bare + `*.log` count as a file argument in has_file_args, + - unbalanced quotes -> success=False (shell-quote / the JS wrapper + surface this as a parse failure). + + It deliberately does NOT model unquoted control operators (| ; & < > ( )): + the only caller of `sed_command_is_allowed_by_allowlist` is the sed + COMMAND_ALLOWLIST callback, which the harness reaches only AFTER rejecting + any operator-containing command (isCommandSafeViaFlagParsing's hasOperators + guard). Where this simplification diverges it fails closed (more + restrictive), never open. + """ + tokens: list[object] = [] + current: list[str] = [] + has_token = False + is_glob = False + in_single = False + in_double = False + i = 0 + n = len(command) + + def _flush() -> None: + nonlocal current, has_token, is_glob + text = "".join(current) + tokens.append(_GlobToken("glob", text) if is_glob else text) + current = [] + has_token = False + is_glob = False + + while i < n: + c = command[i] + if in_single: + if c == "'": + in_single = False + else: + current.append(c) + i += 1 + continue + if in_double: + if c == '"': + in_double = False + elif c == "\\" and i + 1 < n and command[i + 1] in ('"', "\\", "$", "`"): + current.append(command[i + 1]) + i += 2 + continue + else: + current.append(c) + i += 1 + continue + # Outside all quotes. + if c == "'": + in_single = True + has_token = True + i += 1 + continue + if c == '"': + in_double = True + has_token = True + i += 1 + continue + if c == "\\": + if i + 1 < n: + current.append(command[i + 1]) + has_token = True + i += 2 + continue + i += 1 + continue + if c.isspace(): + if has_token: + _flush() + i += 1 + continue + if c in "*?[]": + is_glob = True + current.append(c) + has_token = True + i += 1 + + if in_single or in_double: + return (False, []) + if has_token: + _flush() + return (True, tokens) + + +def _validate_flags_against_allowlist( + flags: list[str], allowed_flags: list[str] +) -> bool: + """TS sedValidation.ts:13 validateFlagsAgainstAllowlist. Handles combined + short flags (e.g. -nE) by checking each character.""" + for flag in flags: + if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2: + for i in range(1, len(flag)): + single_flag = "-" + flag[i] + if single_flag not in allowed_flags: + return False + else: + if flag not in allowed_flags: + return False + return True + + +_PRINT_COMMAND_RE = re.compile(r"^(?:\d+|\d+,\d+)?p$") + + +def is_print_command(cmd: str) -> bool: + """TS sedValidation.ts:128. STRICT: matches p, 1p, 123p, 1,5p, 10,200p.""" + if not cmd: + return False + return bool(_PRINT_COMMAND_RE.search(cmd)) + + +_SED_PREFIX_RE = re.compile(r"^\s*sed\s+") + + +def is_line_printing_command(command: str, expressions: list[str]) -> bool: + """TS sedValidation.ts:44. Pattern 1: `sed -n 'Np'` style line printing. + File arguments are ALLOWED for this pattern.""" + m = _SED_PREFIX_RE.match(command) + if not m: + return False + + without_sed = command[m.end() :] + ok, parsed = _try_parse_shell_command(without_sed) + if not ok: + return False + + flags = [ + a + for a in parsed + if isinstance(a, str) and a.startswith("-") and a != "--" + ] + + allowed_flags = [ + "-n", + "--quiet", + "--silent", + "-E", + "--regexp-extended", + "-r", + "-z", + "--zero-terminated", + "--posix", + ] + + if not _validate_flags_against_allowlist(flags, allowed_flags): + return False + + # -n flag is required for Pattern 1. + has_n_flag = False + for flag in flags: + if flag in ("-n", "--quiet", "--silent"): + has_n_flag = True + break + if flag.startswith("-") and not flag.startswith("--") and "n" in flag: + has_n_flag = True + break + + if not has_n_flag: + return False + + if len(expressions) == 0: + return False + + # All expressions must be print commands (strict allowlist); allow + # semicolon-separated print commands. + for expr in expressions: + commands = expr.split(";") + for cmd in commands: + if not is_print_command(cmd.strip()): + return False + + return True + + +_SUBST_EXPR_RE = re.compile(r"^s/(.*?)$") +_SUBST_FLAGS_RE = re.compile(r"^[gpimIM]*[1-9]?[gpimIM]*$") + + +def is_substitution_command( + command: str, + expressions: list[str], + has_file_arguments: bool, + allow_file_writes: bool = False, +) -> bool: + """TS sedValidation.ts:142. Pattern 2: `sed 's/pat/repl/flags'`. In + read-only mode (allow_file_writes False) requires stdout-only (no file + args, no -i).""" + if not allow_file_writes and has_file_arguments: + return False + + m = _SED_PREFIX_RE.match(command) + if not m: + return False + + without_sed = command[m.end() :] + ok, parsed = _try_parse_shell_command(without_sed) + if not ok: + return False + + flags = [ + a + for a in parsed + if isinstance(a, str) and a.startswith("-") and a != "--" + ] + + allowed_flags = ["-E", "--regexp-extended", "-r", "--posix"] + if allow_file_writes: + allowed_flags.extend(["-i", "--in-place"]) + + if not _validate_flags_against_allowlist(flags, allowed_flags): + return False + + if len(expressions) != 1: + return False + + expr = expressions[0].strip() + + # Must be a substitution command starting with 's'. + if not expr.startswith("s"): + return False + + # Only allow / as delimiter (strict): s/pattern/replacement/flags + substitution_match = _SUBST_EXPR_RE.match(expr) + if not substitution_match: + return False + + rest = substitution_match.group(1) + + # Find the positions of / delimiters (skipping escaped chars). + delimiter_count = 0 + last_delimiter_pos = -1 + i = 0 + while i < len(rest): + if rest[i] == "\\": + i += 2 + continue + if rest[i] == "/": + delimiter_count += 1 + last_delimiter_pos = i + i += 1 + + # Exactly 2 delimiters (pattern and replacement). + if delimiter_count != 2: + return False + + expr_flags = rest[last_delimiter_pos + 1 :] + + # Only allow g, p, i, I, m, M, and optionally ONE digit 1-9. + if not _SUBST_FLAGS_RE.search(expr_flags): + return False + + return True + + +def has_file_args(command: str) -> bool: + """TS sedValidation.ts:307. True if the sed command has file arguments + (not just stdin). Fails closed (True) on parse failure.""" + m = _SED_PREFIX_RE.match(command) + if not m: + return False + + without_sed = command[m.end() :] + ok, parsed = _try_parse_shell_command(without_sed) + if not ok: + return True + + arg_count = 0 + has_e_flag = False + i = 0 + while i < len(parsed): + arg = parsed[i] + + # A glob pattern counts as a file argument. + if isinstance(arg, _GlobToken): + return True + # Skip non-string tokens that aren't globs (operators — none reach here). + if not isinstance(arg, str): + i += 1 + continue + + # -e / --expression consumes the following expression token. + if (arg == "-e" or arg == "--expression") and i + 1 < len(parsed): + has_e_flag = True + i += 2 + continue + + if arg.startswith("--expression="): + has_e_flag = True + i += 1 + continue + + if arg.startswith("-e="): + has_e_flag = True + i += 1 + continue + + if arg.startswith("-"): + i += 1 + continue + + arg_count += 1 + + # If -e flags were used, ALL non-flag args are file arguments. + if has_e_flag: + return True + + # Without -e, the first non-flag arg is the sed expression; a second + # non-flag arg means file arguments are present. + if arg_count > 1: + return True + + i += 1 + + return False + + +def extract_sed_expressions(command: str) -> list[str]: + """TS sedValidation.ts:388. Extract sed expressions (ignoring flags and + filenames). Raises _SedParseError on dangerous flag combos / malformed + syntax (mirrors the thrown Errors).""" + expressions: list[str] = [] + + m = _SED_PREFIX_RE.match(command) + if not m: + return expressions + + without_sed = command[m.end() :] + + # Reject dangerous combined -e/-w flag forms (e.g. -ew, -eW, -ee, -we). + if re.search(r"-e[wWe]", without_sed) or re.search(r"-w[eE]", without_sed): + raise _SedParseError("Dangerous flag combination detected") + + ok, parsed = _try_parse_shell_command(without_sed) + if not ok: + raise _SedParseError("Malformed shell syntax") + + found_e_flag = False + found_expression = False + + i = 0 + while i < len(parsed): + arg = parsed[i] + + # Skip non-string arguments (control operators / globs). + if not isinstance(arg, str): + i += 1 + continue + + # -e / --expression followed by expression. + if (arg == "-e" or arg == "--expression") and i + 1 < len(parsed): + found_e_flag = True + next_arg = parsed[i + 1] + if isinstance(next_arg, str): + expressions.append(next_arg) + i += 2 # consume flag + expression + else: + i += 1 + continue + + if arg.startswith("--expression="): + found_e_flag = True + expressions.append(arg[len("--expression=") :]) + i += 1 + continue + + if arg.startswith("-e="): + found_e_flag = True + expressions.append(arg[len("-e=") :]) + i += 1 + continue + + if arg.startswith("-"): + i += 1 + continue + + # First non-flag arg is the sed expression when no -e was used. + if not found_e_flag and not found_expression: + expressions.append(arg) + found_expression = True + i += 1 + continue + + # Remaining non-flag args are filenames. + break + + return expressions + + +def contains_dangerous_operations(expression: str) -> bool: + """TS sedValidation.ts:473 (denylist). True if the sed expression contains + dangerous operations (w/W write, e/E execute, and obfuscations thereof).""" + cmd = expression.strip() + if not cmd: + return False + + # Reject non-ASCII (homoglyphs, combining chars). ASCII 0x01-0x7F only. + if re.search(r"[^\x01-\x7F]", cmd): + return True + + # Reject curly braces (blocks) — too complex to parse. + if "{" in cmd or "}" in cmd: + return True + + # Reject newlines. + if "\n" in cmd: + return True + + # Reject comments (# not immediately after an s command delimiter). + hash_index = cmd.find("#") + if hash_index != -1 and not (hash_index > 0 and cmd[hash_index - 1] == "s"): + return True + + # Reject negation operator. + if re.search(r"^!", cmd) or re.search(r"[/\d$]!", cmd): + return True + + # Reject GNU step address (digit~digit, ,~digit, $~digit). + if re.search(r"\d\s*~\s*\d|,\s*~\s*\d|\$\s*~\s*\d", cmd): + return True + + # Reject bare leading comma (shorthand for 1,$ range). + if re.search(r"^,", cmd): + return True + + # Reject comma followed by +/- (GNU offset addresses). + if re.search(r",\s*[+-]", cmd): + return True + + # Reject backslash tricks: s\ (backslash delim) or \X alt-delimiters. + if re.search(r"s\\", cmd) or re.search(r"\\[|#%@]", cmd): + return True + + # Reject escaped slashes followed by w/W. + if re.search(r"\\\/.*[wW]", cmd): + return True + + # Reject slash-then-nonslash, whitespace, then dangerous command. + if re.search(r"\/[^/]*\s+[wWeE]", cmd): + return True + + # Reject malformed substitution commands. + if re.search(r"^s\/", cmd) and not re.search( + r"^s\/[^/]*\/[^/]*\/[^/]*$", cmd + ): + return True + + # PARANOID: 's...' ending in w/W/e/E that isn't a proper substitution. + if re.search(r"^s.", cmd) and re.search(r"[wWeE]$", cmd): + proper_subst = re.search(r"^s([^\\\n]).*?\1.*?\1[^wWeE]*$", cmd) + if not proper_subst: + return True + + # Dangerous write commands: [addr]w file, /pattern/w file, ranges, etc. + if ( + re.search(r"^[wW]\s*\S+", cmd) + or re.search(r"^\d+\s*[wW]\s*\S+", cmd) + or re.search(r"^\$\s*[wW]\s*\S+", cmd) + or re.search(r"^\/[^/]*\/[IMim]*\s*[wW]\s*\S+", cmd) + or re.search(r"^\d+,\d+\s*[wW]\s*\S+", cmd) + or re.search(r"^\d+,\$\s*[wW]\s*\S+", cmd) + or re.search(r"^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*[wW]\s*\S+", cmd) + ): + return True + + # Dangerous execute commands: [addr]e cmd, /pattern/e, ranges, etc. + if ( + re.search(r"^e", cmd) + or re.search(r"^\d+\s*e", cmd) + or re.search(r"^\$\s*e", cmd) + or re.search(r"^\/[^/]*\/[IMim]*\s*e", cmd) + or re.search(r"^\d+,\d+\s*e", cmd) + or re.search(r"^\d+,\$\s*e", cmd) + or re.search(r"^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*e", cmd) + ): + return True + + # Substitution with dangerous flags: spatreplflags where flags + # contain w or e. POSIX allows any char except backslash/newline as delim. + substitution_match = re.search(r"s([^\\\n]).*?\1.*?\1(.*?)$", cmd) + if substitution_match: + flags = substitution_match.group(2) or "" + if "w" in flags or "W" in flags: + return True + if "e" in flags or "E" in flags: + return True + + # y (transliterate) command followed by any w/W/e/E (paranoid). + y_command_match = re.search(r"y([^\\\n])", cmd) + if y_command_match: + if re.search(r"[wWeE]", cmd): + return True + + return False + + +def sed_command_is_allowed_by_allowlist( + raw_command: str, allow_file_writes: bool = False +) -> bool: + """TS sedValidation.ts:247 sedCommandIsAllowedByAllowlist. The COMMAND_ + ALLOWLIST callback invokes this with allow_file_writes defaulting False + (read-only). Returns True if the sed command matches the allowlist patterns + AND passes the denylist check.""" + # Extract sed expressions (content inside quotes where sed commands live). + try: + expressions = extract_sed_expressions(raw_command) + except Exception: + # If parsing failed, treat as not allowed. + return False + + has_file_arguments = has_file_args(raw_command) + + is_pattern1 = False + is_pattern2 = False + + if allow_file_writes: + # Only substitution commands need file writes (Pattern 2 variant). + is_pattern2 = is_substitution_command( + raw_command, expressions, has_file_arguments, allow_file_writes=True + ) + else: + is_pattern1 = is_line_printing_command(raw_command, expressions) + is_pattern2 = is_substitution_command( + raw_command, expressions, has_file_arguments + ) + + if not is_pattern1 and not is_pattern2: + return False + + # Pattern 2 does not allow semicolons; Pattern 1 does (for print separators). + for expr in expressions: + if is_pattern2 and ";" in expr: + return False + + # Defense-in-depth: even if the allowlist matches, check the denylist. + for expr in expressions: + if contains_dangerous_operations(expr): + return False + + return True + + +# =========================================================================== +# COMMAND_ALLOWLIST callbacks (additionalCommandIsDangerousCallback ports) +# =========================================================================== + +_PS_BSD_E_RE = re.compile(r"^[a-zA-Z]*e[a-zA-Z]*$") + + +def _ps_is_dangerous(_raw_command: str, args: list[str]) -> bool: + """TS readOnlyValidation.ts:419. Block BSD-style 'e' modifier (shows env + vars). A BSD-style option is a letter-only token (no leading dash) with 'e'.""" + return any( + (not a.startswith("-")) and bool(_PS_BSD_E_RE.search(a)) for a in args + ) + + +def _date_is_dangerous(_raw_command: str, args: list[str]) -> bool: + """TS readOnlyValidation.ts:755. Positional args in MMDDhhmm[[CC]YY][.ss] + set system time; require positional args to start with '+' (format strings).""" + flags_with_args = { + "-d", + "--date", + "-r", + "--reference", + "--iso-8601", + "--rfc-3339", + } + i = 0 + while i < len(args): + token = args[i] + if token.startswith("--") and "=" in token: + i += 1 + elif token.startswith("-"): + if token in flags_with_args: + i += 2 + else: + i += 1 + else: + if not token.startswith("+"): + return True + i += 1 + return False + + +def _lsof_is_dangerous(_raw_command: str, args: list[str]) -> bool: + """TS readOnlyValidation.ts:907. Block +m (create mount supplement file).""" + return any(a == "+m" or a.startswith("+m") for a in args) + + +_TPUT_DANGEROUS_CAPABILITIES = { + "init", + "reset", + "rs1", + "rs2", + "rs3", + "is1", + "is2", + "is3", + "iprog", + "if", + "rf", + "clear", + "flash", + "mc0", + "mc4", + "mc5", + "mc5i", + "mc5p", + "pfkey", + "pfloc", + "pfx", + "pfxl", + "smcup", + "rmcup", +} + + +def _tput_is_dangerous(_raw_command: str, args: list[str]) -> bool: + """TS readOnlyValidation.ts:977. Block terminal-state-modifying capabilities + and -S (read capability names from stdin, incl. bundled -xS).""" + flags_with_args = {"-T"} + i = 0 + after_double_dash = False + while i < len(args): + token = args[i] + if token == "--": + after_double_dash = True + i += 1 + elif not after_double_dash and token.startswith("-"): + # Defense-in-depth: block -S even if it passes validateFlags. + if token == "-S": + return True + # -S bundled with other flags (e.g., -xS). + if not token.startswith("--") and len(token) > 2 and "S" in token: + return True + if token in flags_with_args: + i += 2 + else: + i += 1 + else: + if token in _TPUT_DANGEROUS_CAPABILITIES: + return True + i += 1 + return False + + +# --- git callbacks (TS readOnlyCommandValidation.ts) --- + + +def _git_reflog_is_dangerous(_raw_command: str, args: list[str]) -> bool: + """TS readOnlyCommandValidation.ts:283. Block write-capable subcommands + (expire/delete/exists); bare/show/ref-name are safe.""" + dangerous_subcommands = {"expire", "delete", "exists"} + for token in args: + if not token or token.startswith("-"): + continue + if token in dangerous_subcommands: + return True + # First positional is safe (show/HEAD/ref) — subsequent are ref args. + return False + return False + + +_GIT_REMOTE_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$") + + +def _git_remote_show_is_dangerous(_raw_command: str, args: list[str]) -> bool: + """TS readOnlyCommandValidation.ts:478. Allow optional -n then exactly one + alphanumeric remote name.""" + positional = [a for a in args if a != "-n"] + if len(positional) != 1: + return True + return not bool(_GIT_REMOTE_NAME_RE.search(positional[0])) + + +def _git_remote_is_dangerous(_raw_command: str, args: list[str]) -> bool: + """TS readOnlyCommandValidation.ts:495. Only bare 'git remote' or + 'git remote -v/--verbose'.""" + return any(a != "-v" and a != "--verbose" for a in args) + + +def _git_tag_is_dangerous(_raw_command: str, args: list[str]) -> bool: + """TS readOnlyCommandValidation.ts:739. Block tag creation via positional + args; only listing/filtering forms are read-only.""" + flags_with_args = { + "--contains", + "--no-contains", + "--merged", + "--no-merged", + "--points-at", + "--sort", + "--format", + "-n", + } + i = 0 + seen_list_flag = False + seen_dash_dash = False + while i < len(args): + token = args[i] + if not token: + i += 1 + continue + # `--` ends flag parsing; subsequent tokens are positional even if `-`. + if token == "--" and not seen_dash_dash: + seen_dash_dash = True + i += 1 + continue + if not seen_dash_dash and token.startswith("-"): + if token == "--list" or token == "-l": + seen_list_flag = True + elif ( + len(token) > 2 + and token[0] == "-" + and token[1] != "-" + and "=" not in token + and "l" in token[1:] + ): + # Short-flag bundle like -li, -il containing 'l'. + seen_list_flag = True + if "=" in token: + i += 1 + elif token in flags_with_args: + i += 2 + else: + i += 1 + else: + # Positional arg without --list = tag creation. + if not seen_list_flag: + return True + i += 1 + return False + + +def _git_branch_is_dangerous(_raw_command: str, args: list[str]) -> bool: + """TS readOnlyCommandValidation.ts:851. Block branch creation via positional + args; only listing/filtering forms are read-only.""" + flags_with_args = {"--contains", "--no-contains", "--points-at", "--sort"} + flags_with_optional_args = {"--merged", "--no-merged"} + i = 0 + last_flag = "" + seen_list_flag = False + seen_dash_dash = False + while i < len(args): + token = args[i] + if not token: + i += 1 + continue + if token == "--" and not seen_dash_dash: + seen_dash_dash = True + last_flag = "" + i += 1 + continue + if not seen_dash_dash and token.startswith("-"): + if token == "--list" or token == "-l": + seen_list_flag = True + elif ( + len(token) > 2 + and token[0] == "-" + and token[1] != "-" + and "=" not in token + and "l" in token[1:] + ): + seen_list_flag = True + if "=" in token: + last_flag = token.split("=")[0] or "" + i += 1 + elif token in flags_with_args: + last_flag = token + i += 2 + else: + last_flag = token + i += 1 + else: + last_flag_has_optional_arg = last_flag in flags_with_optional_args + if not seen_list_flag and not last_flag_has_optional_arg: + return True + i += 1 + return False + + +def _pyright_is_dangerous(_raw_command: str, args: list[str]) -> bool: + """TS readOnlyCommandValidation.ts:1523. Block --watch / -w.""" + return any(t == "--watch" or t == "-w" for t in args) + + +def _sed_is_dangerous(raw_command: str, _args: list[str]) -> bool: + """TS readOnlyValidation.ts:241. Delegates to the sed allowlist port.""" + return not sed_command_is_allowed_by_allowlist(raw_command) + + +# =========================================================================== +# Shared git flag groups (TS readOnlyCommandValidation.ts:44-101) +# =========================================================================== + +GIT_REF_SELECTION_FLAGS: dict[str, str] = { + "--all": "none", + "--branches": "none", + "--tags": "none", + "--remotes": "none", +} + +GIT_DATE_FILTER_FLAGS: dict[str, str] = { + "--since": "string", + "--after": "string", + "--until": "string", + "--before": "string", +} + +GIT_LOG_DISPLAY_FLAGS: dict[str, str] = { + "--oneline": "none", + "--graph": "none", + "--decorate": "none", + "--no-decorate": "none", + "--date": "string", + "--relative-date": "none", +} + +GIT_COUNT_FLAGS: dict[str, str] = { + "--max-count": "number", + "-n": "number", +} + +# Stat output flags — used in git log, show, diff. +GIT_STAT_FLAGS: dict[str, str] = { + "--stat": "none", + "--numstat": "none", + "--shortstat": "none", + "--name-only": "none", + "--name-status": "none", +} + +# Color output flags — used in git log, show, diff. +GIT_COLOR_FLAGS: dict[str, str] = { + "--color": "none", + "--no-color": "none", +} + +# Patch display flags — used in git log, show. +GIT_PATCH_FLAGS: dict[str, str] = { + "--patch": "none", + "-p": "none", + "--no-patch": "none", + "--no-ext-diff": "none", + "-s": "none", +} + +# Author/committer filter flags — used in git log, reflog. +GIT_AUTHOR_FILTER_FLAGS: dict[str, str] = { + "--author": "string", + "--committer": "string", + "--grep": "string", +} + + +# =========================================================================== +# GIT_READ_ONLY_COMMANDS (TS readOnlyCommandValidation.ts:107-923) +# NOTE: 'git remote show' MUST precede 'git remote' so longer patterns match +# first — insertion order is preserved. +# =========================================================================== + +GIT_READ_ONLY_COMMANDS: dict[str, CommandConfig] = { + "git diff": CommandConfig( + safe_flags={ + **GIT_STAT_FLAGS, + **GIT_COLOR_FLAGS, + "--dirstat": "none", + "--summary": "none", + "--patch-with-stat": "none", + "--word-diff": "none", + "--word-diff-regex": "string", + "--color-words": "none", + "--no-renames": "none", + "--no-ext-diff": "none", + "--check": "none", + "--ws-error-highlight": "string", + "--full-index": "none", + "--binary": "none", + "--abbrev": "number", + "--break-rewrites": "none", + "--find-renames": "none", + "--find-copies": "none", + "--find-copies-harder": "none", + "--irreversible-delete": "none", + "--diff-algorithm": "string", + "--histogram": "none", + "--patience": "none", + "--minimal": "none", + "--ignore-space-at-eol": "none", + "--ignore-space-change": "none", + "--ignore-all-space": "none", + "--ignore-blank-lines": "none", + "--inter-hunk-context": "number", + "--function-context": "none", + "--exit-code": "none", + "--quiet": "none", + "--cached": "none", + "--staged": "none", + "--pickaxe-regex": "none", + "--pickaxe-all": "none", + "--no-index": "none", + "--relative": "string", + "--diff-filter": "string", + "-p": "none", + "-u": "none", + "-s": "none", + "-M": "none", + "-C": "none", + "-B": "none", + "-D": "none", + "-l": "none", + # SECURITY: -S/-G/-O take REQUIRED string args (pickaxe search, + # pickaxe regex, orderfile). 'none' caused a parser differential + # allowing `git diff -S -- --output=/tmp/pwned` file write. + "-S": "string", + "-G": "string", + "-O": "string", + "-R": "none", + }, + ), + "git log": CommandConfig( + safe_flags={ + **GIT_LOG_DISPLAY_FLAGS, + **GIT_REF_SELECTION_FLAGS, + **GIT_DATE_FILTER_FLAGS, + **GIT_COUNT_FLAGS, + **GIT_STAT_FLAGS, + **GIT_COLOR_FLAGS, + **GIT_PATCH_FLAGS, + **GIT_AUTHOR_FILTER_FLAGS, + "--abbrev-commit": "none", + "--full-history": "none", + "--dense": "none", + "--sparse": "none", + "--simplify-merges": "none", + "--ancestry-path": "none", + "--source": "none", + "--first-parent": "none", + "--merges": "none", + "--no-merges": "none", + "--reverse": "none", + "--walk-reflogs": "none", + "--skip": "number", + "--max-age": "number", + "--min-age": "number", + "--no-min-parents": "none", + "--no-max-parents": "none", + "--follow": "none", + "--no-walk": "none", + "--left-right": "none", + "--cherry-mark": "none", + "--cherry-pick": "none", + "--boundary": "none", + "--topo-order": "none", + "--date-order": "none", + "--author-date-order": "none", + "--pretty": "string", + "--format": "string", + "--diff-filter": "string", + "-S": "string", + "-G": "string", + "--pickaxe-regex": "none", + "--pickaxe-all": "none", + }, + ), + "git show": CommandConfig( + safe_flags={ + **GIT_LOG_DISPLAY_FLAGS, + **GIT_STAT_FLAGS, + **GIT_COLOR_FLAGS, + **GIT_PATCH_FLAGS, + "--abbrev-commit": "none", + "--word-diff": "none", + "--word-diff-regex": "string", + "--color-words": "none", + "--pretty": "string", + "--format": "string", + "--first-parent": "none", + "--raw": "none", + "--diff-filter": "string", + "-m": "none", + "--quiet": "none", + }, + ), + "git shortlog": CommandConfig( + safe_flags={ + **GIT_REF_SELECTION_FLAGS, + **GIT_DATE_FILTER_FLAGS, + "-s": "none", + "--summary": "none", + "-n": "none", + "--numbered": "none", + "-e": "none", + "--email": "none", + "-c": "none", + "--committer": "none", + "--group": "string", + "--format": "string", + "--no-merges": "none", + "--author": "string", + }, + ), + "git reflog": CommandConfig( + safe_flags={ + **GIT_LOG_DISPLAY_FLAGS, + **GIT_REF_SELECTION_FLAGS, + **GIT_DATE_FILTER_FLAGS, + **GIT_COUNT_FLAGS, + **GIT_AUTHOR_FILTER_FLAGS, + }, + # SECURITY: block `git reflog expire/delete` (write .git/logs/**). + additional_command_is_dangerous=_git_reflog_is_dangerous, + ), + "git stash list": CommandConfig( + safe_flags={ + **GIT_LOG_DISPLAY_FLAGS, + **GIT_REF_SELECTION_FLAGS, + **GIT_COUNT_FLAGS, + }, + ), + "git ls-remote": CommandConfig( + safe_flags={ + "--branches": "none", + "-b": "none", + "--tags": "none", + "-t": "none", + "--heads": "none", + "-h": "none", + "--refs": "none", + "--quiet": "none", + "-q": "none", + "--exit-code": "none", + "--get-url": "none", + "--symref": "none", + "--sort": "string", + # SECURITY: --server-option / -o EXCLUDED (network write primitive). + }, + ), + "git status": CommandConfig( + safe_flags={ + "--short": "none", + "-s": "none", + "--branch": "none", + "-b": "none", + "--porcelain": "none", + "--long": "none", + "--verbose": "none", + "-v": "none", + "--untracked-files": "string", + "-u": "string", + "--ignored": "none", + "--ignore-submodules": "string", + "--column": "none", + "--no-column": "none", + "--ahead-behind": "none", + "--no-ahead-behind": "none", + "--renames": "none", + "--no-renames": "none", + "--find-renames": "string", + "-M": "string", + }, + ), + "git blame": CommandConfig( + safe_flags={ + **GIT_COLOR_FLAGS, + "-L": "string", + "--porcelain": "none", + "-p": "none", + "--line-porcelain": "none", + "--incremental": "none", + "--root": "none", + "--show-stats": "none", + "--show-name": "none", + "--show-number": "none", + "-n": "none", + "--show-email": "none", + "-e": "none", + "-f": "none", + "--date": "string", + "-w": "none", + "--ignore-rev": "string", + "--ignore-revs-file": "string", + "-M": "none", + "-C": "none", + "--score-debug": "none", + "--abbrev": "number", + "-s": "none", + "-l": "none", + "-t": "none", + }, + ), + "git ls-files": CommandConfig( + safe_flags={ + "--cached": "none", + "-c": "none", + "--deleted": "none", + "-d": "none", + "--modified": "none", + "-m": "none", + "--others": "none", + "-o": "none", + "--ignored": "none", + "-i": "none", + "--stage": "none", + "-s": "none", + "--killed": "none", + "-k": "none", + "--unmerged": "none", + "-u": "none", + "--directory": "none", + "--no-empty-directory": "none", + "--eol": "none", + "--full-name": "none", + "--abbrev": "number", + "--debug": "none", + "-z": "none", + "-t": "none", + "-v": "none", + "-f": "none", + "--exclude": "string", + "-x": "string", + "--exclude-from": "string", + "-X": "string", + "--exclude-per-directory": "string", + "--exclude-standard": "none", + "--error-unmatch": "none", + "--recurse-submodules": "none", + }, + ), + "git config --get": CommandConfig( + safe_flags={ + "--local": "none", + "--global": "none", + "--system": "none", + "--worktree": "none", + "--default": "string", + "--type": "string", + "--bool": "none", + "--int": "none", + "--bool-or-int": "none", + "--path": "none", + "--expiry-date": "none", + "-z": "none", + "--null": "none", + "--name-only": "none", + "--show-origin": "none", + "--show-scope": "none", + }, + ), + # 'git remote show' before 'git remote' (longer pattern first). + "git remote show": CommandConfig( + safe_flags={ + "-n": "none", + }, + additional_command_is_dangerous=_git_remote_show_is_dangerous, + ), + "git remote": CommandConfig( + safe_flags={ + "-v": "none", + "--verbose": "none", + }, + additional_command_is_dangerous=_git_remote_is_dangerous, + ), + "git merge-base": CommandConfig( + safe_flags={ + "--is-ancestor": "none", + "--fork-point": "none", + "--octopus": "none", + "--independent": "none", + "--all": "none", + }, + ), + "git rev-parse": CommandConfig( + safe_flags={ + "--verify": "none", + "--short": "string", + "--abbrev-ref": "none", + "--symbolic": "none", + "--symbolic-full-name": "none", + "--show-toplevel": "none", + "--show-cdup": "none", + "--show-prefix": "none", + "--git-dir": "none", + "--git-common-dir": "none", + "--absolute-git-dir": "none", + "--show-superproject-working-tree": "none", + "--is-inside-work-tree": "none", + "--is-inside-git-dir": "none", + "--is-bare-repository": "none", + "--is-shallow-repository": "none", + "--is-shallow-update": "none", + "--path-prefix": "none", + }, + ), + "git rev-list": CommandConfig( + safe_flags={ + **GIT_REF_SELECTION_FLAGS, + **GIT_DATE_FILTER_FLAGS, + **GIT_COUNT_FLAGS, + **GIT_AUTHOR_FILTER_FLAGS, + "--count": "none", + "--reverse": "none", + "--first-parent": "none", + "--ancestry-path": "none", + "--merges": "none", + "--no-merges": "none", + "--min-parents": "number", + "--max-parents": "number", + "--no-min-parents": "none", + "--no-max-parents": "none", + "--skip": "number", + "--max-age": "number", + "--min-age": "number", + "--walk-reflogs": "none", + "--oneline": "none", + "--abbrev-commit": "none", + "--pretty": "string", + "--format": "string", + "--abbrev": "number", + "--full-history": "none", + "--dense": "none", + "--sparse": "none", + "--source": "none", + "--graph": "none", + }, + ), + "git describe": CommandConfig( + safe_flags={ + "--tags": "none", + "--match": "string", + "--exclude": "string", + "--long": "none", + "--abbrev": "number", + "--always": "none", + "--contains": "none", + "--first-match": "none", + "--exact-match": "none", + "--candidates": "number", + "--dirty": "none", + "--broken": "none", + }, + ), + "git cat-file": CommandConfig( + safe_flags={ + "-t": "none", + "-s": "none", + "-p": "none", + "-e": "none", + "--batch-check": "none", + "--allow-undetermined-type": "none", + }, + ), + "git for-each-ref": CommandConfig( + safe_flags={ + "--format": "string", + "--sort": "string", + "--count": "number", + "--contains": "string", + "--no-contains": "string", + "--merged": "string", + "--no-merged": "string", + "--points-at": "string", + }, + ), + "git grep": CommandConfig( + safe_flags={ + "-e": "string", + "-E": "none", + "--extended-regexp": "none", + "-G": "none", + "--basic-regexp": "none", + "-F": "none", + "--fixed-strings": "none", + "-P": "none", + "--perl-regexp": "none", + "-i": "none", + "--ignore-case": "none", + "-v": "none", + "--invert-match": "none", + "-w": "none", + "--word-regexp": "none", + "-n": "none", + "--line-number": "none", + "-c": "none", + "--count": "none", + "-l": "none", + "--files-with-matches": "none", + "-L": "none", + "--files-without-match": "none", + "-h": "none", + "-H": "none", + "--heading": "none", + "--break": "none", + "--full-name": "none", + "--color": "none", + "--no-color": "none", + "-o": "none", + "--only-matching": "none", + "-A": "number", + "--after-context": "number", + "-B": "number", + "--before-context": "number", + "-C": "number", + "--context": "number", + "--and": "none", + "--or": "none", + "--not": "none", + "--max-depth": "number", + "--untracked": "none", + "--no-index": "none", + "--recurse-submodules": "none", + "--cached": "none", + "--threads": "number", + "-q": "none", + "--quiet": "none", + }, + ), + "git stash show": CommandConfig( + safe_flags={ + **GIT_STAT_FLAGS, + **GIT_COLOR_FLAGS, + **GIT_PATCH_FLAGS, + "--word-diff": "none", + "--word-diff-regex": "string", + "--diff-filter": "string", + "--abbrev": "number", + }, + ), + "git worktree list": CommandConfig( + safe_flags={ + "--porcelain": "none", + "-v": "none", + "--verbose": "none", + "--expire": "string", + }, + ), + "git tag": CommandConfig( + safe_flags={ + "-l": "none", + "--list": "none", + "-n": "number", + "--contains": "string", + "--no-contains": "string", + "--merged": "string", + "--no-merged": "string", + "--sort": "string", + "--format": "string", + "--points-at": "string", + "--column": "none", + "--no-column": "none", + "-i": "none", + "--ignore-case": "none", + }, + # SECURITY: block tag creation via positional args (write refs/tags/**). + additional_command_is_dangerous=_git_tag_is_dangerous, + ), + "git branch": CommandConfig( + safe_flags={ + "-l": "none", + "--list": "none", + "-a": "none", + "--all": "none", + "-r": "none", + "--remotes": "none", + "-v": "none", + "-vv": "none", + "--verbose": "none", + "--color": "none", + "--no-color": "none", + "--column": "none", + "--no-column": "none", + "--abbrev": "number", + "--no-abbrev": "none", + "--contains": "string", + "--no-contains": "string", + "--merged": "none", + "--no-merged": "none", + "--points-at": "string", + "--sort": "string", + "--show-current": "none", + "-i": "none", + "--ignore-case": "none", + }, + # SECURITY: block branch creation via positional args. + additional_command_is_dangerous=_git_branch_is_dangerous, + ), +} + + +# =========================================================================== +# DOCKER_READ_ONLY_COMMANDS (TS readOnlyCommandValidation.ts:1386) +# =========================================================================== + +DOCKER_READ_ONLY_COMMANDS: dict[str, CommandConfig] = { + "docker logs": CommandConfig( + safe_flags={ + "--follow": "none", + "-f": "none", + "--tail": "string", + "-n": "string", + "--timestamps": "none", + "-t": "none", + "--since": "string", + "--until": "string", + "--details": "none", + }, + ), + "docker inspect": CommandConfig( + safe_flags={ + "--format": "string", + "-f": "string", + "--type": "string", + "--size": "none", + "-s": "none", + }, + ), +} + + +# =========================================================================== +# RIPGREP_READ_ONLY_COMMANDS (TS readOnlyCommandValidation.ts:1416) +# =========================================================================== + +RIPGREP_READ_ONLY_COMMANDS: dict[str, CommandConfig] = { + "rg": CommandConfig( + safe_flags={ + "-e": "string", + "--regexp": "string", + "-f": "string", + "-i": "none", + "--ignore-case": "none", + "-S": "none", + "--smart-case": "none", + "-F": "none", + "--fixed-strings": "none", + "-w": "none", + "--word-regexp": "none", + "-v": "none", + "--invert-match": "none", + "-c": "none", + "--count": "none", + "-l": "none", + "--files-with-matches": "none", + "--files-without-match": "none", + "-n": "none", + "--line-number": "none", + "-o": "none", + "--only-matching": "none", + "-A": "number", + "--after-context": "number", + "-B": "number", + "--before-context": "number", + "-C": "number", + "--context": "number", + "-H": "none", + "-h": "none", + "--heading": "none", + "--no-heading": "none", + "-q": "none", + "--quiet": "none", + "--column": "none", + "-g": "string", + "--glob": "string", + "-t": "string", + "--type": "string", + "-T": "string", + "--type-not": "string", + "--type-list": "none", + "--hidden": "none", + "--no-ignore": "none", + "-u": "none", + "-m": "number", + "--max-count": "number", + "-d": "number", + "--max-depth": "number", + "-a": "none", + "--text": "none", + "-z": "none", + "-L": "none", + "--follow": "none", + "--color": "string", + "--json": "none", + "--stats": "none", + "--help": "none", + "--version": "none", + "--debug": "none", + "--": "none", + }, + ), +} + + +# =========================================================================== +# PYRIGHT_READ_ONLY_COMMANDS (TS readOnlyCommandValidation.ts:1504) +# =========================================================================== + +PYRIGHT_READ_ONLY_COMMANDS: dict[str, CommandConfig] = { + "pyright": CommandConfig( + # pyright treats `--` as a file path, not end-of-options. + respects_double_dash=False, + safe_flags={ + "--outputjson": "none", + "--project": "string", + "-p": "string", + "--pythonversion": "string", + "--pythonplatform": "string", + "--typeshedpath": "string", + "--venvpath": "string", + "--level": "string", + "--stats": "none", + "--verbose": "none", + "--version": "none", + "--dependencies": "none", + "--warnings": "none", + }, + additional_command_is_dangerous=_pyright_is_dangerous, + ), +} + + +# =========================================================================== +# EXTERNAL_READONLY_COMMANDS (TS readOnlyCommandValidation.ts:1539) +# Cross-shell commands that work identically in bash and PowerShell. +# =========================================================================== + +EXTERNAL_READONLY_COMMANDS: list[str] = [ + "docker ps", + "docker images", +] + + +# =========================================================================== +# FD_SAFE_FLAGS (TS readOnlyValidation.ts:54) +# SECURITY: -x/--exec, -X/--exec-batch, -l/--list-details deliberately excluded. +# =========================================================================== + +FD_SAFE_FLAGS: dict[str, str] = { + "-h": "none", + "--help": "none", + "-V": "none", + "--version": "none", + "-H": "none", + "--hidden": "none", + "-I": "none", + "--no-ignore": "none", + "--no-ignore-vcs": "none", + "--no-ignore-parent": "none", + "-s": "none", + "--case-sensitive": "none", + "-i": "none", + "--ignore-case": "none", + "-g": "none", + "--glob": "none", + "--regex": "none", + "-F": "none", + "--fixed-strings": "none", + "-a": "none", + "--absolute-path": "none", + "-L": "none", + "--follow": "none", + "-p": "none", + "--full-path": "none", + "-0": "none", + "--print0": "none", + "-d": "number", + "--max-depth": "number", + "--min-depth": "number", + "--exact-depth": "number", + "-t": "string", + "--type": "string", + "-e": "string", + "--extension": "string", + "-S": "string", + "--size": "string", + "--changed-within": "string", + "--changed-before": "string", + "-o": "string", + "--owner": "string", + "-E": "string", + "--exclude": "string", + "--ignore-file": "string", + "-c": "string", + "--color": "string", + "-j": "number", + "--threads": "number", + "--max-buffer-time": "string", + "--max-results": "number", + "-1": "none", + "-q": "none", + "--quiet": "none", + "--show-errors": "none", + "--strip-cwd-prefix": "none", + "--one-file-system": "none", + "--prune": "none", + "--search-path": "string", + "--base-directory": "string", + "--path-separator": "string", + "--batch-size": "number", + "--no-require-git": "none", + "--hyperlink": "string", + "--and": "string", + "--format": "string", +} + + +# =========================================================================== +# COMMAND_ALLOWLIST (TS readOnlyValidation.ts:127-1136) +# Spreads keep the shared tables single-source; insertion order preserved so +# multi-word command matching sees longer patterns appropriately. +# =========================================================================== + +COMMAND_ALLOWLIST: dict[str, CommandConfig] = { + "xargs": CommandConfig( + safe_flags={ + "-I": "{}", + # SECURITY: lowercase -i / -e REMOVED (GNU optional-attached-arg + # semantics create a validator/xargs differential -> exfil / RCE). + "-n": "number", + "-P": "number", + "-L": "number", + "-s": "number", + "-E": "EOF", # POSIX, MANDATORY separate arg + "-0": "none", + "-t": "none", + "-r": "none", + "-x": "none", + "-d": "char", + }, + ), + # All git read-only commands from the shared validation map. + **GIT_READ_ONLY_COMMANDS, + "file": CommandConfig( + safe_flags={ + "--brief": "none", + "-b": "none", + "--mime": "none", + "-i": "none", + "--mime-type": "none", + "--mime-encoding": "none", + "--apple": "none", + "--check-encoding": "none", + "-c": "none", + "--exclude": "string", + "--exclude-quiet": "string", + "--print0": "none", + "-0": "none", + "-f": "string", + "-F": "string", + "--separator": "string", + "--help": "none", + "--version": "none", + "-v": "none", + "--no-dereference": "none", + "-h": "none", + "--dereference": "none", + "-L": "none", + "--magic-file": "string", + "-m": "string", + "--keep-going": "none", + "-k": "none", + "--list": "none", + "-l": "none", + "--no-buffer": "none", + "-n": "none", + "--preserve-date": "none", + "-p": "none", + "--raw": "none", + "-r": "none", + "-s": "none", + "--special-files": "none", + "--uncompress": "none", + "-z": "none", + }, + ), + "sed": CommandConfig( + safe_flags={ + "--expression": "string", + "-e": "string", + "--quiet": "none", + "--silent": "none", + "-n": "none", + "--regexp-extended": "none", + "-r": "none", + "--posix": "none", + "-E": "none", + "--line-length": "number", + "-l": "number", + "--zero-terminated": "none", + "-z": "none", + "--separate": "none", + "-s": "none", + "--unbuffered": "none", + "-u": "none", + "--debug": "none", + "--help": "none", + "--version": "none", + }, + additional_command_is_dangerous=_sed_is_dangerous, + ), + "sort": CommandConfig( + safe_flags={ + "--ignore-leading-blanks": "none", + "-b": "none", + "--dictionary-order": "none", + "-d": "none", + "--ignore-case": "none", + "-f": "none", + "--general-numeric-sort": "none", + "-g": "none", + "--human-numeric-sort": "none", + "-h": "none", + "--ignore-nonprinting": "none", + "-i": "none", + "--month-sort": "none", + "-M": "none", + "--numeric-sort": "none", + "-n": "none", + "--random-sort": "none", + "-R": "none", + "--reverse": "none", + "-r": "none", + "--sort": "string", + "--stable": "none", + "-s": "none", + "--unique": "none", + "-u": "none", + "--version-sort": "none", + "-V": "none", + "--zero-terminated": "none", + "-z": "none", + "--key": "string", + "-k": "string", + "--field-separator": "string", + "-t": "string", + "--check": "none", + "-c": "none", + "--check-char-order": "none", + "-C": "none", + "--merge": "none", + "-m": "none", + "--buffer-size": "string", + "-S": "string", + "--parallel": "number", + "--batch-size": "number", + "--help": "none", + "--version": "none", + }, + ), + "man": CommandConfig( + safe_flags={ + "-a": "none", + "--all": "none", + "-d": "none", + "-f": "none", + "--whatis": "none", + "-h": "none", + "-k": "none", + "--apropos": "none", + "-l": "string", + "-w": "none", + "-S": "string", + "-s": "string", + }, + ), + # help — only bash builtin help flags (man's -P allows arbitrary exec). + "help": CommandConfig( + safe_flags={ + "-d": "none", + "-m": "none", + "-s": "none", + }, + ), + "netstat": CommandConfig( + safe_flags={ + "-a": "none", + "-L": "none", + "-l": "none", + "-n": "none", + "-f": "string", + "-g": "none", + "-i": "none", + "-I": "string", + "-s": "none", + "-r": "none", + "-m": "none", + "-v": "none", + }, + ), + "ps": CommandConfig( + safe_flags={ + "-e": "none", + "-A": "none", + "-a": "none", + "-d": "none", + "-N": "none", + "--deselect": "none", + "-f": "none", + "-F": "none", + "-l": "none", + "-j": "none", + "-y": "none", + "-w": "none", + "-ww": "none", + "--width": "number", + "-c": "none", + "-H": "none", + "--forest": "none", + "--headers": "none", + "--no-headers": "none", + "-n": "string", + "--sort": "string", + "-L": "none", + "-T": "none", + "-m": "none", + "-C": "string", + "-G": "string", + "-g": "string", + "-p": "string", + "--pid": "string", + "-q": "string", + "--quick-pid": "string", + "-s": "string", + "--sid": "string", + "-t": "string", + "--tty": "string", + "-U": "string", + "-u": "string", + "--user": "string", + "--help": "none", + "--info": "none", + "-V": "none", + "--version": "none", + }, + # Block BSD-style 'e' modifier (shows env vars). + additional_command_is_dangerous=_ps_is_dangerous, + ), + "base64": CommandConfig( + respects_double_dash=False, # macOS base64 does not respect POSIX -- + safe_flags={ + "-d": "none", + "-D": "none", + "--decode": "none", + "-b": "number", + "--break": "number", + "-w": "number", + "--wrap": "number", + "-i": "string", + "--input": "string", + "--ignore-garbage": "none", + "-h": "none", + "--help": "none", + "--version": "none", + }, + ), + "grep": CommandConfig( + safe_flags={ + "-e": "string", + "--regexp": "string", + "-f": "string", + "--file": "string", + "-F": "none", + "--fixed-strings": "none", + "-G": "none", + "--basic-regexp": "none", + "-E": "none", + "--extended-regexp": "none", + "-P": "none", + "--perl-regexp": "none", + "-i": "none", + "--ignore-case": "none", + "--no-ignore-case": "none", + "-v": "none", + "--invert-match": "none", + "-w": "none", + "--word-regexp": "none", + "-x": "none", + "--line-regexp": "none", + "-c": "none", + "--count": "none", + "--color": "string", + "--colour": "string", + "-L": "none", + "--files-without-match": "none", + "-l": "none", + "--files-with-matches": "none", + "-m": "number", + "--max-count": "number", + "-o": "none", + "--only-matching": "none", + "-q": "none", + "--quiet": "none", + "--silent": "none", + "-s": "none", + "--no-messages": "none", + "-b": "none", + "--byte-offset": "none", + "-H": "none", + "--with-filename": "none", + "-h": "none", + "--no-filename": "none", + "--label": "string", + "-n": "none", + "--line-number": "none", + "-T": "none", + "--initial-tab": "none", + "-u": "none", + "--unix-byte-offsets": "none", + "-Z": "none", + "--null": "none", + "-z": "none", + "--null-data": "none", + "-A": "number", + "--after-context": "number", + "-B": "number", + "--before-context": "number", + "-C": "number", + "--context": "number", + "--group-separator": "string", + "--no-group-separator": "none", + "-a": "none", + "--text": "none", + "--binary-files": "string", + "-D": "string", + "--devices": "string", + "-d": "string", + "--directories": "string", + "--exclude": "string", + "--exclude-from": "string", + "--exclude-dir": "string", + "--include": "string", + "-r": "none", + "--recursive": "none", + "-R": "none", + "--dereference-recursive": "none", + "--line-buffered": "none", + "-U": "none", + "--binary": "none", + "--help": "none", + "-V": "none", + "--version": "none", + }, + ), + # rg (ripgrep) from the shared validation map. + **RIPGREP_READ_ONLY_COMMANDS, + "sha256sum": CommandConfig( + safe_flags={ + "-b": "none", + "--binary": "none", + "-t": "none", + "--text": "none", + "-c": "none", + "--check": "none", + "--ignore-missing": "none", + "--quiet": "none", + "--status": "none", + "--strict": "none", + "-w": "none", + "--warn": "none", + "--tag": "none", + "-z": "none", + "--zero": "none", + "--help": "none", + "--version": "none", + }, + ), + "sha1sum": CommandConfig( + safe_flags={ + "-b": "none", + "--binary": "none", + "-t": "none", + "--text": "none", + "-c": "none", + "--check": "none", + "--ignore-missing": "none", + "--quiet": "none", + "--status": "none", + "--strict": "none", + "-w": "none", + "--warn": "none", + "--tag": "none", + "-z": "none", + "--zero": "none", + "--help": "none", + "--version": "none", + }, + ), + "md5sum": CommandConfig( + safe_flags={ + "-b": "none", + "--binary": "none", + "-t": "none", + "--text": "none", + "-c": "none", + "--check": "none", + "--ignore-missing": "none", + "--quiet": "none", + "--status": "none", + "--strict": "none", + "-w": "none", + "--warn": "none", + "--tag": "none", + "-z": "none", + "--zero": "none", + "--help": "none", + "--version": "none", + }, + ), + # tree — -o/--output writes to a file, so it's excluded. -R excluded (writes + # 00Tree.html). All other flags are display/filter options. + "tree": CommandConfig( + safe_flags={ + "-a": "none", + "-d": "none", + "-l": "none", + "-f": "none", + "-x": "none", + "-L": "number", + "-P": "string", + "-I": "string", + "--gitignore": "none", + "--gitfile": "string", + "--ignore-case": "none", + "--matchdirs": "none", + "--metafirst": "none", + "--prune": "none", + "--info": "none", + "--infofile": "string", + "--noreport": "none", + "--charset": "string", + "--filelimit": "number", + "-q": "none", + "-N": "none", + "-Q": "none", + "-p": "none", + "-u": "none", + "-g": "none", + "-s": "none", + "-h": "none", + "--si": "none", + "--du": "none", + "-D": "none", + "--timefmt": "string", + "-F": "none", + "--inodes": "none", + "--device": "none", + "-v": "none", + "-t": "none", + "-c": "none", + "-U": "none", + "-r": "none", + "--dirsfirst": "none", + "--filesfirst": "none", + "--sort": "string", + "-i": "none", + "-A": "none", + "-S": "none", + "-n": "none", + "-C": "none", + "-X": "none", + "-J": "none", + "-H": "string", + "--nolinks": "none", + "--hintro": "string", + "--houtro": "string", + "-T": "string", + "--hyperlink": "none", + "--scheme": "string", + "--authority": "string", + "--fromfile": "none", + "--fromtabfile": "none", + "--fflinks": "none", + "--help": "none", + "--version": "none", + }, + ), + # date — -s/--set and -f/--file can set system time; only safe display + # options allowed, and positional args must start with '+' (see callback). + "date": CommandConfig( + safe_flags={ + "-d": "string", + "--date": "string", + "-r": "string", + "--reference": "string", + "-u": "none", + "--utc": "none", + "--universal": "none", + "-I": "none", + "--iso-8601": "string", + "-R": "none", + "--rfc-email": "none", + "--rfc-3339": "string", + "--debug": "none", + "--help": "none", + "--version": "none", + }, + additional_command_is_dangerous=_date_is_dangerous, + ), + # hostname — positional args set the hostname; block them via regex. + "hostname": CommandConfig( + safe_flags={ + "-f": "none", + "--fqdn": "none", + "--long": "none", + "-s": "none", + "--short": "none", + "-i": "none", + "--ip-address": "none", + "-I": "none", + "--all-ip-addresses": "none", + "-a": "none", + "--alias": "none", + "-d": "none", + "--domain": "none", + "-A": "none", + "--all-fqdns": "none", + "-v": "none", + "--verbose": "none", + "-h": "none", + "--help": "none", + "-V": "none", + "--version": "none", + }, + regex=re.compile(r"^hostname(?:\s+(?:-[a-zA-Z]|--[a-zA-Z-]+))*\s*$"), + ), + # info — -o/--output writes files; only safe display/navigation options. + "info": CommandConfig( + safe_flags={ + "-f": "string", + "--file": "string", + "-d": "string", + "--directory": "string", + "-n": "string", + "--node": "string", + "-a": "none", + "--all": "none", + "-k": "string", + "--apropos": "string", + "-w": "none", + "--where": "none", + "--location": "none", + "--show-options": "none", + "--vi-keys": "none", + "--subnodes": "none", + "-h": "none", + "--help": "none", + "--usage": "none", + "--version": "none", + }, + ), + "lsof": CommandConfig( + safe_flags={ + "-?": "none", + "-h": "none", + "-v": "none", + "-a": "none", + "-b": "none", + "-C": "none", + "-l": "none", + "-n": "none", + "-N": "none", + "-O": "none", + "-P": "none", + "-Q": "none", + "-R": "none", + "-t": "none", + "-U": "none", + "-V": "none", + "-X": "none", + "-H": "none", + "-E": "none", + "-F": "none", + "-g": "none", + "-i": "none", + "-K": "none", + "-L": "none", + "-o": "none", + "-r": "none", + "-s": "none", + "-S": "none", + "-T": "none", + "-x": "none", + "-A": "string", + "-c": "string", + "-d": "string", + "-e": "string", + "-k": "string", + "-p": "string", + "-u": "string", + # OMITTED (writes to disk): -D (device cache file build/update) + }, + # Block +m (create mount supplement file) — writes to disk. + additional_command_is_dangerous=_lsof_is_dangerous, + ), + "pgrep": CommandConfig( + safe_flags={ + "-d": "string", + "--delimiter": "string", + "-l": "none", + "--list-name": "none", + "-a": "none", + "--list-full": "none", + "-v": "none", + "--inverse": "none", + "-w": "none", + "--lightweight": "none", + "-c": "none", + "--count": "none", + "-f": "none", + "--full": "none", + "-g": "string", + "--pgroup": "string", + "-G": "string", + "--group": "string", + "-i": "none", + "--ignore-case": "none", + "-n": "none", + "--newest": "none", + "-o": "none", + "--oldest": "none", + "-O": "string", + "--older": "string", + "-P": "string", + "--parent": "string", + "-s": "string", + "--session": "string", + "-t": "string", + "--terminal": "string", + "-u": "string", + "--euid": "string", + "-U": "string", + "--uid": "string", + "-x": "none", + "--exact": "none", + "-F": "string", + "--pidfile": "string", + "-L": "none", + "--logpidfile": "none", + "-r": "string", + "--runstates": "string", + "--ns": "string", + "--nslist": "string", + "--help": "none", + "-V": "none", + "--version": "none", + }, + ), + "tput": CommandConfig( + safe_flags={ + "-T": "string", + "-V": "none", + "-x": "none", + # SECURITY: -S (read capability names from stdin) EXCLUDED. + }, + additional_command_is_dangerous=_tput_is_dangerous, + ), + # ss — socket statistics (iproute2). -K/--kill, -D/--diag, -F/--filter, + # -N/--net deliberately excluded. + "ss": CommandConfig( + safe_flags={ + "-h": "none", + "--help": "none", + "-V": "none", + "--version": "none", + "-n": "none", + "--numeric": "none", + "-r": "none", + "--resolve": "none", + "-a": "none", + "--all": "none", + "-l": "none", + "--listening": "none", + "-o": "none", + "--options": "none", + "-e": "none", + "--extended": "none", + "-m": "none", + "--memory": "none", + "-p": "none", + "--processes": "none", + "-i": "none", + "--info": "none", + "-s": "none", + "--summary": "none", + "-4": "none", + "--ipv4": "none", + "-6": "none", + "--ipv6": "none", + "-0": "none", + "--packet": "none", + "-t": "none", + "--tcp": "none", + "-M": "none", + "--mptcp": "none", + "-S": "none", + "--sctp": "none", + "-u": "none", + "--udp": "none", + "-d": "none", + "--dccp": "none", + "-w": "none", + "--raw": "none", + "-x": "none", + "--unix": "none", + "--tipc": "none", + "--vsock": "none", + "-f": "string", + "--family": "string", + "-A": "string", + "--query": "string", + "--socket": "string", + "-Z": "none", + "--context": "none", + "-z": "none", + "--contexts": "none", + "-b": "none", + "--bpf": "none", + "-E": "none", + "--events": "none", + "-H": "none", + "--no-header": "none", + "-O": "none", + "--oneline": "none", + "--tipcinfo": "none", + "--tos": "none", + "--cgroup": "none", + "--inet-sockopt": "none", + }, + ), + # fd/fdfind — fast file finder. -x/--exec and -X/--exec-batch excluded. + "fd": CommandConfig(safe_flags={**FD_SAFE_FLAGS}), + # fdfind is the Debian/Ubuntu package name for fd — same binary/flags. + "fdfind": CommandConfig(safe_flags={**FD_SAFE_FLAGS}), + **PYRIGHT_READ_ONLY_COMMANDS, + **DOCKER_READ_ONLY_COMMANDS, +} + + +# =========================================================================== +# SAFE_TARGET_COMMANDS_FOR_XARGS (TS readOnlyValidation.ts:1166) +# =========================================================================== + +SAFE_TARGET_COMMANDS_FOR_XARGS: list[str] = [ + "echo", + "printf", + "wc", + "grep", + "head", + "tail", +] + + +# =========================================================================== +# makeRegexForSafeCommand + READONLY_COMMANDS + READONLY_COMMAND_REGEXES +# (TS readOnlyValidation.ts:1356-1504) +# =========================================================================== + + +def make_regex_for_safe_command(command: str) -> re.Pattern[str]: + r"""TS readOnlyValidation.ts:1356. Matches safe invocations of `command`, + blocking shell metacharacters / substitution / expansion / assignment. + The command name is interpolated raw (unescaped), matching the TS template + literal: new RegExp(`^${command}(?:\s|$)[^<>()$\`|{}&;\n\r]*$`).""" + return re.compile("^" + command + r"(?:\s|$)[^<>()$`|{}&;\n\r]*$") + + +# Simple commands that are safe for execution (each -> makeRegexForSafeCommand). +READONLY_COMMANDS: list[str] = [ + # Cross-platform commands from shared validation. + *EXTERNAL_READONLY_COMMANDS, + # Time and date. + "cal", + "uptime", + # File content viewing. + "cat", + "head", + "tail", + "wc", + "stat", + "strings", + "hexdump", + "od", + "nl", + # System info. + "id", + "uname", + "free", + "df", + "du", + "locale", + "groups", + "nproc", + # Path information. + "basename", + "dirname", + "realpath", + # Text processing. + "cut", + "paste", + "tr", + "column", + "tac", + "rev", + "fold", + "expand", + "unexpand", + "fmt", + "comm", + "cmp", + "numfmt", + # Path information (additional). + "readlink", + # File comparison. + "diff", + # true and false. + "true", + "false", + # Misc. safe commands. + "sleep", + "which", + "type", + "expr", + "test", + "getconf", + "seq", + "tsort", + "pr", +] + + +# Complex commands that require custom regex patterns. +# TS uses a Set; a list preserves order and is equivalent for `.test()` scans. +READONLY_COMMAND_REGEXES: list[re.Pattern[str]] = [ + # Simple commands converted via make_regex_for_safe_command. + *[make_regex_for_safe_command(c) for c in READONLY_COMMANDS], + # Echo that doesn't execute commands or use variables. Allow newlines in + # single quotes (safe), optional trailing 2>&1. + re.compile( + r"""^echo(?:\s+(?:'[^']*'|"[^"$<>\n\r]*"|[^|;&`$(){}><#\\!"'\s]+))*(?:\s+2>&1)?\s*$""" + ), + # Claude CLI help. + re.compile(r"^claude -h$"), + re.compile(r"^claude --help$"), + # Only flags, no input/output files. + re.compile(r"^uniq(?:\s+(?:-[a-zA-Z]+|--[a-zA-Z-]+(?:=\S+)?|-[fsw]\s+\d+))*(?:\s|$)\s*$"), + # System info. + re.compile(r"^pwd$"), + re.compile(r"^whoami$"), + # Development tools version checking — exact match only, no suffix allowed. + re.compile(r"^node -v$"), + re.compile(r"^node --version$"), + re.compile(r"^python --version$"), + re.compile(r"^python3 --version$"), + # Misc. safe commands. + re.compile(r"^history(?:\s+\d+)?\s*$"), + re.compile(r"^alias$"), + re.compile(r"^arch(?:\s+(?:--help|-h))?\s*$"), + # Network commands — exact commands with no arguments. + re.compile(r"^ip addr$"), + re.compile(r"^ifconfig(?:\s+[a-zA-Z][a-zA-Z0-9_-]*)?\s*$"), + # JSON processing with jq — inline filters and file args; block dangerous + # flags (-f/--from-file, --rawfile, --slurpfile, --run-tests, -L/--library- + # path), the `env` builtin, and `$ENV`. + re.compile( + r"""^jq(?!\s+.*(?:-f\b|--from-file|--rawfile|--slurpfile|--run-tests|-L\b|--library-path|\benv\b|\$ENV\b))(?:\s+(?:-[a-zA-Z]+|--[a-zA-Z-]+(?:=\S+)?))*(?:\s+'[^'`]*'|\s+"[^"`]*"|\s+[^-\s'"][^\s]*)+\s*$""" + ), + # Path commands (path validation ensures they're allowed). + # cd — allows changing to directories. + re.compile(r"""^cd(?:\s+(?:'[^']*'|"[^"]*"|[^\s;|&`$(){}><#\\]+))?$"""), + # ls — allows listing directories. + re.compile(r"^ls(?:\s+[^<>()$`|{}&;\n\r]*)?$"), + # find — blocks dangerous flags. Allow escaped parens \( \) for grouping. + re.compile( + r"^find(?:\s+(?:\\[()]|(?!-delete\b|-exec\b|-execdir\b|-ok\b|-okdir\b|-fprint0?\b|-fls\b|-fprintf\b)[^<>()$`|{}&;\n\r\s]|\s)+)?$" + ), +] + + +__all__ = [ + "FlagArgType", + "CommandConfig", + "GIT_REF_SELECTION_FLAGS", + "GIT_DATE_FILTER_FLAGS", + "GIT_LOG_DISPLAY_FLAGS", + "GIT_COUNT_FLAGS", + "GIT_STAT_FLAGS", + "GIT_COLOR_FLAGS", + "GIT_PATCH_FLAGS", + "GIT_AUTHOR_FILTER_FLAGS", + "GIT_READ_ONLY_COMMANDS", + "RIPGREP_READ_ONLY_COMMANDS", + "PYRIGHT_READ_ONLY_COMMANDS", + "DOCKER_READ_ONLY_COMMANDS", + "EXTERNAL_READONLY_COMMANDS", + "FD_SAFE_FLAGS", + "COMMAND_ALLOWLIST", + "SAFE_TARGET_COMMANDS_FOR_XARGS", + "make_regex_for_safe_command", + "READONLY_COMMANDS", + "READONLY_COMMAND_REGEXES", + "sed_command_is_allowed_by_allowlist", + "is_line_printing_command", + "is_substitution_command", + "is_print_command", + "has_file_args", + "extract_sed_expressions", + "contains_dangerous_operations", +] diff --git a/src/permissions/updates.py b/src/permissions/updates.py index 185e9ade9..846079546 100644 --- a/src/permissions/updates.py +++ b/src/permissions/updates.py @@ -447,12 +447,11 @@ def create_read_rule_suggestion( {"AskUserQuestion", "EnterPlanMode", "ExitPlanMode"} ) -# NB: the original surfaces this option with a "(shift+tab)" hint, because there -# the key cycles permission modes. This port has the cycle *logic* -# (``src.permissions.cycle.cycle_permission_mode``) but no keybinding wired to -# it — mode changes go through the ``/permissions`` command — so advertising the -# shortcut would promise a keypress that does nothing. The hint is intentionally -# omitted until a shift+tab binding exists. +# NB: the original surfaces this option with a "(shift+tab)" hint, because +# there the key cycles permission modes. The TUI wired shift+tab cycling in +# ch13 round-4 (ui-tui useInputHandlers → cycle_permission_mode control), so +# the hint would now be truthful — it stays off the LABEL only because the +# option text mirrors the original's wording exactly. _PATH_INPUT_KEYS: tuple[str, ...] = ("file_path", "notebook_path", "path") @@ -585,7 +584,36 @@ def default_session_suggestions( ) return updates - # Every other tool (WebFetch, Skill, MCP, …): persisted content-less rule. + # WebFetch: domain-scoped rule (TS WebFetchTool.ts:346 buildSuggestions) — + # "don't ask again" grants the HOST, not every future fetch. Normally the + # tool's own check supplies this on its passthrough; this branch is the + # fallback for callers that build suggestions from tool_input directly. + if tool_name == "WebFetch": + url = tool_input.get("url", "") + hostname = None + if isinstance(url, str) and url: + import urllib.parse + + try: + hostname = urllib.parse.urlparse(url).hostname + except Exception: + hostname = None + if hostname: + return [ + PermissionUpdateAddRules( + destination="localSettings", + behavior="allow", + rules=( + PermissionRuleValue( + tool_name="WebFetch", + rule_content=f"domain:{hostname}", + ), + ), + ) + ] + # Unparseable URL → fall through to the content-less rule below. + + # Every other tool (Skill, MCP, …): persisted content-less rule. if tool_name: return [ PermissionUpdateAddRules( diff --git a/src/server/agent_server.py b/src/server/agent_server.py index a74cdf37a..5f8650f6c 100644 --- a/src/server/agent_server.py +++ b/src/server/agent_server.py @@ -1916,6 +1916,10 @@ def permission_handler(self, request: Any) -> Any: # the real grant scope instead of a generic "don't ask again for # ". Mirrors the original's tool-specific option text. "session_label": _session_option_label_safe(request), + # Destructive-command caution (e.g. "Note: may overwrite + # remote history") — rendered as a warning line in the + # approval box, mirroring the original's dialog warning. + "warning": _permission_request_warning(request), }, }) @@ -3678,6 +3682,29 @@ def _session_option_label_safe(request: Any) -> str | None: return None +def _permission_request_warning(request: Any) -> str | None: + """Destructive-command caution line for the approval box. + + The original renders this inside its Bash permission dialog + (destructiveCommandWarning). Since the loosening rework routes + destructive commands through the ordinary grantable prompt (no more + un-grantable class asks), the warning is how the risk stays visible. + Best-effort and purely informational.""" + try: + if (getattr(request, "tool_name", "") or "") != "Bash": + return None + command = (getattr(request, "tool_input", None) or {}).get("command", "") + if not isinstance(command, str) or not command: + return None + from src.tool_system.tools.bash.destructive_warnings import ( + get_destructive_command_warning, + ) + + return get_destructive_command_warning(command) + except Exception: # noqa: BLE001 — warning is cosmetic + return None + + def _serialize_permission_update(update: Any) -> dict: """ch13 round-4 — wire shape for a PermissionUpdate. Delegates to the canonical serializer (promoted to src/permissions/updates.py in HOOKS-1, diff --git a/src/tool_system/tools/bash/bash_tool.py b/src/tool_system/tools/bash/bash_tool.py index 6e6bf5e19..0fa87cb10 100644 --- a/src/tool_system/tools/bash/bash_tool.py +++ b/src/tool_system/tools/bash/bash_tool.py @@ -170,13 +170,12 @@ def _run_bash_with_abort( from ...context import ToolContext from ...errors import ToolInputError, ToolPermissionError from ...protocol import ToolResult -from src.permissions.bash_security import check_bash_command_safety +from src.permissions.bash_security import analyze_bash_command from src.permissions.types import PermissionPassthroughResult, PermissionResult from src.utils.format import format_duration from .background import spawn_background_bash from .command_semantics import interpret_command_result -from .destructive_warnings import get_destructive_command_warning from .prompt import get_bash_prompt, get_default_timeout_ms, get_max_timeout_ms from .read_only_validation import is_command_read_only from .search_classification import ( @@ -209,18 +208,237 @@ def _bash_check_permissions( tool_input: dict[str, Any], context: ToolContext, ) -> PermissionResult: + """Bash's own permission stage — TS-parity rewrite. + + Mirrors the original's per-command pipeline (bashPermissions.ts): deny/ask + RULES run upstream in ``has_permissions_to_use_tool_inner`` (compound-aware + and normalization-hardened) before this is called; here we resolve: + + 1. STRUCTURAL refusals only — a command the parser can't statically + analyze (control flow, unparseable quoting) or one hiding executable + substitution asks with NO suggestions (TS too-complex/injection asks, + ``suggestions: []``), except that a raw exact-string allow rule is + honored first (TS checkEarlyExitDeny exact-allow: the user consciously + saved that literal command). + 2. acceptEdits mode — filesystem-write commands (mkdir/touch/rm/rmdir/ + mv/cp/sed; rm/rmdir still gated on critical paths) and redirect-free + read-only commands auto-allow (TS modeValidation.ts). + 3. Read-only auto-allow — a provably read-only, in-roots command runs + with NO prompt and NO rule in any mode (TS bashPermissions.ts:1136 + "Read-only command is allowed"). + 4. Everything else → passthrough → the generic prompt, which now carries + the "don't ask again" suggestion ladder. + + The old class-based screen (dangerous/destructive/unknown → un-grantable + safety ask that also preempted allow rules) is gone — the original has no + such screen; its dialog shows a warning for destructive commands instead + (forwarded via the ``warning`` field on the can_use_tool request). The + classifier still consumes ``analyze_bash_command`` via auto mode, and the + hardcoded dangerous patterns + sandbox hard-gate still refuse at spawn + (``bash_command_safety_guard``). + """ command = (tool_input or {}).get("command", "") if not command: return PermissionPassthroughResult() - cwd_str = str(context.cwd) if context.cwd else None - result = check_bash_command_safety(command, cwd=cwd_str) - if result is not None: - return result + from src.permissions.bash_mode_validation import check_accept_edits_bash + from src.permissions.bash_suggestions import ( + contains_executable_substitution, + ) + from src.permissions.read_only_commands import ( + check_read_only_constraints, + ) + from src.permissions.types import ( + PermissionAllowDecision, + PermissionAskDecision, + SafetyCheckDecisionReason, + ) + + cwd_str = str(context.cwd) if getattr(context, "cwd", None) else _os_mod.getcwd() + perm_ctx = getattr(context, "permission_context", None) + + # bypassPermissions (and plan+bypass-available) runs everything — the + # tool's own structural/write asks must not preempt it (they are + # SafetyCheck asks, which otherwise short-circuit before the bypass branch + # in has_permissions_to_use_tool_inner). Deny rules still apply upstream. + _mode = getattr(perm_ctx, "mode", None) + if _mode == "bypassPermissions" or ( + _mode == "plan" + and getattr(perm_ctx, "is_bypass_permissions_mode_available", False) + ): + return PermissionPassthroughResult() + + allowed_roots: list[str] = [] + try: + allowed_roots = [str(r) for r in context.allowed_roots()] + except Exception: + allowed_roots = [] + if not allowed_roots: + allowed_roots = [cwd_str] + + # 1. Structural refusals (parser gives up / hidden substitution / + # eval-like builtins whose arguments ARE code). Two TS analogs with + # DIFFERENT exact-allow handling: + # * PARSE refusals (AST too-complex / injection substitution) go through + # checkEarlyExitDeny, which honors an exact-string ALLOW rule + # (bashPermissions.ts:2124-2131) — the user saved that literal command. + # * checkSemantics refusals (EVAL_LIKE_BUILTINS, NAME-eval subscript) + # go through checkSemanticsDeny, which only honors DENY rules, NEVER an + # allow — so an exact ``Bash(eval "…")`` rule must NOT run eval. + # All ask with empty suggestions. + from src.permissions.read_only_commands import ( + accesses_proc_environ, + find_eval_like_builtin, + find_name_eval_subscript_attack, + ) + + analysis = analyze_bash_command(command) + parse_reason: str | None = None # exact-allow honored + semantics_reason: str | None = None # exact-allow NOT honored + if analysis.is_complex: + parse_reason = f"Complex command: {analysis.reason}" + elif contains_executable_substitution(command): + parse_reason = "Command contains substitution that executes hidden commands" + else: + eval_like = find_eval_like_builtin(command) + subscript = find_name_eval_subscript_attack(command) + if eval_like is not None: + semantics_reason = ( + f"`{eval_like}` executes its arguments as shell code, which " + "cannot be statically analyzed" + ) + elif subscript is not None: + semantics_reason = ( + f"`{subscript}` arithmetically evaluates an array subscript, " + "which can execute a command substitution even when quoted" + ) + elif accesses_proc_environ(command): + semantics_reason = ( + "Accesses /proc/*/environ, which may expose environment " + "secrets of another process" + ) + if parse_reason is not None: + exact_rule = _exact_allow_rule(perm_ctx, command) + if exact_rule is not None: + from src.permissions.types import RuleDecisionReason + + return PermissionAllowDecision( + behavior="allow", + updated_input=tool_input, + decision_reason=RuleDecisionReason(rule=exact_rule), + ) + if parse_reason is not None or semantics_reason is not None: + return PermissionAskDecision( + behavior="ask", + message=( + "This command requires confirmation: " + f"{parse_reason or semantics_reason}" + ), + decision_reason=SafetyCheckDecisionReason( + reason=parse_reason or semantics_reason, + classifier_approvable=True, + ), + suggestions=(), # never suggest saving an unanalyzable command + ) + + # 2. acceptEdits mode: filesystem writes + read-only commands auto-allow. + if perm_ctx is not None and getattr(perm_ctx, "mode", None) == "acceptEdits": + accept_edits = check_accept_edits_bash( + command, cwd=cwd_str, allowed_roots=allowed_roots + ) + if accept_edits is True: + from src.permissions.types import ModeDecisionReason + + return PermissionAllowDecision( + behavior="allow", + updated_input=tool_input, + decision_reason=ModeDecisionReason(mode="acceptEdits"), + ) + if isinstance(accept_edits, PermissionAskDecision): + return accept_edits # dangerous rm/rmdir target — always surface + + # 2.5. A filesystem-write command aimed at a DANGEROUS-removal target + # (`/`, `~`, a direct child of `/`) or OUTSIDE the workspace can never be + # auto-allowed — not by acceptEdits, not by a Bash(rm:*) rule. Surface a + # SafetyCheck ask with NO suggestions (mirrors TS checkPathConstraints / + # checkDangerousRemovalPaths, which "cannot be auto-allowed by permission + # rules" and never suggests saving the command). Returning it here — as a + # SafetyCheck ask — makes it preempt the content-rule allow in check.py + # (the safetyCheck coercion runs before rule matching), so a saved + # ``Bash(rm:*)`` still can't reach ``rm -rf ~``. + from src.permissions.bash_mode_validation import ( + rule_allow_path_gate, + ACCEPT_EDITS_WRITE_COMMANDS, + ) + from src.permissions.bash_suggestions import ( + contains_unquoted_chaining, + split_chained_command, + ) + + write_legs = [command] + if contains_unquoted_chaining(command): + _subs = split_chained_command(command) + write_legs = _subs if _subs is not None else [command] + for _leg in write_legs: + _tok = _leg.strip().split(None, 1)[0] if _leg.strip() else "" + _base = _os_mod.path.basename(_tok) + if _base in ACCEPT_EDITS_WRITE_COMMANDS and not rule_allow_path_gate( + _leg, cwd=cwd_str, allowed_roots=allowed_roots + ): + return PermissionAskDecision( + behavior="ask", + message=( + f"This command targets a protected or out-of-workspace " + f"path and requires confirmation: {_leg.strip()}" + ), + decision_reason=SafetyCheckDecisionReason( + reason="Write to a dangerous or out-of-workspace path", + classifier_approvable=False, + ), + suggestions=(), + ) + # 3. Read-only auto-allow (no rule, no prompt — any mode). + if check_read_only_constraints( + command, cwd=cwd_str, allowed_roots=allowed_roots + ): + from src.permissions.types import OtherDecisionReason + + return PermissionAllowDecision( + behavior="allow", + updated_input=tool_input, + decision_reason=OtherDecisionReason( + reason="Read-only command is allowed", + ), + ) + + # 4. No verdict here — rules and the prompt flow decide. return PermissionPassthroughResult() +def _exact_allow_rule(perm_ctx: Any, command: str) -> Any | None: + """Raw exact-string allow rule for ``command``, or None. + + TS honors an exact-match allow even for commands its analyzers refuse to + reason about (bashPermissions.ts:2124-2131) — the user saved that literal + string on purpose. Only full-string equality qualifies. + """ + if perm_ctx is None: + return None + try: + from src.permissions.rules import get_rule_by_contents_for_tool + + stripped = command.strip() + for rule_content, rule in get_rule_by_contents_for_tool( + perm_ctx, "Bash", "allow" + ).items(): + if stripped and stripped == str(rule_content).strip(): + return rule + except Exception: + return None + return None + + def _bash_validate_input( tool_input: dict[str, Any], context: ToolContext, diff --git a/src/tool_system/tools/edit.py b/src/tool_system/tools/edit.py index 97c6d51fc..1dd14b6fc 100644 --- a/src/tool_system/tools/edit.py +++ b/src/tool_system/tools/edit.py @@ -16,10 +16,9 @@ unified_diff_hunks, ) from .read import _backfill_read_edit_path # shared file_path expander -from ..errors import ToolInputError, ToolPermissionError +from ..errors import ToolInputError from ..protocol import ToolResult from src.permissions.types import ( - PermissionAskDecision, PermissionPassthroughResult, PermissionResult, ) @@ -189,17 +188,14 @@ def _find_similar_file(file_path: str, cwd: Path) -> str | None: # -- Permissions --------------------------------------------------------------- def _check_permissions(tool_input: dict[str, Any], context: ToolContext) -> PermissionResult: - file_path = tool_input.get("file_path") - if not isinstance(file_path, str): - return PermissionPassthroughResult() - try: - path = context.ensure_allowed_path(file_path) - except ToolPermissionError: - return PermissionPassthroughResult() - if path.suffix.lower() in {".md", ".markdown"} and not context.allow_docs: - return PermissionAskDecision( - message="Editing documentation files is blocked unless allow_docs is enabled", - ) + # NB: no docs gate. The port used to raise an explicit ask for + # ``.md``/``.markdown`` edits unless ``allow_docs`` — the original Claude + # Code has no such permission gate (stray-docs discouragement lives in + # the system prompt), and being an explicit ask it was structurally + # un-grantable: no "allow all edits this session" option and immune to + # acceptEdits (both are passthrough-gated), so every markdown edit + # re-prompted forever. Markdown now flows like any other edit: prompt in + # default mode WITH the session option, auto-allow under acceptEdits. return PermissionPassthroughResult() diff --git a/src/tool_system/tools/web_fetch.py b/src/tool_system/tools/web_fetch.py index f40a9ab84..0cc35450d 100644 --- a/src/tool_system/tools/web_fetch.py +++ b/src/tool_system/tools/web_fetch.py @@ -415,7 +415,52 @@ def _is_preapproved(hostname: str, pathname: str) -> bool: # -- Permission Check ---------------------------------------------------------- +def _domain_rule_content(url: str) -> str | None: + """``https://docs.foo.com/x`` → ``domain:docs.foo.com`` (TS + webFetchToolInputToPermissionRuleContent, WebFetchTool.ts:66-79).""" + try: + hostname = urllib.parse.urlparse(url).hostname + except Exception: + return None + return f"domain:{hostname}" if hostname else None + + +def _domain_suggestions(rule_content: str) -> tuple: + """One ``addRules WebFetch(domain:) → localSettings`` update (TS + buildSuggestions, WebFetchTool.ts:346-355) — the "don't ask again" grant + is scoped to the host, not all of WebFetch.""" + from src.permissions.types import ( + PermissionRuleValue, + PermissionUpdateAddRules, + ) + + return ( + PermissionUpdateAddRules( + destination="localSettings", + behavior="allow", + rules=( + PermissionRuleValue( + tool_name="WebFetch", rule_content=rule_content + ), + ), + ), + ) + + def _check_permissions(tool_input: dict[str, Any], context: ToolContext) -> PermissionResult: + """TS-parity WebFetch permission stage (WebFetchTool.ts:118-195): + preapproved host → allow; then ``domain:`` deny/ask/allow rules; + else ask-by-passthrough carrying a domain-scoped suggestion. Previously + BOTH branches returned passthrough — the preapproved list was dead code + and every fetch prompted with only an all-of-WebFetch grant on offer.""" + from src.permissions.types import ( + PermissionAllowDecision, + PermissionAskDecision, + PermissionDenyDecision, + OtherDecisionReason, + RuleDecisionReason, + ) + url = tool_input.get("url", "") if not isinstance(url, str) or not url: return PermissionPassthroughResult() @@ -424,10 +469,57 @@ def _check_permissions(tool_input: dict[str, Any], context: ToolContext) -> Perm hostname = parsed.hostname or "" pathname = parsed.path or "/" if _is_preapproved(hostname, pathname): - return PermissionPassthroughResult() + return PermissionAllowDecision( + behavior="allow", + updated_input=tool_input, + decision_reason=OtherDecisionReason(reason="Preapproved host"), + ) except Exception: pass - return PermissionPassthroughResult() + + rule_content = _domain_rule_content(url) + if rule_content is None: + return PermissionPassthroughResult() + + perm_ctx = getattr(context, "permission_context", None) + if perm_ctx is not None: + try: + from src.permissions.rules import get_rule_by_contents_for_tool + + deny = get_rule_by_contents_for_tool(perm_ctx, "WebFetch", "deny").get( + rule_content + ) + if deny is not None: + return PermissionDenyDecision( + behavior="deny", + message=f"WebFetch denied access to {rule_content}.", + decision_reason=RuleDecisionReason(rule=deny), + ) + ask = get_rule_by_contents_for_tool(perm_ctx, "WebFetch", "ask").get( + rule_content + ) + if ask is not None: + return PermissionAskDecision( + behavior="ask", + message="WebFetch requires approval for this domain.", + decision_reason=RuleDecisionReason(rule=ask), + suggestions=_domain_suggestions(rule_content), + ) + allow = get_rule_by_contents_for_tool(perm_ctx, "WebFetch", "allow").get( + rule_content + ) + if allow is not None: + return PermissionAllowDecision( + behavior="allow", + updated_input=tool_input, + decision_reason=RuleDecisionReason(rule=allow), + ) + except Exception: + pass + + return PermissionPassthroughResult( + suggestions=_domain_suggestions(rule_content), + ) # -- Result Mapping ------------------------------------------------------------ diff --git a/src/tool_system/tools/write.py b/src/tool_system/tools/write.py index 348b475b8..4e729bdb9 100644 --- a/src/tool_system/tools/write.py +++ b/src/tool_system/tools/write.py @@ -15,7 +15,6 @@ ) from src.permissions.types import ( PermissionAllowDecision, - PermissionAskDecision, PermissionPassthroughResult, PermissionResult, ) @@ -157,21 +156,17 @@ def _check_permissions(tool_input: dict[str, Any], context: ToolContext) -> Perm return PermissionPassthroughResult() # Memory carve-out: writes inside the auto-memory directory bypass - # the workspace allowlist AND the docs gate. Without this, the model - # would prompt the user on every "save a memory" attempt. + # the workspace allowlist. Without this, the model would prompt the + # user on every "save a memory" attempt. if _is_auto_memory_write(file_path): return PermissionPassthroughResult() - # Path is already expanded by backfill_observable_input - try: - path = context.ensure_allowed_path(file_path) - except ToolPermissionError: - return PermissionPassthroughResult() - - if path.suffix.lower() in {".md", ".markdown"} and not context.allow_docs: - return PermissionAskDecision( - message="Writing documentation files is blocked unless allow_docs is enabled", - ) + # NB: no docs gate. The port used to raise an explicit ask for + # ``.md``/``.markdown`` writes unless ``allow_docs`` — the original + # Claude Code has no such permission gate, and being an explicit ask it + # was structurally un-grantable (no session option, immune to + # acceptEdits), so every markdown write re-prompted forever. Markdown + # now flows like any other write. return PermissionPassthroughResult() diff --git a/tests/integration/test_permission_integration.py b/tests/integration/test_permission_integration.py index cd8614a83..1e229fcbe 100644 --- a/tests/integration/test_permission_integration.py +++ b/tests/integration/test_permission_integration.py @@ -80,21 +80,40 @@ def test_protected_file_blocked(self) -> None: self.assertIsNotNone(result) self.assertEqual(result.behavior, "ask") - def test_env_file_blocked(self) -> None: + def test_env_file_not_blocked(self) -> None: + # Loosened to TS parity: .env is NOT in the original's DANGEROUS_FILES, + # so an in-repo .env auto-accepts under acceptEdits (was over-gated). result = check_path_safety_for_auto_edit("/project/.env") - self.assertIsNotNone(result) + self.assertIsNone(result) def test_git_dir_blocked(self) -> None: result = check_path_safety_for_auto_edit("/project/.git/config") self.assertIsNotNone(result) + def test_gitconfig_blocked(self) -> None: + # A file that IS in the original's DANGEROUS_FILES still asks. + result = check_path_safety_for_auto_edit("/project/.gitconfig") + self.assertIsNotNone(result) + def test_normal_source_allowed(self) -> None: result = check_path_safety_for_auto_edit("/project/src/main.py") self.assertIsNone(result) - def test_lockfile_blocked(self) -> None: + def test_lockfile_not_blocked(self) -> None: + # Lockfiles are not in the original's set either — auto-accept. result = check_path_safety_for_auto_edit("/project/package-lock.json") - self.assertIsNotNone(result) + self.assertIsNone(result) + + def test_worktree_path_not_blocked(self) -> None: + # .claude/worktrees/ is structural (git worktrees live there); edits + # inside a worktree must not hit the .claude protected-dir gate. + result = check_path_safety_for_auto_edit( + "/project/.claude/worktrees/feat/src/main.py" + ) + self.assertIsNone(result) + # ...but a real .claude config file is still protected. + nested = check_path_safety_for_auto_edit("/project/.claude/settings.json") + self.assertIsNotNone(nested) class TestPermissionRuleFlow(unittest.TestCase): diff --git a/tests/parity/test_snapshot_parity_r2.py b/tests/parity/test_snapshot_parity_r2.py index 4a92923ee..6507f9cf0 100644 --- a/tests/parity/test_snapshot_parity_r2.py +++ b/tests/parity/test_snapshot_parity_r2.py @@ -347,17 +347,19 @@ def test_safe_bash_permissions(self) -> None: def test_filesystem_safety_snapshot(self) -> None: from src.permissions.filesystem import check_path_safety_for_auto_edit - # Protected files require confirmation - protected = [".gitconfig", ".bashrc", ".env", "package-lock.json"] + # Protected files require confirmation (the original's DANGEROUS_FILES). + protected = [".gitconfig", ".bashrc", ".zshrc", ".mcp.json"] for f in protected: result = check_path_safety_for_auto_edit(f"/project/{f}") self.assertIsNotNone(result, f"Expected protection for: {f}") self.assertEqual(result.behavior, "ask") - # Normal files auto-allowed - normal = ["app.py", "README.md", "index.ts"] + # Auto-allowed: normal source AND the entries trimmed to match the + # original (.env / lockfiles are NOT gated by the original). + normal = ["src/app.py", "README.md", "src/index.ts", ".env", + "package-lock.json"] for f in normal: - result = check_path_safety_for_auto_edit(f"/project/src/{f}") + result = check_path_safety_for_auto_edit(f"/project/{f}") self.assertIsNone(result, f"Expected auto-allow for: {f}") def test_legacy_tool_name_normalization(self) -> None: diff --git a/tests/parity/test_structural_parity_r2.py b/tests/parity/test_structural_parity_r2.py index 29e3ce810..384216fb8 100644 --- a/tests/parity/test_structural_parity_r2.py +++ b/tests/parity/test_structural_parity_r2.py @@ -303,27 +303,55 @@ def test_dangerous_files_blocked(self) -> None: def test_dangerous_directories_blocked(self) -> None: from src.permissions.filesystem import check_path_safety_for_auto_edit - dirs = [".git", ".ssh", ".gnupg", ".config", ".vscode"] + # Trimmed to the original Claude Code's DANGEROUS_DIRECTORIES (plus this + # port's .clawcodex): .ssh/.gnupg/.config are NOT in the original's set + # (out-of-workspace paths never reach this acceptEdits gate anyway). + dirs = [".git", ".vscode", ".idea", ".claude", ".clawcodex"] for d in dirs: path = f"/home/user/{d}/some_file.txt" result = check_path_safety_for_auto_edit(path) self.assertIsNotNone(result, f"Expected protection for dir: {d}") - def test_env_files_blocked(self) -> None: + def test_trimmed_directories_not_blocked(self) -> None: from src.permissions.filesystem import check_path_safety_for_auto_edit - env_files = [".env", ".env.local", ".env.production"] - for f in env_files: + for d in (".ssh", ".gnupg", ".config"): + path = f"/project/{d}/some_file.txt" + self.assertIsNone( + check_path_safety_for_auto_edit(path), + f"{d} is no longer gated (TS parity)", + ) + + def test_worktree_carveout(self) -> None: + from src.permissions.filesystem import check_path_safety_for_auto_edit + # .claude/worktrees/ is structural — edits inside a worktree pass. + self.assertIsNone( + check_path_safety_for_auto_edit( + "/project/.claude/worktrees/feat/src/main.py" + ) + ) + # A real .claude config file is still protected. + self.assertIsNotNone( + check_path_safety_for_auto_edit("/project/.claude/settings.json") + ) + + def test_env_files_not_blocked(self) -> None: + from src.permissions.filesystem import check_path_safety_for_auto_edit + # .env* is NOT in the original's DANGEROUS_FILES — auto-accept. + for f in (".env", ".env.local", ".env.production"): path = f"/project/{f}" - result = check_path_safety_for_auto_edit(path) - self.assertIsNotNone(result, f"Expected protection for: {f}") + self.assertIsNone( + check_path_safety_for_auto_edit(path), + f"{f} is no longer gated (TS parity)", + ) - def test_lockfiles_blocked(self) -> None: + def test_lockfiles_not_blocked(self) -> None: from src.permissions.filesystem import check_path_safety_for_auto_edit - lockfiles = ["package-lock.json", "yarn.lock", "poetry.lock"] - for f in lockfiles: + for f in ("package-lock.json", "yarn.lock", "poetry.lock"): path = f"/project/{f}" - result = check_path_safety_for_auto_edit(path) - self.assertIsNotNone(result, f"Expected protection for: {f}") + self.assertIsNone( + check_path_safety_for_auto_edit(path), + f"{f} is no longer gated (TS parity)", + ) def test_normal_files_allowed(self) -> None: from src.permissions.filesystem import check_path_safety_for_auto_edit diff --git a/tests/test_bash_suggestions.py b/tests/test_bash_suggestions.py index d31c026a2..d1ce7cc0a 100644 --- a/tests/test_bash_suggestions.py +++ b/tests/test_bash_suggestions.py @@ -10,7 +10,9 @@ import unittest from src.permissions.bash_suggestions import ( - SAFE_PREFIX_COMMANDS, + BARE_SHELL_PREFIXES, + EVAL_LIKE_BUILTINS, + NEVER_PREFIX_COMMANDS, get_safe_first_word_prefix, get_simple_command_prefix, suggestions_for_bash_command, @@ -61,17 +63,25 @@ def test_prefix_rule_for_subcommand(self) -> None: self.assertEqual(rule.tool_name, "Bash") self.assertEqual(rule.rule_content, "git diff:*") - def test_exact_rule_when_no_prefix(self) -> None: - # No 2-word prefix and not a safe-to-generalize command → exact rule. - # (NB: a safe read-only command like `ls -la` now yields `ls:*` — see - # TestFirstWordPrefixSuggestion.) + def test_first_word_generalizes_when_no_two_word_prefix(self) -> None: + # `mv old.txt new.txt` has no 2-word prefix (2nd token is a path), so + # the first-word rung now generalizes to `mv:*` (TS getFirstWordPrefix + # parity; the rule-allow path gate keeps `mv:*` workspace-contained). rule = _only_rule(suggestions_for_bash_command("mv old.txt new.txt")) - self.assertEqual(rule.rule_content, "mv old.txt new.txt") - - def test_heredoc_uses_prefix_before_operator(self) -> None: + self.assertEqual(rule.rule_content, "mv:*") + + def test_exact_rule_for_ungeneralizable_first_word(self) -> None: + # A path-form first token can't generalize (not a bare command name) + # → exact rule fallback. + rule = _only_rule(suggestions_for_bash_command("./run.sh --once")) + self.assertEqual(rule.rule_content, "./run.sh --once") + + def test_substitution_heredoc_yields_no_suggestion(self) -> None: + # A command with executable substitution ($(...)) is injection-suspect; + # like TS's too-complex path it carries NO savable suggestion (was: the + # port's heredoc special-case minted `git commit:*`). cmd = 'git commit -m "$(cat <<\'EOF\'\nmsg\nEOF\n)"' - rule = _only_rule(suggestions_for_bash_command(cmd)) - self.assertEqual(rule.rule_content, "git commit:*") + self.assertEqual(suggestions_for_bash_command(cmd), []) def test_heredoc_bare_shell_yields_nothing(self) -> None: # Deliberate divergence from TS: Bash(bash:*) ≈ Bash(*). @@ -254,19 +264,28 @@ def test_exact_prefix_suffix_branch(self) -> None: class TestGetSafeFirstWordPrefix(unittest.TestCase): - def test_safe_command_returns_first_word(self) -> None: - self.assertEqual(get_safe_first_word_prefix("ls demos/"), "ls") - self.assertEqual(get_safe_first_word_prefix("cat a/b/c.txt"), "cat") - self.assertEqual(get_safe_first_word_prefix("grep -r foo ."), "grep") - - def test_unsafe_command_returns_none(self) -> None: - # Commands that write/exec via their own args — must NOT generalize. - # Includes the write-via-output-arg trio (xxd/base64/info) a reviewer - # caught in the first cut. - for cmd in ("find . -name x", "sort -o /etc/x f", "tee /etc/x", - "cp a /b", "mv a /b", "dd if=/dev/zero of=/x", "rm x", - "xxd -r p.hex /victim", "base64 -d -i x -o /victim", - "info --output=/victim coreutils"): + # Behavior CHANGED (loosen-permissions): the first-word rung is now a + # faithful port of TS getFirstWordPrefix — it generalizes ANY bare command + # name except bare shells/wrappers and eval-like builtins, because the TUI + # approval box has an editable rule field (round 6). The prior read-only + # allowlist (SAFE_PREFIX_COMMANDS) was deleted; it forced everyday dev + # tools (pytest, ruff, find) to re-prompt on every arg. + def test_generalizes_bare_command_names(self) -> None: + for cmd, want in ( + ("ls demos/", "ls"), + ("cat a/b/c.txt", "cat"), + ("grep -r foo .", "grep"), + ("pytest -q", "pytest"), + ("ruff check .", "ruff"), + ("find . -name x", "find"), + ("sort -o out f", "sort"), + ): + self.assertEqual(get_safe_first_word_prefix(cmd), want, cmd) + + def test_bare_shells_and_eval_like_return_none(self) -> None: + for cmd in ("bash -c ls", "sh x", "env ls", "xargs rm", "sudo ls", + "eval x", "source setup.sh", "trap 'x' EXIT", + "command ls", "let y=1"): self.assertIsNone(get_safe_first_word_prefix(cmd), cmd) def test_path_or_flag_first_token_returns_none(self) -> None: @@ -280,12 +299,13 @@ def test_unsafe_env_var_returns_none(self) -> None: def test_safe_env_var_skipped(self) -> None: self.assertEqual(get_safe_first_word_prefix("NO_COLOR=1 ls /"), "ls") - def test_no_shells_or_wrappers_in_safe_set(self) -> None: - for bad in ("bash", "sh", "env", "xargs", "sudo", "find", "fd", - "sort", "uniq", "tee", "cp", "mv", "rm", "dd", "git", - "sed", "awk", "command", "date", "npm", "curl", - "xxd", "base64", "info"): - self.assertNotIn(bad, SAFE_PREFIX_COMMANDS, bad) + def test_never_prefix_covers_shells_and_eval_like(self) -> None: + # The refusal set = bare shells/wrappers ∪ eval-like builtins. + self.assertTrue(BARE_SHELL_PREFIXES <= NEVER_PREFIX_COMMANDS) + self.assertTrue(EVAL_LIKE_BUILTINS <= NEVER_PREFIX_COMMANDS) + for bad in ("bash", "sh", "env", "xargs", "sudo", "eval", "source", + "command", "builtin", "trap", "let", "hash"): + self.assertIn(bad, NEVER_PREFIX_COMMANDS, bad) class TestFirstWordPrefixSuggestion(unittest.TestCase): @@ -307,20 +327,25 @@ def test_reported_bug_one_grant_covers_sibling_paths(self) -> None: matcher = prepare_permission_matcher(rule) self.assertTrue(matcher("ls /Users/x/workspace/demos/elon-blog/")) - def test_dangerous_command_not_generalized_to_bare_prefix(self) -> None: - # Must never auto-suggest Bash(find:*)/Bash(sort:*)/Bash(xxd:*)/etc. - for cmd in ("find . -name x", "sort -o /etc/x f", "tee /etc/x", - "xxd -r p.hex /victim", "base64 -d -i x -o /victim", - "info --output=/victim coreutils"): - self.assertNotEqual(self._rule(cmd), f"{cmd.split()[0]}:*", cmd) + def test_everyday_dev_tools_generalize(self) -> None: + # The core UX win: one grant covers all future args (TS parity). + self.assertEqual(self._rule("pytest -k foo"), "pytest:*") + # `ruff check src` HAS a 2-word prefix (check is a subcommand) → the + # tighter `ruff check:*` wins over the first-word rung. + self.assertEqual(self._rule("ruff check src"), "ruff check:*") + self.assertEqual(self._rule("find . -name x"), "find:*") + + def test_eval_like_and_substitution_never_suggested(self) -> None: + # Guardrail: eval-like builtins and substitution-bearing commands get + # NO savable suggestion (they ask through the structural path). + for cmd in ('eval "x"', "source s.sh", "trap 'rm -rf /' EXIT", + 'echo "$(date)"', "cat `whoami`"): + self.assertEqual(suggestions_for_bash_command(cmd), [], cmd) def test_two_word_prefix_still_wins(self) -> None: # The 2-word prefix path is unchanged (takes precedence). self.assertEqual(self._rule("git status"), "git status:*") - def test_unsafe_command_falls_back_to_exact(self) -> None: - self.assertEqual(self._rule("find . -name x"), "find . -name x") - if __name__ == "__main__": unittest.main() diff --git a/tests/test_loosen_permission_guardrails.py b/tests/test_loosen_permission_guardrails.py new file mode 100644 index 000000000..25386f221 --- /dev/null +++ b/tests/test_loosen_permission_guardrails.py @@ -0,0 +1,325 @@ +"""Guardrail tests for the permission-loosening rework. + +The loosening lets saved rules fire (no more un-grantable class asks) and +auto-allows read-only commands — these tests pin the boundaries that must NOT +have loosened with it (design-critic blockers #1 and #2): + +1. eval-like builtins (TS EVAL_LIKE_BUILTINS): always ask, empty suggestions, + never minted as a rule, never honored via a prefix rule — only a raw + exact-string allow fires (TS checkEarlyExitDeny honors exact allows on the + semantics path). +2. Rule-allowed path-write commands stay contained: ``Bash(rm:*)`` must not + auto-run ``rm -rf ~`` / out-of-workspace targets (TS runs + checkPathConstraints BEFORE allow rules). +""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from src.permissions.bash_suggestions import suggestions_for_bash_command +from src.permissions.check import has_permissions_to_use_tool_inner +from src.permissions.types import ToolPermissionContext +from src.tool_system.tools.bash.bash_tool import BashTool + + +class _ToolUseContext: + """Minimal stand-in for ToolContext: cwd + allowed_roots + perm context.""" + + def __init__(self, root: str, perm: ToolPermissionContext) -> None: + self.cwd = root + self._root = root + self.permission_context = perm + + def allowed_roots(self): + return [self._root] + + +def _ctx(allow=(), mode="default"): + return ToolPermissionContext( + mode=mode, + always_allow_rules={"session": [f"Bash({r})" for r in allow]}, + ) + + +class _Base(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = str(Path(self.tmp.name).resolve()) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def decide(self, command: str, allow=(), mode="default"): + perm = _ctx(allow=allow, mode=mode) + return has_permissions_to_use_tool_inner( + BashTool, + {"command": command}, + perm, + tool_use_context=_ToolUseContext(self.root, perm), + ) + + +class TestEvalLikeBuiltins(_Base): + def test_eval_asks_with_empty_suggestions(self) -> None: + decision = self.decide('eval "ls -la"') + self.assertEqual(decision.behavior, "ask") + self.assertFalse(getattr(decision, "suggestions", None)) + + def test_eval_prefix_rule_never_fires(self) -> None: + # Even a (hand-written) Bash(eval:*) rule must not auto-allow: the + # structural ask precedes content-rule matching, as in TS where + # checkSemantics precedes the allow rules. + decision = self.decide('eval "rm -rf /"', allow=["eval:*"]) + self.assertEqual(decision.behavior, "ask") + + def test_eval_exact_allow_does_not_fire(self) -> None: + # TS checkSemanticsDeny honors ONLY deny rules on the semantics path — + # never an allow. Even an exact ``Bash(eval "ls")`` rule must not run + # eval (the arguments are code; a static allow can't vouch for them). + decision = self.decide('eval "ls"', allow=['eval "ls"']) + self.assertEqual(decision.behavior, "ask") + + def test_no_suggestion_minted_for_eval_like(self) -> None: + for cmd in ('eval "x"', "source setup.sh", "trap 'rm -rf /' EXIT", + "command rm -rf /", "hash -p /tmp/evil ls", + "let 'x=a[$(id)]'"): + self.assertEqual(suggestions_for_bash_command(cmd), [], cmd) + + def test_zsh_dangerous_builtins_refused(self) -> None: + # TS ZSH_DANGEROUS_BUILTINS — refused like eval-like builtins (parity + # + defense-in-depth if a zsh path ever becomes reachable). + for cmd, rule in ( + ("zmodload zsh/system", "zmodload:*"), + ("zf_rm -rf x", "zf_rm:*"), + ("sysopen -w -o creat -u 3 /etc/y", "sysopen:*"), + ("emulate sh -c x", "emulate:*"), + ): + self.assertEqual(self.decide(cmd, allow=[rule]).behavior, "ask", cmd) + self.assertEqual(suggestions_for_bash_command(cmd), [], cmd) + + def test_wrapper_hidden_eval_still_asks(self) -> None: + decision = self.decide('nohup eval "ls"') + self.assertEqual(decision.behavior, "ask") + self.assertFalse(getattr(decision, "suggestions", None)) + + def test_compound_with_eval_leg_asks(self) -> None: + decision = self.decide('ls && eval "x"', allow=["ls:*", "eval:*"]) + self.assertEqual(decision.behavior, "ask") + + def test_name_eval_subscript_attack_blocked(self) -> None: + # `printf -v 'a[$(id)]'` etc. arithmetically evaluate the array + # subscript → run $(id) even single-quoted. Must ask under any grant, + # mint no suggestion, and NOT run via an exact rule (TS checkSemantics + # is deny-only). + for cmd, allow in ( + ("printf -v 'a[$(id)]' x", ["printf:*"]), + ("test -v 'a[$(id)]'", ["test:*"]), + ("[[ 'a[$(id)]' -eq 0 ]]", ["[[:*"]), + ("read -a 'arr[$(id)]'", ["read:*"]), + ("unset 'a[`id`]'", ["unset:*"]), + ("wait -p 'a[$(id)]'", ["wait:*"]), + ("FOO=1 printf -v 'a[$(id)]' x", ["printf:*"]), + ("printf -v 'a[$(id)]' x", ["printf -v 'a[$(id)]' x"]), # exact + ): + self.assertEqual(self.decide(cmd, allow=allow).behavior, "ask", cmd) + from src.permissions.bash_suggestions import suggestions_for_bash_command + self.assertEqual(suggestions_for_bash_command("printf -v 'a[$(id)]' x"), []) + + def test_name_eval_builtins_benign_uses_work(self) -> None: + for cmd, allow in ( + ("printf '%s' hello", ["printf:*"]), + ("printf -v myvar hello", ["printf:*"]), + ("test -f foo.txt", ["test:*"]), + ("read var", ["read:*"]), + ): + self.assertEqual(self.decide(cmd, allow=allow).behavior, "allow", cmd) + + def test_exact_allow_honored_for_parse_refusal_not_semantics(self) -> None: + # Too-complex/substitution: an exact rule is honored (checkEarlyExitDeny + # allows exact). Eval-like/subscript: NEVER honored (checkSemanticsDeny). + self.assertEqual( + self.decide("a=$(date); echo $a", allow=["a=$(date); echo $a"]).behavior, + "allow", + ) + self.assertEqual( + self.decide('eval "ls"', allow=['eval "ls"']).behavior, "ask" + ) + + def test_proc_environ_exfil_blocked(self) -> None: + # Reading /proc/*/environ leaks another process's env (secrets); TS + # checkSemantics refuses it regardless of rules. Must ask in every + # form — redirect, `..` traversal, backslash evasion, exact rule, + # allow-all — and mint no suggestion. + for cmd, allow in ( + ("cat /proc/self/environ", ["cat:*"]), + ("cat /proc/1234/environ", ["cat:*"]), + ("cat < /proc/self/environ", ["cat:*"]), + ("cat /proc/self/../self/environ", ["cat:*"]), + (r"cat /proc/self/\environ", ["cat:*"]), + ("cat /proc/self/environ", ["cat /proc/self/environ"]), + ("head /proc/self/environ", ["*"]), + ): + self.assertEqual(self.decide(cmd, allow=allow).behavior, "ask", cmd) + from src.permissions.bash_suggestions import suggestions_for_bash_command + self.assertEqual( + suggestions_for_bash_command("cat /proc/self/environ"), [] + ) + + def test_path_binary_named_eval_is_not_the_builtin(self) -> None: + # ./eval is a user binary, not the shell builtin — normal flow (it + # still prompts here because there is no rule and it's not read-only). + decision = self.decide("./eval --version") + self.assertEqual(decision.behavior, "ask") + # ...but it is not the structural eval refusal: suggestions exist. + self.assertTrue(getattr(decision, "suggestions", None)) + + +class TestWriteCommandRuleGate(_Base): + # A matched Bash write-command allow (``Bash(rm:*)`` / ``Bash(*)``) is + # path-gated exactly as TS runs checkPathConstraints before the allow rule: + # a DANGEROUS-removal target (`/`, `~`, direct child of `/`) or an + # OUT-OF-WORKSPACE path can never auto-run — those still prompt. + # DOCUMENTED DEVIATION (design-review sanctioned): an in-workspace, + # non-critical target IS honored under an explicit grant. + def test_rm_rule_blocked_on_dangerous_targets(self) -> None: + for cmd in ("rm -rf ~", "rm -rf /", "rm -rf /etc"): + decision = self.decide(cmd, allow=["rm:*"]) + self.assertEqual(decision.behavior, "ask", cmd) + + def test_rm_rule_blocked_outside_workspace(self) -> None: + with tempfile.TemporaryDirectory() as other: + decision = self.decide(f"rm -rf {other}/x", allow=["rm:*"]) + self.assertEqual(decision.behavior, "ask") + + def test_rm_rule_allowed_inside_workspace(self) -> None: + # Documented deviation: explicit Bash(rm:*) fires for in-workspace, + # non-critical targets. + decision = self.decide("rm -rf build", allow=["rm:*"]) + self.assertEqual(decision.behavior, "allow") + + def test_mv_rule_blocked_on_exfil_target(self) -> None: + with tempfile.TemporaryDirectory() as other: + decision = self.decide( + f"mv secret.txt {other}/exfil", allow=["mv:*"] + ) + self.assertEqual(decision.behavior, "ask") + + def test_mv_rule_allowed_inside_workspace(self) -> None: + decision = self.decide("mv a.txt b.txt", allow=["mv:*"]) + self.assertEqual(decision.behavior, "allow") + + def test_exact_write_rule_also_path_gated(self) -> None: + # Even an exact rule can't auto-run a write on a dangerous path (TS + # honors exact allows AFTER checkPathConstraints). + decision = self.decide("rm -rf /etc/foo", allow=["rm -rf /etc/foo"]) + self.assertEqual(decision.behavior, "ask") + + def test_bare_bash_grant_still_path_gates_writes(self) -> None: + # Content-less Bash (allow-all) is path-gated for writes too (TS: Bash(*) + # runs checkPathConstraints). In-workspace write runs; dangerous does not. + self.assertEqual(self.decide("rm -rf build", allow=["*"]).behavior, "allow") + self.assertEqual(self.decide("rm -rf ~", allow=["*"]).behavior, "ask") + + def test_bare_bash_compound_dangerous_leg_blocked(self) -> None: + decision = self.decide("echo hi && rm -rf ~", allow=["*"]) + self.assertEqual(decision.behavior, "ask") + + def test_compound_rm_leg_blocked_on_dangerous_target(self) -> None: + decision = self.decide("ls && rm -rf ~", allow=["ls:*", "rm:*"]) + self.assertEqual(decision.behavior, "ask") + + def test_write_with_unresolvable_target_fails_closed(self) -> None: + # A write whose target is a runtime expansion ($VAR / ${VAR} / $() / + # glob) can't be statically contained — it must fail closed, even under + # an explicit grant and even in acceptEdits, in ALL forms. + for cmd, allow, mode in ( + ("rm -rf $HOME", ["rm:*"], "default"), + ("rm -rf ${HOME}", ["rm:*"], "default"), + ("rm -rf $HOME", ["*"], "default"), + ("rm -rf *", ["rm:*"], "default"), + ("mv a ${OUT}", ["mv:*"], "default"), + ("cp x $DEST", ["cp:*"], "default"), + ("rm -rf ${HOME}", [], "acceptEdits"), + ("rm -rf $HOME", [], "acceptEdits"), + ): + self.assertEqual( + self.decide(cmd, allow=allow, mode=mode).behavior, "ask", + f"{cmd} [{mode}]", + ) + + def test_output_redirect_outside_workspace_blocked(self) -> None: + # An output redirect can write ANY command's stdout out of the + # workspace; even a read-command grant (echo:*/cat:*) must not let it + # escape (TS gates redirect targets). + for cmd, allow in ( + ("echo x > /etc/y", ["echo:*"]), + ("echo x >> /etc/passwd", ["echo:*"]), + ("echo x > ../out", ["echo:*"]), + ("echo x > $HOME/f", ["echo:*"]), + ("cat f > /etc/y", ["cat:*"]), + ("echo x > /etc/y", ["*"]), # allow-all too + ): + self.assertEqual(self.decide(cmd, allow=allow).behavior, "ask", cmd) + + def test_in_workspace_redirect_and_dev_null_allowed(self) -> None: + for cmd, allow in ( + ("echo x > local.txt", ["echo:*"]), + ("echo x > sub/out.txt", ["echo:*"]), + ("grep x f 2>/dev/null", ["grep:*"]), + ("cat f > /dev/null", ["cat:*"]), + ): + self.assertEqual(self.decide(cmd, allow=allow).behavior, "allow", cmd) + + def test_amp_redirect_to_file_gated_but_fd_dup_allowed(self) -> None: + # `>&file` redirects BOTH streams to a FILE (must gate out-of-roots); + # `>&` / `>&-` / `2>&1` are fd dup/close (not a file write). + for cmd in ("echo x >&/etc/y", "echo x >& /etc/y", "echo x >&../out"): + self.assertEqual(self.decide(cmd, allow=["echo:*"]).behavior, "ask", cmd) + for cmd in ("echo x >&2", "echo x >&-", "grep p f 2>&1", "echo x >&local.txt"): + self.assertEqual( + self.decide(cmd, allow=["echo:*", "grep:*"]).behavior, "allow", cmd + ) + + def test_non_write_commands_fire_normally(self) -> None: + decision = self.decide("git push origin main", allow=["git push:*"]) + self.assertEqual(decision.behavior, "allow") + + +class TestGrantableEverydayTools(_Base): + """The actual loosening: rules fire and suggestions exist for the tools + that used to be un-grantable class asks.""" + + def test_pytest_prompt_carries_prefix_suggestion(self) -> None: + decision = self.decide("pytest -q") + self.assertEqual(decision.behavior, "ask") + suggestions = list(getattr(decision, "suggestions", None) or ()) + self.assertTrue(suggestions) + rule = suggestions[0].rules[0] + self.assertEqual(rule.rule_content, "pytest:*") + + def test_saved_rule_fires_for_dangerous_class(self) -> None: + for cmd, rule in ( + ("git push origin main", "git push:*"), + ("python x.py", "python:*"), + ("npm run lint", "npm run:*"), + ("pytest -k foo", "pytest:*"), + ): + decision = self.decide(cmd, allow=[rule]) + self.assertEqual(decision.behavior, "allow", cmd) + + def test_read_only_auto_allows_without_rule(self) -> None: + for cmd in ("ls -la", "git status", "git diff", "pwd", + "git status && ls"): + decision = self.decide(cmd) + self.assertEqual(decision.behavior, "allow", cmd) + + def test_mixed_rule_plus_readonly_compound(self) -> None: + decision = self.decide("pytest -q && git status", allow=["pytest:*"]) + self.assertEqual(decision.behavior, "allow") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_permission_session_options.py b/tests/test_permission_session_options.py index 577c74753..5dff2498e 100644 --- a/tests/test_permission_session_options.py +++ b/tests/test_permission_session_options.py @@ -194,12 +194,32 @@ def test_grep_outside_roots_grants_the_path_itself(self) -> None: self.assertEqual(updates[0].rules[0].tool_name, "Grep") self.assertEqual(updates[1].directories, ("/other/sub",)) - def test_other_tool_yields_persisted_content_less_rule(self) -> None: - updates = default_session_suggestions("WebFetch", {"url": "https://x"}) + def test_webfetch_yields_domain_scoped_rule(self) -> None: + # WebFetch now grants the HOST (TS WebFetchTool.ts:346), not all of + # WebFetch — the "always" option persists WebFetch(domain:). + updates = default_session_suggestions( + "WebFetch", {"url": "https://docs.python.org/3/"} + ) self.assertEqual(len(updates), 1) self.assertIsInstance(updates[0], PermissionUpdateAddRules) self.assertEqual(updates[0].destination, "localSettings") self.assertEqual(updates[0].rules[0].tool_name, "WebFetch") + self.assertEqual( + updates[0].rules[0].rule_content, "domain:docs.python.org" + ) + + def test_webfetch_unparseable_url_falls_back_to_content_less(self) -> None: + updates = default_session_suggestions("WebFetch", {"url": ""}) + self.assertEqual(len(updates), 1) + self.assertEqual(updates[0].rules[0].tool_name, "WebFetch") + self.assertIsNone(updates[0].rules[0].rule_content) + + def test_generic_other_tool_yields_content_less_rule(self) -> None: + updates = default_session_suggestions("SomeMcpTool", {}) + self.assertEqual(len(updates), 1) + self.assertIsInstance(updates[0], PermissionUpdateAddRules) + self.assertEqual(updates[0].destination, "localSettings") + self.assertEqual(updates[0].rules[0].tool_name, "SomeMcpTool") self.assertIsNone(updates[0].rules[0].rule_content) def test_interaction_tools_have_no_session_option(self) -> None: @@ -318,10 +338,19 @@ def test_bash_label_uses_dont_ask_again(self) -> None: self.assertTrue(label.startswith("and don't ask again for")) self.assertIn("Bash(git diff", label) - def test_other_tool_label_uses_dont_ask_again(self) -> None: - suggestions = default_session_suggestions("WebFetch", {"url": "https://x"}) + def test_webfetch_label_names_the_domain(self) -> None: + suggestions = default_session_suggestions( + "WebFetch", {"url": "https://docs.python.org/3/"} + ) label = session_option_label(tuple(suggestions), "WebFetch") - self.assertEqual(label, "and don't ask again for WebFetch") + self.assertEqual( + label, "and don't ask again for WebFetch(domain:docs.python.org)" + ) + + def test_generic_other_tool_label_uses_dont_ask_again(self) -> None: + suggestions = default_session_suggestions("SomeMcpTool", {}) + label = session_option_label(tuple(suggestions), "SomeMcpTool") + self.assertEqual(label, "and don't ask again for SomeMcpTool") # -------------------------------------------------------------------------- @@ -359,12 +388,25 @@ def test_notebook_edit_path_key_is_honored(self) -> None: self.assertEqual(decision.behavior, "allow") def test_dangerous_file_inside_roots_still_asks(self) -> None: + # .env / lockfiles are no longer gated (TS parity — trimmed the + # over-broad safety set); a genuinely protected config file (.gitconfig, + # in the original's DANGEROUS_FILES) still asks in acceptEdits. decision = self._check( _MockTool(name="Write"), - {"file_path": os.path.join(self.root, ".env"), "content": "x"}, + {"file_path": os.path.join(self.root, ".gitconfig"), "content": "x"}, ) self.assertEqual(decision.behavior, "ask") + def test_env_and_lockfile_inside_roots_now_auto_allow(self) -> None: + # Loosened to match the original: after opting into acceptEdits, an + # in-repo .env / lockfile / Makefile auto-accepts instead of prompting. + for name in (".env", "package-lock.json", "Makefile"): + decision = self._check( + _MockTool(name="Write"), + {"file_path": os.path.join(self.root, name), "content": "x"}, + ) + self.assertEqual(decision.behavior, "allow", name) + def test_edit_outside_roots_still_asks(self) -> None: decision = self._check( _MockTool(name="Write"), diff --git a/tests/test_permissions.py b/tests/test_permissions.py index d4d00b361..5f74d1529 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -61,13 +61,18 @@ def test_check_permissions_passthrough_for_regular_file(self) -> None: ) self.assertEqual(result.behavior, "passthrough") - def test_check_permissions_ask_for_md_file_when_docs_disallowed(self) -> None: - result = WriteTool.check_permissions( - {"file_path": str(self.root / "test.md"), "content": "hello"}, - self.ctx, - ) - self.assertEqual(result.behavior, "ask") - self.assertIn("allow_docs", result.message.lower()) + def test_check_permissions_passthrough_for_md_file(self) -> None: + # Docs gate REMOVED (loosen-permissions): the original Claude Code has + # no markdown permission gate, and the port's explicit ask was + # structurally un-grantable (no session option, immune to acceptEdits) + # → every .md write re-prompted forever. Markdown now flows like any + # other write: prompt in default mode WITH the session option. + for name in ("test.md", "test.markdown"): + result = WriteTool.check_permissions( + {"file_path": str(self.root / name), "content": "hello"}, + self.ctx, + ) + self.assertEqual(result.behavior, "passthrough", name) def test_check_permissions_passthrough_for_md_file_when_docs_allowed(self) -> None: self.ctx.allow_docs = True @@ -77,13 +82,6 @@ def test_check_permissions_passthrough_for_md_file_when_docs_allowed(self) -> No ) self.assertEqual(result.behavior, "passthrough") - def test_check_permissions_ask_for_markdown_file(self) -> None: - result = WriteTool.check_permissions( - {"file_path": str(self.root / "test.markdown"), "content": "hello"}, - self.ctx, - ) - self.assertEqual(result.behavior, "ask") - class TestEditToolPermissions(unittest.TestCase): def setUp(self) -> None: @@ -104,13 +102,13 @@ def test_check_permissions_passthrough_for_regular_file(self) -> None: ) self.assertEqual(result.behavior, "passthrough") - def test_check_permissions_ask_for_md_file_when_docs_disallowed(self) -> None: + def test_check_permissions_passthrough_for_md_file(self) -> None: + # Docs gate removed — see TestWriteToolPermissions for rationale. result = EditTool.check_permissions( {"file_path": str(self.test_file), "old_string": "original", "new_string": "modified"}, self.ctx, ) - self.assertEqual(result.behavior, "ask") - self.assertIn("allow_docs", result.message.lower()) + self.assertEqual(result.behavior, "passthrough") def test_check_permissions_passthrough_for_md_file_when_docs_allowed(self) -> None: self.ctx.allow_docs = True @@ -147,17 +145,20 @@ def test_dispatch_allows_regular_file_with_handler(self) -> None: self.assertFalse(result.is_error) self.assertEqual(result.output.get("type"), "create") - def test_dispatch_denies_md_file_without_handler(self) -> None: - result = self.registry.dispatch( + def test_dispatch_md_file_asks_like_any_write(self) -> None: + # Docs gate removed: a .md write goes through the ORDINARY ask flow + # (here: no handler + prompts unavailable is not simulated, so the + # ask surfaces as a deny from the missing handler), identical to a + # .txt write without a handler — not a special docs denial. + md = self.registry.dispatch( ToolCall(name="Write", input={"file_path": str(self.root / "test.md"), "content": "hello"}), self.ctx, ) - self.assertTrue(result.is_error) - error_msg = result.output.get("error", "").lower() - self.assertTrue( - "permission" in error_msg or "allow_docs" in error_msg or "blocked" in error_msg or "denied" in error_msg, - f"Expected permission-related error, got: {error_msg}", + txt = self.registry.dispatch( + ToolCall(name="Write", input={"file_path": str(self.root / "test.txt"), "content": "hello"}), + self.ctx, ) + self.assertEqual(md.is_error, txt.is_error) def test_dispatch_calls_permission_handler_for_ask(self) -> None: from src.permissions.types import PermissionAskReply diff --git a/tests/test_r6_compound_permissions.py b/tests/test_r6_compound_permissions.py index 0f6afbeb7..51191c59a 100644 --- a/tests/test_r6_compound_permissions.py +++ b/tests/test_r6_compound_permissions.py @@ -132,9 +132,10 @@ def test_user_pipeline_gets_bundled_rules(self): updates = suggestions_for_bash_command(USER_PIPELINE) self.assertEqual(len(updates), 1) # ONE addRules update (TS parity) contents = [r.rule_content for r in updates[0].rules] - # grep/tr are read-only-safe first words → prefix rules; sort is - # excluded from the safe set (sort -o writes) → exact; dedup applies. - self.assertEqual(contents, ["grep:*", "tr:*", "sort -u"]) + # Every first word now generalizes to a prefix rule (getFirstWordPrefix + # parity — the read-only SAFE_PREFIX allowlist was removed): grep/tr/ + # sort all become `:*`; dedup applies. + self.assertEqual(contents, ["grep:*", "tr:*", "sort:*"]) self.assertEqual(updates[0].destination, "localSettings") def test_cap_at_five_rules(self): @@ -143,11 +144,12 @@ def test_cap_at_five_rules(self): self.assertEqual(len(updates), 1) self.assertEqual(len(updates[0].rules), 5) - def test_splitter_refusal_falls_back_to_legacy(self): - # Command substitution → no split; legacy first-sub 2-word prefix. + def test_substitution_bearing_compound_yields_no_suggestion(self): + # A command with executable substitution ($(...)) is injection-suspect; + # like TS's too-complex path it carries NO savable suggestion (was: the + # port's legacy fallback minted `git status:*`). updates = suggestions_for_bash_command("git status && echo $(id)") - contents = [r.rule_content for u in updates for r in u.rules] - self.assertEqual(contents, ["git status:*"]) + self.assertEqual(updates, []) class TestCompoundMatching(unittest.TestCase): @@ -155,9 +157,22 @@ def test_all_subs_matching_allows_the_pipeline(self): ctx = _ctx(allow=("grep:*", "tr:*", "sort -u")) self.assertEqual(_decide(USER_PIPELINE, ctx).behavior, "allow") - def test_one_unmatched_sub_still_asks(self): - ctx = _ctx(allow=("grep:*", "tr:*")) # no rule for sort -u - self.assertEqual(_decide(USER_PIPELINE, ctx).behavior, "ask") + def test_one_unmatched_non_readonly_sub_still_asks(self): + # A sub that is neither rule-matched NOR read-only keeps the whole + # compound at ask. (`tee out` writes; not read-only, no rule.) + ctx = _ctx(allow=("grep:*",)) + self.assertEqual( + _decide("grep x f | tee out.txt", ctx).behavior, "ask" + ) + + def test_unmatched_but_readonly_sub_rides_readonly_fallback(self): + # NEW (rule-OR-readonly per sub, TS parity): with grep:*/tr:* granted, + # the un-granted `sort -u` leg is provably read-only, so the pipeline + # auto-allows without a `sort` rule. (No out-of-roots paths here.) + ctx = _ctx(allow=("grep:*", "tr:*")) + self.assertEqual( + _decide("grep x f.txt | tr a b | sort -u", ctx).behavior, "allow" + ) def test_accepting_the_suggestion_stops_reprompting(self): # The full loop: ask → accept the suggested bundle → same command allows. @@ -279,47 +294,71 @@ def test_deny_still_wins_over_substitution_command(self): self.assertEqual(_decide('echo "$(rm -rf /)"', ctx).behavior, "deny") -class TestSafetyScreenBackstop(unittest.TestCase): - """Belt-and-suspenders: with the REAL bash safety screen wired in (not the - passthrough stub the other tests use), genuinely-dangerous commands are - caught even without any rule — and the substitution forms stay ask whether - the guard or the analyzer fires. Pins that the two layers agree.""" +class TestFaithfulBashToolCheck(unittest.TestCase): + """Drives the REAL BashTool.check_permissions (not the passthrough stub the + other tests use), so these pin the shipped end-to-end behavior after the + class-based safety screen was removed: a non-read-only command with no rule + still asks, substitution forms stay ask under a grant, and a plain + read-only/granted command allows.""" - def _decide_faithful(self, command, allow=()): - from src.tool_system.tools.bash.bash_tool import check_bash_command_safety + def _decide_faithful(self, command, allow=(), mode="default"): + from src.tool_system.tools.bash.bash_tool import BashTool - class _FaithfulBash: - name = "Bash" + class _TUC: + def __init__(self, root, perm): + self.cwd = root + self._root = root + self.permission_context = perm - def check_permissions(self, tool_input, context): - r = check_bash_command_safety(tool_input.get("command", ""), cwd=None) - return r if r is not None else PermissionPassthroughResult() + def allowed_roots(self): + return [self._root] - ctx = ToolPermissionContext( - always_allow_rules={"session": [f"Bash({r})" for r in allow]} - ) - return has_permissions_to_use_tool_inner( - _FaithfulBash(), {"command": command}, ctx - ).behavior + import tempfile + + with tempfile.TemporaryDirectory() as root: + perm = ToolPermissionContext( + mode=mode, + always_allow_rules={"session": [f"Bash({r})" for r in allow]}, + ) + return has_permissions_to_use_tool_inner( + BashTool, {"command": command}, perm, + tool_use_context=_TUC(root, perm), + ).behavior - def test_dangerous_command_asks_without_a_rule(self): - self.assertEqual(self._decide_faithful("rm -rf /tmp/x"), "ask") + def test_non_readonly_command_asks_without_a_rule(self): + # Was a class-based "destructive" ask; now: rm isn't read-only and has + # no rule → passthrough → ask (writes need acceptEdits or a prompt). + self.assertEqual(self._decide_faithful("rm -rf build"), "ask") + + def test_unknown_binary_asks_without_a_rule(self): + self.assertEqual(self._decide_faithful("frobnicate --go"), "ask") def test_substitution_forms_ask_even_with_a_grant(self): - # The guard fires first; the safety screen would also catch these — both - # layers point the same way, so this can never silently over-allow. + # Structural refusal fires first: a grant can't auto-run smuggled code. for cmd in ( 'echo "$(rm -rf /)"', 'cat <(rm -rf /)', 'echo \\$(rm -rf /)', "echo $'x'$(rm -rf /)", ): - self.assertEqual(self._decide_faithful(cmd, allow=("echo:*", "cat:*")), - "ask", cmd) + self.assertEqual( + self._decide_faithful(cmd, allow=("echo:*", "cat:*")), "ask", cmd + ) + + def test_eval_like_asks_even_with_a_grant(self): + self.assertEqual( + self._decide_faithful('eval "ls"', allow=("eval:*",)), "ask" + ) + + def test_plain_granted_command_allows(self): + self.assertEqual( + self._decide_faithful("echo hello", allow=("echo:*",)), "allow" + ) - def test_plain_safe_command_still_allows_under_grant(self): - self.assertEqual(self._decide_faithful("echo hello", allow=("echo:*",)), - "allow") + def test_read_only_command_allows_without_a_rule(self): + # The core loosening, end-to-end through the real tool. + self.assertEqual(self._decide_faithful("ls -la"), "allow") + self.assertEqual(self._decide_faithful("git status"), "allow") class TestDenyNormalization(unittest.TestCase): diff --git a/tests/test_read_only_commands.py b/tests/test_read_only_commands.py new file mode 100644 index 000000000..b859e4f28 --- /dev/null +++ b/tests/test_read_only_commands.py @@ -0,0 +1,232 @@ +"""Tests for the read-only Bash auto-allow gate (loosen-permissions). + +Port-parity targets: typescript/src/tools/BashTool/readOnlyValidation.ts +(isCommandReadOnly / checkReadOnlyConstraints) and the shared validateFlags +loop. The gate is what lets ``ls`` / ``git status`` / ``grep`` run with NO +prompt and NO rule in default mode, so its REFUSALS are the security surface: +every "refused" case here is a command that must keep prompting. +""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + +from src.permissions.read_only_commands import ( + check_read_only_constraints, + contains_unquoted_expansion, + is_command_read_only, + is_current_directory_bare_git_repo, +) + + +class _Base(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = str(Path(self.tmp.name).resolve()) + # A normal .git so the bare-repo guard doesn't trip. + os.makedirs(os.path.join(self.root, ".git"), exist_ok=True) + Path(self.root, ".git", "HEAD").write_text("ref: refs/heads/main\n") + + def tearDown(self) -> None: + self.tmp.cleanup() + + def ro(self, command: str) -> bool: + return check_read_only_constraints( + command, cwd=self.root, allowed_roots=[self.root] + ) + + +class TestReadOnlyAllowed(_Base): + def test_plain_listing_and_reading(self) -> None: + for cmd in ( + "ls", + "ls -la", + "ls -la src", + "pwd", + "cat README.txt", + "cat f.txt 2>&1", + "head -20 f.py", + "tail -n 50 log.txt", + "wc -l f.py", + "tree -L 2", + "diff a.txt b.txt", + "which python3", + "readlink f", + "du -sh .", + "uname -a", + "whoami", + "true", + "sleep 2", + ): + self.assertTrue(self.ro(cmd), cmd) + + def test_git_read_only_subcommands(self) -> None: + for cmd in ( + "git status", + "git log --oneline -20", + "git diff --stat", + "git diff HEAD~1", + "git branch", + "git show HEAD", + "git blame f.py", + ): + self.assertTrue(self.ro(cmd), cmd) + + def test_search_tools(self) -> None: + for cmd in ( + "grep -rn pattern .", + "grep -A20 foo f.txt", + "rg -n pattern src", + "find . -name test_foo.py", + ): + self.assertTrue(self.ro(cmd), cmd) + + def test_compound_all_read_only(self) -> None: + self.assertTrue(self.ro("git status && ls -la")) + self.assertTrue(self.ro("cat f.txt | head -5")) + self.assertTrue(self.ro("git log --oneline | head -20")) + self.assertTrue(self.ro("cd src && ls")) + + def test_relative_paths_inside_roots(self) -> None: + self.assertTrue(self.ro("cat src/permissions/check.py")) + self.assertTrue(self.ro(f"ls {self.root}")) + + +class TestReadOnlyRefused(_Base): + def test_write_and_exec_commands(self) -> None: + for cmd in ( + "rm -rf build", + "touch x", + "mkdir y", + "mv a b", + "cp a b", + "python x.py", + "pytest -q", + "npm run build", + "pip install requests", + "curl https://example.com", + "tar -xf a.tar", + ): + self.assertFalse(self.ro(cmd), cmd) + + def test_git_mutating_subcommands(self) -> None: + for cmd in ( + "git push", + "git push --force origin main", + "git commit -m x", + "git checkout -b feat", + "git reset --hard HEAD~1", + "git clean -fd", + "git rebase main", + ): + self.assertFalse(self.ro(cmd), cmd) + + def test_write_flags_on_read_only_binaries(self) -> None: + # Flag-aware refusals — exactly what the crude first-token lists miss. + for cmd in ( + "sort -o out.txt in.txt", + "date -s '2020-01-01'", + "sed -i s/a/b/ f.txt", + "tree -o out.html", + "find . -delete", + "find . -exec rm {} ;", + "git -c core.fsmonitor=/tmp/evil status", + "git --exec-path=/tmp/evil status", + ): + self.assertFalse(self.ro(cmd), cmd) + + def test_redirects_and_operators(self) -> None: + for cmd in ( + "ls > files.txt", + "cat a >> b", + "echo hi > f", + "grep x f 2>/dev/null", + "sort < in.txt", + ): + self.assertFalse(self.ro(cmd), cmd) + + def test_expansion_refused(self) -> None: + # Globs/vars can expand into flags the validators never saw. + for cmd in ("ls *.py", "cat $FILE", "grep x $(ls)", "echo `date`"): + self.assertFalse(self.ro(cmd), cmd) + + def test_quoted_globs_are_fine(self) -> None: + self.assertFalse(contains_unquoted_expansion("grep 'a*b' f.txt")) + self.assertTrue(contains_unquoted_expansion("grep a*b f.txt")) + self.assertTrue(contains_unquoted_expansion('echo "$HOME"')) + self.assertFalse(contains_unquoted_expansion("echo '$HOME'")) + + def test_compound_with_non_read_only_leg(self) -> None: + self.assertFalse(self.ro("git status && git push")) + self.assertFalse(self.ro("ls && rm -rf x")) + self.assertFalse(self.ro("cat f | xargs rm")) + + def test_cd_git_and_multi_cd_guards(self) -> None: + self.assertFalse(self.ro("cd sub && git status")) + self.assertFalse(self.ro("pushd sub && git log")) + self.assertFalse(self.ro("cd a; cd b")) + + def test_paths_outside_roots_refused(self) -> None: + for cmd in ( + "cat /etc/passwd", + "ls ~/", + "cat ../outside.txt", + "cd /tmp", + "head -5 /var/log/system.log", + ): + self.assertFalse(self.ro(cmd), cmd) + + def test_bare_shells_and_wrappers(self) -> None: + for cmd in ("bash -c ls", "sh script.sh", "env ls", "xargs rm", + "sudo ls", "eval ls"): + self.assertFalse(self.ro(cmd), cmd) + + def test_xargs_rI_parser_differential_refused(self) -> None: + # GNU getopt bundling: `-rI echo sh -c id` runs `sh -c id`. + self.assertFalse(is_command_read_only("xargs -rI echo sh -c id")) + + def test_ps_bsd_e_modifier_refused(self) -> None: + self.assertFalse(is_command_read_only("ps axe")) + self.assertTrue(is_command_read_only("ps aux")) + + +class TestBareRepoGuard(unittest.TestCase): + def test_bare_repo_indicators_detected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = str(Path(tmp).resolve()) + # No .git; plant bare-repo indicators. + os.makedirs(os.path.join(root, "objects")) + os.makedirs(os.path.join(root, "refs")) + Path(root, "HEAD").write_text("ref: refs/heads/main\n") + self.assertTrue(is_current_directory_bare_git_repo(root)) + self.assertFalse( + check_read_only_constraints( + "git status", cwd=root, allowed_roots=[root] + ) + ) + # Non-git read-only commands are unaffected by the guard. + self.assertTrue( + check_read_only_constraints( + "ls -la", cwd=root, allowed_roots=[root] + ) + ) + + def test_normal_repo_not_flagged(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = str(Path(tmp).resolve()) + os.makedirs(os.path.join(root, ".git")) + Path(root, ".git", "HEAD").write_text("ref: refs/heads/main\n") + self.assertFalse(is_current_directory_bare_git_repo(root)) + + def test_worktree_gitfile_not_flagged(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = str(Path(tmp).resolve()) + Path(root, ".git").write_text("gitdir: /elsewhere/.git/worktrees/x\n") + self.assertFalse(is_current_directory_bare_git_repo(root)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tool_permission_parity.py b/tests/test_tool_permission_parity.py index 97837e9e1..79b10251a 100644 --- a/tests/test_tool_permission_parity.py +++ b/tests/test_tool_permission_parity.py @@ -89,7 +89,9 @@ def test_gated_tools_ask_in_default(self) -> None: "Edit": {"file_path": str(self.ws / "a.txt"), "old_string": "hi", "new_string": "yo"}, "Write": {"file_path": str(self.ws / "b.txt"), "content": "x"}, "NotebookEdit": {"notebook_path": str(self.ws / "n.ipynb"), "new_source": "x"}, - "Bash": {"command": "echo hi"}, + # A non-read-only command: `echo hi` now auto-allows (read-only + # loosening), so use a command that still requires approval. + "Bash": {"command": "pytest -q"}, "WebFetch": {"url": "https://example.com", "prompt": "x"}, "MCP": {"server": "s", "tool": "t"}, "ListMcpResourcesTool": {}, @@ -255,15 +257,32 @@ def test_undeclared_command_blocked(self) -> None: self.assertFalse(marker.exists(), "undeclared command must NOT execute") self.assertIn("Error", out) - def test_dangerous_command_blocked_even_when_declared(self) -> None: - # Safety screen wins over an allowed_tools grant: the marker survives - # because the declared-but-destructive rm never runs. - marker = self.ws / "danger.marker" - marker.write_text("keep") - out = self._exec(["Bash(rm:*)"], f"rm -rf {marker}") - self.assertTrue(marker.exists(), "destructive command must be blocked despite being declared") + def test_declared_rm_of_dangerous_path_blocked(self) -> None: + # The class-based safety screen is gone; the remaining guard is the + # path gate. A declared Bash(rm:*) can delete an in-workspace file + # (the skill declared it), but a DANGEROUS-removal target (here $HOME) + # is still blocked — the marker outside the target is irrelevant; we + # assert rm -rf ~ never runs. + out = self._exec(["Bash(rm:*)"], "rm -rf ~") self.assertIn("Error", out) + def test_declared_rm_outside_workspace_blocked(self) -> None: + import tempfile + with tempfile.TemporaryDirectory() as other: + victim = Path(other) / "keep.txt" + victim.write_text("keep") + out = self._exec(["Bash(rm:*)"], f"rm -rf {victim}") + self.assertTrue(victim.exists(), "out-of-workspace rm must be blocked") + self.assertIn("Error", out) + + def test_declared_in_workspace_rm_runs(self) -> None: + # Documented deviation: a skill that declares Bash(rm:*) may delete an + # in-workspace, non-critical file (the declaration IS the grant). + marker = self.ws / "declared_rm.marker" + marker.write_text("x") + self._exec(["Bash(rm:*)"], f"rm -rf {marker}") + self.assertFalse(marker.exists(), "declared in-workspace rm should run") + def test_chained_command_blocked(self) -> None: # Chaining can't ride in on a single-command allow rule. marker = self.ws / "chain.marker" @@ -271,16 +290,16 @@ def test_chained_command_blocked(self) -> None: self.assertFalse(marker.exists(), "chained command must not run") self.assertIn("Error", out) - def test_bare_bash_grant_runs_safe_blocks_dangerous(self) -> None: - # A bare `Bash` allowed-tool grants all *non-screened* shell, but the - # safety screen still fires first for destructive commands. + def test_bare_bash_grant_runs_in_workspace_blocks_dangerous_path(self) -> None: + # A bare `Bash` grant (allow-all) runs safe/in-workspace shell, but the + # write path gate still blocks a dangerous-removal target — TS path-gates + # Bash(*) writes too (checkPathConstraints precedes the allow rule). safe = self.ws / "bare_safe.marker" self._exec(["Bash"], f"touch {safe}") - self.assertTrue(safe.exists(), "bare Bash grant should run safe commands") - danger = self.ws / "bare_danger.marker" - danger.write_text("keep") - self._exec(["Bash"], f"rm -rf {danger}") - self.assertTrue(danger.exists(), "bare Bash grant must not bypass the safety screen") + self.assertTrue(safe.exists(), "bare Bash grant should run in-workspace commands") + # rm -rf ~ must never run, even under allow-all. + out = self._exec(["Bash"], "rm -rf ~") + self.assertIn("Error", out) def test_bypass_mode_runs_undeclared(self) -> None: marker = self.ws / "bypass.marker" diff --git a/tests/test_tool_system_tools.py b/tests/test_tool_system_tools.py index ffd1fd406..c26656252 100644 --- a/tests/test_tool_system_tools.py +++ b/tests/test_tool_system_tools.py @@ -125,10 +125,14 @@ def test_write_requires_read_before_overwrite(self) -> None: WriteTool.call({"file_path": str(p), "content": "new"}, self.ctx) self.assertEqual(p.read_text(encoding="utf-8"), "new") - def test_write_blocks_docs_by_default(self) -> None: + def test_write_markdown_passthrough(self) -> None: + # Docs gate removed (loosen-permissions): the original Claude Code has + # no markdown permission gate, so a .md write flows like any other + # write (prompt in default mode WITH a session option, not a special + # un-grantable docs ask). p = self.root / "README.md" result = WriteTool.check_permissions({"file_path": str(p), "content": "x"}, self.ctx) - self.assertEqual(result.behavior, "ask") + self.assertEqual(result.behavior, "passthrough") class TestEditTool(ToolSystemTests): diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index d63769857..596531370 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -900,6 +900,35 @@ describe('createGatewayEventHandler', () => { }) }) + it('carries the destructive-command warning onto the approval overlay', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ + payload: { + command: 'git push --force origin main', + tool_name: 'Bash', + warning: 'Note: may overwrite remote history' + }, + type: 'approval.request' + } as any) + + expect(getOverlayState().approval).toMatchObject({ + command: 'git push --force origin main', + warning: 'Note: may overwrite remote history' + }) + }) + + it('leaves warning undefined when the backend sends none', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ + payload: { command: 'ls -la', tool_name: 'Bash' }, + type: 'approval.request' + } as any) + + expect(getOverlayState().approval?.warning).toBeUndefined() + }) + it('still surfaces terminal turn failures as errors', () => { const appended: Msg[] = [] const onEvent = createGatewayEventHandler(buildCtx(appended)) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index b6d3354ab..356f4e57b 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -817,7 +817,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: rule: rule ? String(rule) : undefined, ruleLabel: ruleLabel ? String(ruleLabel) : undefined, sessionLabel: sessionLabel ? String(sessionLabel) : undefined, - toolName: String(ev.payload.tool_name ?? 'tool') + toolName: String(ev.payload.tool_name ?? 'tool'), + warning: ev.payload.warning ? String(ev.payload.warning) : undefined } }) setStatus('approval needed') diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 7c8767e9b..d9d952dd5 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -145,6 +145,14 @@ export function ApprovalPrompt({ cols = 80, onChoice, req, t }: ApprovalPromptPr ) : null} + {req.warning ? ( + // Destructive-command caution (backend-computed, e.g. "Note: may + // overwrite remote history") — parity with the original's dialog + // warning now that destructive commands prompt through the ordinary + // grantable flow. + ⚠ {req.warning} + ) : null} + Do you want to proceed? {opts.map((o, i) => { diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index ea3a1b6b4..39a4ae3f0 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -1452,7 +1452,9 @@ export class GatewayClient extends EventEmitter { // all edits during this session"); the box uses it verbatim for // non-Bash tools instead of a generic "don't ask again for ". session_label: typeof req.session_label === 'string' ? req.session_label : null, - tool_name: String(req.tool_name ?? 'tool') + tool_name: String(req.tool_name ?? 'tool'), + // Destructive-command caution (backend-computed) → warning line. + warning: typeof req.warning === 'string' && req.warning ? req.warning : null }, type: 'approval.request' }) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 8764d17be..b48b31033 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -761,6 +761,7 @@ export type GatewayEvent = rule_label?: null | string session_label?: null | string tool_name: string + warning?: null | string } session_id?: string type: 'approval.request' diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index af95251a3..b5d9e41c5 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -110,6 +110,9 @@ export interface ApprovalReq { // "Yes, " (e.g. "Yes, allow all edits during this session"). // Used for non-Bash tools; Bash uses the editable rule instead. sessionLabel?: string + // Destructive-command caution from the backend (e.g. "Note: may overwrite + // remote history"), rendered as a warning line above the options. + warning?: string } export interface ConfirmReq {