Files
OpenOPC/opc/layer2_organization/shell_safety.py
T
LZH-YS1998 4b29b89371 refactor: unify tool approval into a single engine and cut prompt storms
Collapse the dual permission stack into one policy. The runtime-side
ToolPermissionResolver (own safe lists, own grant memory, bypassed the
ApprovalEngine whenever it said ALLOW) is deleted; runtime_v2 now consults
ApprovalEngine.predict(), a synchronous fast path reading the same config
and the same persisted allowlist as the async authorize pipeline, so a
grant given anywhere is honored everywhere. permissions.py keeps only a
policy-free adapter; the duplicated permissions_v2 config fields and the
runtime grant persistence loop are removed (stale YAML keys are ignored).

New shell_safety module becomes the single source of truth for shell
classification: flag-audited read-only commands (awk/od/jq/sed -n/diff/
git subcommand table/... auto-allow; find -delete, sort -o, curl -o/-d,
rg --pre still prompt even when the bare name is config-listed),
keyword-aware compound splitting (loop/branch headers no longer poison
grants), expansion-safe $() handling, and fail-closed treatment of
anything unparseable or substitution-bearing.

Grant semantics are rebuilt around derived word-boundary prefixes:
"python3 -c" instead of token bags, interpreter -c/-m kept in the prefix,
bash/eval/sudo never grantable as prefixes, read-only segments exempt
from the every-candidate-must-match rule so a granted command chained
with ls/echo verification passes, and approve-once now records the exact
candidates as a session grant so identical re-runs stop re-prompting.
The authorize heuristic also audits the original command text instead of
the quote-dropping preview (echo "<EOF>" no longer reads as redirection).

Validated live on zz_perm_probe1 (native minimal org): awk/od/ls/cat/
sha256sum ran with zero cards, python3 -c parked once and three different
python3 -c commands then passed via the persisted prefix grant, and an
agent-issued rm -f compound correctly re-prompted showing only the
segments needing approval. Full suite failures are byte-identical to the
pre-change HEAD baseline (27 pre-existing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:43:27 +08:00

525 lines
20 KiB
Python

"""Flag-audited shell-command safety classification.
Single source of truth for shell-command handling in the approval pipeline:
splitting compound commands, stripping harmless redirections, analysing
command substitution, deriving grant prefixes, and deciding whether a command
is read-only-safe (auto-approvable without an approval card).
Design rules (mirroring the codex / Claude Code permission engines):
- Fail closed: anything unparseable, too dynamic, or unknown is NOT safe.
- Audited commands are classified by their flags, not just their name —
``find .`` is read-only, ``find . -delete`` is not. For audited commands the
built-in verdict is final; a bare config prefix cannot rescue a failing
audit.
- Config prefixes only extend coverage to commands the audit table does not
know (user-trusted tools like ``ffmpeg``); network fetchers stay
config-gated even though their flags are audited here.
"""
from __future__ import annotations
import re
import shlex
from typing import Iterable, Sequence
SHELL_CONTROL_TOKENS = {"&&", "||", ";", ";;", "|", "|&", "&"}
# Redirections that cannot write to a real file: fd duplication (2>&1, >&2)
# and discarding output into /dev/null.
SAFE_REDIRECTION_RE = re.compile(r"(?:\d?>>?\s*/dev/null\b|\d?>&\d|&>>?\s*/dev/null\b)")
# Loop/branch headers execute nothing themselves (or only the guarded command,
# which survives the strip). ``for``-style headers are dropped whole because
# their tokens are data (loop variables / word lists), not commands.
_DROP_SEGMENT_KEYWORDS = {"for", "while", "until", "case", "select", "function"}
_STRIP_LEADING_KEYWORDS = {"if", "elif", "then", "else", "do", "done", "fi", "esac", "{", "}", "!"}
# Environment assignments that cannot change what a command does in a way
# that matters for safety. PATH / LD_PRELOAD / PYTHONPATH etc. are absent on
# purpose: an unlisted assignment makes the segment fail the read-only audit.
_SAFE_ENV_VARS = {
"LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "COLUMNS", "LINES",
"NO_COLOR", "FORCE_COLOR", "CLICOLOR", "PYTHONIOENCODING", "PYTHONUNBUFFERED",
"NODE_ENV", "PAGER", "GIT_PAGER",
}
# Substitution results that may safely expand into another command's argument
# list. Deliberately excludes anything that can carry file/environment content
# (`cat`, `echo`, `ls`, ...): allowing `curl $(cat secrets)` would turn a
# read-only helper into an exfil channel.
_EXPANSION_SAFE_HEADS = {
"pwd", "date", "whoami", "hostname", "uname", "nproc", "basename",
"dirname", "realpath", "readlink", "which",
"git rev-parse", "git branch --show-current", "git describe",
}
_EXPANSION_PLACEHOLDER = "__opc_subst__"
# Commands that are read-only with any arguments, minus per-command banned
# flags. They print to stdout and cannot write files or execute other
# programs through their own options.
_GENERIC_READ_ONLY = {
"cat", "head", "tail", "wc", "sort", "uniq", "cut", "tr", "stat", "file",
"basename", "dirname", "realpath", "readlink", "du", "df", "tree", "nproc",
"whoami", "hostname", "date", "uname", "pwd", "ls", "id", "groups", "echo",
"printf", "true", "false", "test", "[", "expr", "seq", "sleep", "diff",
"cmp", "comm", "nl", "column", "expand", "unexpand", "paste", "join",
"strings", "hexdump", "od", "md5sum", "sha1sum", "sha256sum", "sha512sum",
"cksum", "b2sum", "which", "type", "grep", "egrep", "fgrep", "jq", "ps",
"free", "uptime", "lscpu", "lsblk", "whereis", "cd", "wait", "pgrep",
"getent", "locale", "tty", "arch", "printenv",
}
# Flags that make an otherwise read-only command write somewhere.
_BANNED_FLAGS: dict[str, set[str]] = {
"sort": {"-o", "--output"},
"date": {"-s", "--set"},
"tree": {"-o"},
"jq": set(), # jq cannot execute or write via flags
"grep": set(),
"ps": set(),
}
_GIT_READ_ONLY_SUBCOMMANDS = {
"status", "diff", "log", "show", "blame", "rev-parse", "ls-files",
"ls-tree", "describe", "shortlog", "cat-file", "grep", "reflog",
"count-objects", "diff-tree", "rev-list", "merge-base", "name-rev", "var",
"check-ignore", "show-ref", "version", "--version", "cherry", "whatchanged",
}
# Subcommands that only stay read-only in their bare/list form.
_GIT_LIST_ONLY_SUBCOMMANDS = {"branch", "tag", "remote", "stash", "worktree", "config"}
_GIT_LIST_ONLY_SAFE_FLAGS = {
"branch": {"--list", "-l", "-a", "--all", "-r", "--remotes", "-v", "-vv",
"--verbose", "--show-current", "--contains", "--merged", "--no-merged"},
"tag": {"--list", "-l", "-n", "--contains", "--merged", "--no-merged", "--sort"},
"remote": {"-v", "--verbose"},
"stash": set(), # only `git stash list`
"worktree": set(), # only `git worktree list`
"config": {"--get", "--get-all", "--list", "-l", "--get-regexp", "--global", "--local", "--system"},
}
_FIND_BANNED_PREDICATES = {
"-delete", "-exec", "-execdir", "-ok", "-okdir",
"-fprint", "-fprint0", "-fprintf", "-fls",
}
_RG_BANNED_FLAGS = {"--pre", "--hostname-bin"}
# curl writes to stdout by default; these flags make it write files, upload
# data, or read attacker-controlled config. Single chars cover combined short
# flags like ``-sSfLo``.
_CURL_BANNED_LONG = {
"--output", "--remote-name", "--remote-name-all", "--output-dir",
"--upload-file", "--data", "--data-binary", "--data-raw", "--data-ascii",
"--data-urlencode", "--form", "--form-string", "--config", "--dump-header",
"--cookie-jar", "--trace", "--trace-ascii", "--remote-header-name",
}
_CURL_BANNED_SHORT_CHARS = set("oOTdFKDcJ")
# Network fetchers stay config-gated: flag audit alone never auto-allows them,
# the command must also appear in the operator's safe-prefix config.
_NETWORK_AUDITED = {"curl"}
_INTERPRETERS = {"python", "python3", "python2", "node", "bun", "deno", "ruby", "perl"}
_VERSION_ONLY_FLAGS = {"-v", "-V", "--version"}
# Heads that must never become broad grant prefixes ("always allow bash"
# would be a blank check). Grants for these degrade to the exact command.
UNGRANTABLE_PREFIX_HEADS = {
"bash", "sh", "zsh", "dash", "ksh", "eval", "source", ".", "sudo", "doas",
"env", "xargs", "command", "exec", "nohup", "setsid", "watch", "script",
}
_SAFE_WRAPPER_HEADS = {"time", "nohup"}
def strip_safe_redirections(command: str) -> str:
"""Remove fd-duplication / null-sink redirections that cannot write files."""
return SAFE_REDIRECTION_RE.sub(" ", str(command or ""))
def sanitize_expansions(command: str) -> tuple[str, bool]:
"""Replace expansion-safe ``$(...)`` with a placeholder.
Returns ``(sanitized_text, all_safe)``. ``all_safe`` is False when the
command contains backticks, process substitution, nested substitution, or
a ``$(...)`` whose inner command is not in the expansion-safe set.
"""
text = str(command or "")
if "`" in text or "<(" in text or ">(" in text:
return text, False
out: list[str] = []
i = 0
all_safe = True
while i < len(text):
start = text.find("$(", i)
if start < 0:
out.append(text[i:])
break
out.append(text[i:start])
depth = 1
j = start + 2
while j < len(text) and depth > 0:
if text.startswith("$(", j):
# nested substitution: too dynamic to audit
return text, False
if text[j] == "(":
depth += 1
elif text[j] == ")":
depth -= 1
j += 1
if depth != 0:
return text, False
inner = text[start + 2:j - 1].strip()
inner_head = " ".join(inner.split())
if not any(
inner_head == safe or inner_head.startswith(safe + " ")
for safe in _EXPANSION_SAFE_HEADS
):
all_safe = False
out.append(_EXPANSION_PLACEHOLDER)
i = j
return "".join(out), all_safe
def has_blocked_substitution(command: str) -> bool:
"""True when the command contains substitution we refuse to auto-allow."""
_, all_safe = sanitize_expansions(command)
return not all_safe
def split_shell_segments(command: str) -> list[list[str]] | None:
"""Split a compound command into per-command token lists.
Loop/branch headers are dropped or stripped so the returned segments are
the commands that actually execute. Returns ``None`` when the input cannot
be tokenized (unbalanced quotes etc.) — callers must fail closed.
"""
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
if not text:
return []
try:
lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|")
lexer.whitespace_split = True
lexer.commenters = ""
tokens = list(lexer)
except ValueError:
return None
segments: list[list[str]] = []
current: list[str] = []
for token in tokens:
if token in SHELL_CONTROL_TOKENS:
if current:
segments.append(current)
current = []
continue
current.append(token)
if current:
segments.append(current)
cleaned: list[list[str]] = []
for segment in segments:
if segment[0] in _DROP_SEGMENT_KEYWORDS:
continue
index = 0
while index < len(segment) and segment[index] in _STRIP_LEADING_KEYWORDS:
index += 1
remainder = segment[index:]
if remainder:
cleaned.append(remainder)
return cleaned
def command_has_redirection(command: str) -> bool:
"""Detect real (file-writing or file-reading) redirection tokens."""
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
if not text:
return False
try:
lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|<>")
lexer.whitespace_split = True
lexer.commenters = ""
tokens = list(lexer)
except ValueError:
return any(marker in text for marker in (">", "<"))
return any(token in {">", ">>", "<", "<<", "<<<"} for token in tokens)
def _strip_env_assignments(tokens: list[str]) -> tuple[list[str], bool]:
"""Consume leading VAR=value assignments; unsafe vars fail the audit."""
index = 0
safe = True
while index < len(tokens):
token = tokens[index]
eq = token.find("=")
if eq <= 0 or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", token[:eq]):
break
if token[:eq] not in _SAFE_ENV_VARS:
safe = False
index += 1
return tokens[index:], safe
def _strip_safe_wrappers(tokens: list[str]) -> list[str]:
while tokens:
head = tokens[0]
if head in _SAFE_WRAPPER_HEADS:
tokens = tokens[1:]
continue
if head == "timeout" and len(tokens) >= 2:
rest = tokens[1:]
while rest and rest[0].startswith("-"):
rest = rest[1:]
tokens = rest[1:] if rest else []
continue
if head == "nice":
rest = tokens[1:]
if len(rest) >= 2 and rest[0] == "-n":
rest = rest[2:]
elif rest and rest[0].startswith("-"):
rest = rest[1:]
tokens = rest
continue
if head == "stdbuf":
rest = tokens[1:]
while rest and rest[0].startswith("-"):
rest = rest[1:]
tokens = rest
continue
break
return tokens
def _flags_in(tokens: Iterable[str]) -> list[str]:
return [token for token in tokens if token.startswith("-")]
def _git_segment_read_only(tokens: list[str]) -> bool:
rest = tokens[1:]
# consume global options that take a value
while rest and rest[0].startswith("-"):
if rest[0] in {"-C", "-c", "--git-dir", "--work-tree", "--namespace"} and len(rest) >= 2:
rest = rest[2:]
continue
if rest[0] in {"--no-pager", "--paginate", "-P", "-p"}:
rest = rest[1:]
continue
if rest[0] in {"--version", "--help"}:
return True
return False
if not rest:
return False
sub = rest[0]
if sub in _GIT_READ_ONLY_SUBCOMMANDS:
return True
if sub in _GIT_LIST_ONLY_SUBCOMMANDS:
args = rest[1:]
if sub == "stash":
return args[:1] == ["list"]
if sub == "worktree":
return args[:1] == ["list"]
safe_flags = _GIT_LIST_ONLY_SAFE_FLAGS.get(sub, set())
positionals = [a for a in args if not a.startswith("-")]
flags_ok = all(a.split("=", 1)[0] in safe_flags for a in args if a.startswith("-"))
if sub == "config":
# reads need --get/--list; positionals are the key names
has_read_flag = any(a.split("=", 1)[0] in {"--get", "--get-all", "--get-regexp", "--list", "-l"} for a in args)
return flags_ok and has_read_flag
return flags_ok and not positionals
return False
def _sed_segment_read_only(tokens: list[str]) -> bool:
args = tokens[1:]
if not any(a == "-n" or (a.startswith("-") and not a.startswith("--") and "n" in a[1:]) for a in args):
return False
scripts: list[str] = []
index = 0
while index < len(args):
token = args[index]
if token.startswith("-"):
if token.split("=", 1)[0] in {"-i", "--in-place", "-f", "--file", "-s"} or token.startswith("-i"):
return False
if token in {"-e", "--expression"} and index + 1 < len(args):
scripts.append(args[index + 1])
index += 2
continue
index += 1
continue
if not scripts:
scripts.append(token)
index += 1
if not scripts:
return False
return all(re.fullmatch(r"[0-9,$; ]*p", script.strip()) for script in scripts)
def _awk_segment_read_only(tokens: list[str]) -> bool:
args = tokens[1:]
program = ""
index = 0
while index < len(args):
token = args[index]
if token.startswith("-"):
head = token.split("=", 1)[0]
if head in {"-f", "--file", "-i", "--include", "-l", "--load"}:
return False
if head in {"-v", "--assign", "-F", "--field-separator"} and "=" not in token and index + 1 < len(args):
index += 2
continue
if head in {"-e", "--source"} and index + 1 < len(args):
program = program or args[index + 1]
index += 2
continue
index += 1
continue
if not program:
program = token
index += 1
if not program:
return False
banned = ("system", ">", "|", "getline", "close(", "fflush(", "print >", "printf >")
return not any(marker in program for marker in banned)
def _xxd_segment_read_only(tokens: list[str]) -> bool:
args = tokens[1:]
if any(a == "-r" or a == "-revert" for a in args):
return False
positionals = [a for a in args if not a.startswith("-")]
return len(positionals) <= 1
def _find_segment_read_only(tokens: list[str]) -> bool:
return not any(token in _FIND_BANNED_PREDICATES for token in tokens[1:])
def _rg_segment_read_only(tokens: list[str]) -> bool:
return not any(token.split("=", 1)[0] in _RG_BANNED_FLAGS for token in tokens[1:])
def _curl_flags_clean(tokens: list[str]) -> bool:
args = tokens[1:]
index = 0
while index < len(args):
token = args[index]
if token.startswith("--"):
if token.split("=", 1)[0] in _CURL_BANNED_LONG:
return False
if token.split("=", 1)[0] == "--request":
value = token.split("=", 1)[1] if "=" in token else (args[index + 1] if index + 1 < len(args) else "")
if value.upper() not in {"GET", "HEAD"}:
return False
elif token.startswith("-") and len(token) > 1:
if token == "-X":
value = args[index + 1] if index + 1 < len(args) else ""
if value.upper() not in {"GET", "HEAD"}:
return False
index += 2
continue
if any(ch in _CURL_BANNED_SHORT_CHARS for ch in token[1:]):
return False
index += 1
return True
# Commands the audit table knows. For these the audit verdict is FINAL: a
# bare config prefix (e.g. "find" in safe_command_prefixes) cannot rescue a
# failing audit, closing the `find -delete` / `curl -o` holes.
AUDITED_COMMAND_HEADS = (
_GENERIC_READ_ONLY
| _NETWORK_AUDITED
| _INTERPRETERS
| {"git", "find", "sed", "awk", "gawk", "mawk", "nawk", "rg", "xxd", "npm", "pip", "pip3"}
)
def _matches_config_prefix(segment_text: str, config_prefixes: Sequence[str]) -> bool:
normalized = segment_text.casefold()
for raw in config_prefixes:
prefix = " ".join(str(raw or "").split()).casefold()
if not prefix:
continue
if normalized == prefix or normalized.startswith(prefix + " "):
return True
return False
def _segment_read_only(tokens: list[str], config_prefixes: Sequence[str]) -> bool:
tokens, env_safe = _strip_env_assignments(list(tokens))
if not env_safe or not tokens:
return False
tokens = _strip_safe_wrappers(tokens)
if not tokens:
return False
head = tokens[0]
if head == "env":
rest, env_safe = _strip_env_assignments(tokens[1:])
if not env_safe:
return False
if not rest:
return True # bare `env` prints the environment
tokens = rest
head = tokens[0]
if "/" in head:
# path-invoked binaries (./find, /tmp/cat) are never classified by name
return False
if head == "git":
return _git_segment_read_only(tokens)
if head == "find":
return _find_segment_read_only(tokens)
if head == "sed":
return _sed_segment_read_only(tokens)
if head in {"awk", "gawk", "mawk", "nawk"}:
return _awk_segment_read_only(tokens)
if head == "rg":
return _rg_segment_read_only(tokens)
if head == "xxd":
return _xxd_segment_read_only(tokens)
if head in _NETWORK_AUDITED:
segment_text = " ".join(tokens)
if not _matches_config_prefix(head, config_prefixes) and not _matches_config_prefix(segment_text, config_prefixes):
return False
return _curl_flags_clean(tokens)
if head in _INTERPRETERS or head in {"npm", "pip", "pip3"}:
return len(tokens) == 2 and tokens[1] in _VERSION_ONLY_FLAGS
if head in _GENERIC_READ_ONLY:
banned = _BANNED_FLAGS.get(head, set())
if banned and any(token.split("=", 1)[0] in banned for token in _flags_in(tokens[1:])):
return False
return True
# Unknown command: honor operator-configured safe prefixes.
return _matches_config_prefix(" ".join(tokens), config_prefixes)
def is_read_only_shell_command(
command: str,
config_prefixes: Sequence[str] = (),
) -> tuple[bool, str]:
"""Classify a (possibly compound) shell command as read-only-safe.
Returns ``(safe, reason)``. Every segment must independently pass; any
substitution we cannot prove harmless, real redirection, or unparseable
input fails closed.
"""
cleaned = " ".join(str(command or "").split()).strip()
if not cleaned:
return False, "empty command"
sanitized, expansions_safe = sanitize_expansions(cleaned)
if not expansions_safe:
return False, "command substitution cannot be audited"
sanitized = strip_safe_redirections(sanitized)
if command_has_redirection(sanitized):
return False, "command performs file redirection"
segments = split_shell_segments(sanitized)
if segments is None:
return False, "command could not be parsed"
if not segments:
return False, "no executable segments"
for tokens in segments:
if not _segment_read_only(tokens, config_prefixes):
return False, f"segment `{ ' '.join(tokens[:6]) }` is not proven read-only"
return True, "all segments are flag-audited read-only commands"