"""Conservative read-only shell-command classifier for the session-scoped grant. "Allow read-only commands for this session" (owner ask 2026-08-11, born of approval fatigue in security-scan sessions: ~15 hand-approvals per run) auto-allows a command only when THIS classifier accepts it. The contract: - **Local filesystem reads only.** Network clients (curl/wget/ssh/nc) are deliberately excluded even for GET — an auto-allowed network command is an exfiltration channel under prompt injection. Interpreters (python/ruby/sh -c) and anything that can write, execute, or mutate are excluded. - **Pipelines are allowed** (`nl … | sed -n … | grep …`) — every stage must classify. All other shell operators (;, &&, ||, &, redirections, substitutions) are rejected outright. - **Fail closed.** Unknown commands, unparseable input, path-invoked binaries, and any doubtful flag reject. False negatives cost one manual approval; false positives cost an unreviewed side effect — the asymmetry decides every edge case here. This is a user-elected convenience on top of the approval flow, not a sandbox: the session still runs under its permission mode, and the user granted the scope explicitly. """ from __future__ import annotations import re import shlex # Commands that only read local state, with no writing flags to police. _SIMPLE_SAFE = { "ls", "cat", "head", "tail", "wc", "nl", "sort", "uniq", "cut", "tr", "grep", "egrep", "fgrep", "rg", "ugrep", "file", "stat", "du", "df", "pwd", "echo", "printf", "which", "whoami", "id", "date", "uname", "basename", "dirname", "realpath", "readlink", "jq", "column", "diff", "comm", "strings", "md5sum", "shasum", "sha1sum", "sha256sum", "hexdump", "xxd", "od", "true", "false", "yamllint", "actionlint", } # Git subcommands that only read. Note the per-subcommand guards below — several git # "read" commands grow write/exec behavior through specific flags. _GIT_SAFE = { "status", "log", "show", "diff", "blame", "shortlog", "describe", "rev-parse", "rev-list", "ls-files", "ls-tree", "grep", "cat-file", "name-rev", "merge-base", "count-objects", "var", "check-ignore", } _GIT_BRANCH_FLAG_OK = { "--show-current", "--list", "-a", "-r", "-v", "-vv", "--contains", "--merged", "--no-merged", "--all", } _FIND_BAD = ("-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fls", "-fprintf") _ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=[^;&|<>`]*$") # A sed script token that invokes the `w`/`W` (write-file) command: at the start, after a # separator, or after an address. Conservative — a false hit just means one manual approval. _SED_WRITE = re.compile(r"(^|[;{])\s*[0-9,$/ ]*[wW]\s") def _stages(command: str) -> list[list[str]] | None: """Tokenize with operators surfaced; split into pipeline stages. None = reject.""" if not command or not command.strip(): return None # Substitutions can hide inside double quotes, which the tokenizer strips — check the # raw text. Rejects a literal '$(' in a grep pattern too; that asymmetry is the point. if "`" in command or "$(" in command or "<(" in command or ">(" in command: return None lex = shlex.shlex(command, posix=True, punctuation_chars=True) lex.whitespace_split = True try: tokens = list(lex) except ValueError: return None # unbalanced quotes etc. stages: list[list[str]] = [[]] for tok in tokens: if tok == "|": stages.append([]) elif tok in {";", "&", "&&", "||", "|&"} or (tok and set(tok) <= {">", "<", "&", "0", "1", "2"} and any(c in tok for c in "<>&")): return None # every operator except a plain pipe rejects (incl. 2>, &>, <<) else: stages[-1].append(tok) if any(not s for s in stages): return None # empty stage ("| cmd", "cmd |") return stages def _git_ok(args: list[str]) -> bool: # Global flags: only `-C