318 lines
11 KiB
Python
318 lines
11 KiB
Python
"""Tool permission & parameter-scoping policy.
|
|
|
|
Implements checklist §2.2 (confirmation for destructive actions) and
|
|
§2.3 (scope tool parameters) in one place so the rules are easy to
|
|
audit and extend.
|
|
|
|
Three layers, evaluated in order by ``check_tool_policy`` before the
|
|
mode-based permission decision in ``agent.check_permission``:
|
|
|
|
1. Path scoping - reject path-bearing tool args that escape the
|
|
working directory (generalized from the write-only check that
|
|
existed before).
|
|
2. Shell policy - parse ``run_bash`` commands with ``shlex`` and apply
|
|
a denylist of binaries and a regex denylist of dangerous patterns.
|
|
3. Web policy - reject SSRF targets (cloud metadata, loopback,
|
|
link-local, RFC1918 private ranges).
|
|
|
|
Each layer returns ``(allowed: bool, reason: str | None)``. When a
|
|
layer rejects, the call is blocked regardless of the permission mode.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import re
|
|
import shlex
|
|
import socket
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2.2 Destructive-action classification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Tools whose side effects mutate state outside the agent's own memory.
|
|
DESTRUCTIVE_TOOLS = frozenset({
|
|
"run_bash",
|
|
"write_file",
|
|
"edit_file",
|
|
})
|
|
|
|
# Tools that ALWAYS require explicit human confirmation, even under
|
|
# ``dangerouslySkipPermissions``. These are the irreversible / exfil
|
|
# class — we refuse them outright (a denylist), not just prompt for them.
|
|
#
|
|
# ``run_bash`` is not in this set as a whole; instead its *command* is
|
|
# screened by the shell policy below. ``webfetch`` is also policy-
|
|
# screened (SSRF). This set is reserved for tools where *any* call is
|
|
# too dangerous to auto-run.
|
|
ALWAYS_CONFIRM_TOOLS = frozenset({
|
|
# Currently empty — kept for future destructive tools (e.g. delete_file,
|
|
# send_email). The shell policy handles the dangerous run_bash cases.
|
|
})
|
|
|
|
# Argument patterns that make an otherwise-allowed tool require
|
|
# confirmation regardless of mode. Each entry is (tool_name, regex).
|
|
# The regex is matched (case-insensitive) against the JSON-serialized
|
|
# args dict.
|
|
ALWAYS_CONFIRM_ARG_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|
# rm -rf with a broad or root target
|
|
("run_bash", re.compile(r"\brm\s+-rf?\s+(/|~|\*|\$HOME|\.\.)", re.I)),
|
|
# force-push to git
|
|
("run_bash", re.compile(r"\bgit\s+push\s+(-f|--force)", re.I)),
|
|
# privilege escalation
|
|
("run_bash", re.compile(r"\bsudo\b|\bsu\b\s+", re.I)),
|
|
# any use of docker from inside the sandbox (escape risk)
|
|
("run_bash", re.compile(r"\bdocker\b", re.I)),
|
|
# chmod world-writable
|
|
("run_bash", re.compile(r"\bchmod\s+[-\d]*7\d\d?", re.I)),
|
|
# network exfiltration tools
|
|
("run_bash", re.compile(r"\b(curl|wget|nc|netcat|ncat)\b", re.I)),
|
|
# overwriting a file with empty content = delete
|
|
("write_file", re.compile(r'"content"\s*:\s*"\s*"')),
|
|
]
|
|
|
|
|
|
def is_destructive(tool_name: str, args: dict[str, Any]) -> bool:
|
|
"""Return True if the call is in the destructive class."""
|
|
if tool_name in DESTRUCTIVE_TOOLS:
|
|
return True
|
|
for name, pat in ALWAYS_CONFIRM_ARG_PATTERNS:
|
|
if name == tool_name and pat.search(_args_blob(args)):
|
|
return True
|
|
# write_file emptying an existing file is treated as a delete in
|
|
# the caller; here we flag the empty-content case.
|
|
return False
|
|
|
|
|
|
def always_confirm_required(tool_name: str, args: dict[str, Any]) -> bool:
|
|
"""Return True if the call must prompt the user regardless of mode."""
|
|
if tool_name in ALWAYS_CONFIRM_TOOLS:
|
|
return True
|
|
for name, pat in ALWAYS_CONFIRM_ARG_PATTERNS:
|
|
if name == tool_name and pat.search(_args_blob(args)):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _args_blob(args: dict[str, Any]) -> str:
|
|
import json
|
|
try:
|
|
return json.dumps(args, ensure_ascii=False)
|
|
except (TypeError, ValueError):
|
|
return str(args)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2.3 Path scoping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Tools that take a filesystem path argument. The value is the key in
|
|
# the args dict that holds the path.
|
|
PATH_TOOLS: dict[str, str] = {
|
|
"read_file": "path",
|
|
"glob_files": "path",
|
|
"grep": "path",
|
|
"write_file": "path",
|
|
"edit_file": "path",
|
|
}
|
|
|
|
|
|
def check_path_scope(
|
|
tool_name: str,
|
|
args: dict[str, Any],
|
|
working_dir: Path,
|
|
) -> tuple[bool, str | None]:
|
|
"""Reject path-bearing tool calls whose target escapes working_dir.
|
|
|
|
Note: the Docker mount already constrains the *container's* view of
|
|
the filesystem; this check is a defense-in-depth layer on the host
|
|
side so a malicious path is rejected before it ever reaches docker.
|
|
"""
|
|
if tool_name not in PATH_TOOLS:
|
|
return True, None
|
|
raw = args.get(PATH_TOOLS[tool_name])
|
|
if not raw:
|
|
return True, None # missing arg is a schema problem, not a scope problem
|
|
try:
|
|
target = Path(raw)
|
|
if not target.is_absolute():
|
|
target = working_dir / target
|
|
target.resolve().relative_to(working_dir.resolve())
|
|
return True, None
|
|
except (ValueError, OSError, RuntimeError) as e:
|
|
return False, (
|
|
f"Path '{raw}' is outside the working directory "
|
|
f"({working_dir}). File tools may only touch paths inside "
|
|
f"the project root. ({e})"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2.3 Shell policy (run_bash denylist)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Binaries that must never be executed by the agent's shell tool, even
|
|
# under dangerouslySkipPermissions. Used as a hard denylist.
|
|
SHELL_DENYLIST_BINARIES = frozenset({
|
|
"docker", # sandbox escape / host control
|
|
"sudo", "su", # privilege escalation
|
|
"nc", "netcat", "ncat", # reverse shells / exfil
|
|
"curl", "wget", # exfil / SSRF — handled here AND in web policy
|
|
"chmod", "chown", # permission tampering
|
|
"mkfs", "dd", # destructive disk ops
|
|
"shutdown", "reboot", "halt", "poweroff",
|
|
"systemctl", "service",
|
|
"crontab", "at",
|
|
})
|
|
|
|
# Dangerous patterns matched against the raw command string.
|
|
SHELL_DENYLIST_PATTERNS = [
|
|
(re.compile(r"\brm\s+-rf?\s+(/|~|\*|\$HOME|\.\.)", re.I),
|
|
"recursive delete of a broad or root target"),
|
|
(re.compile(r">\s*/etc/", re.I),
|
|
"redirect into /etc/ (system files)"),
|
|
(re.compile(r"\bmkfs\b", re.I), "filesystem format command"),
|
|
(re.compile(r"\bdd\b\s+if=", re.I), "raw disk write via dd"),
|
|
(re.compile(r":\(\)\s*\{", re.I), "fork-bomb pattern"),
|
|
(re.compile(r"\b(eval|exec)\b", re.I),
|
|
"eval/exec in a shell command (injection risk)"),
|
|
(re.compile(r">\s*/dev/sd", re.I), "write to a block device"),
|
|
(re.compile(r"\bhistory\s+-c\b", re.I), "history wipe"),
|
|
(re.compile(r"\bexport\s+PATH=", re.I),
|
|
"PATH override (could shadow binaries)"),
|
|
]
|
|
|
|
|
|
def check_shell_policy(command: str) -> tuple[bool, str | None]:
|
|
"""Screen a ``run_bash`` command against the denylist."""
|
|
if not command or not command.strip():
|
|
return True, None
|
|
|
|
# Pattern check first (catches "rm -rf /" regardless of binary).
|
|
for pat, reason in SHELL_DENYLIST_PATTERNS:
|
|
if pat.search(command):
|
|
return False, f"Blocked by shell policy: {reason}."
|
|
|
|
# Tokenize and inspect the leading binary of each pipeline segment.
|
|
try:
|
|
tokens = shlex.split(command)
|
|
except ValueError:
|
|
# Unparseable (e.g. unbalanced quotes) — let the shell itself
|
|
# reject it, but flag for confirmation.
|
|
return False, "Shell command could not be parsed (unbalanced quotes)."
|
|
|
|
for tok in tokens:
|
|
if tok in ("|", "||", "&&", ";"):
|
|
continue
|
|
if tok.startswith("-"):
|
|
continue # flag
|
|
binary = Path(tok).name
|
|
if binary in SHELL_DENYLIST_BINARIES:
|
|
return False, (
|
|
f"Blocked by shell policy: binary '{binary}' is on the "
|
|
f"denylist for run_bash."
|
|
)
|
|
# First non-flag token is the command; after that, subsequent
|
|
# bare tokens are arguments. We only need to check each token
|
|
# against the denylist once.
|
|
return True, None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2.3 Web policy (SSRF guard)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Hosts that must never be fetched, regardless of mode.
|
|
WEB_DENYLIST_HOSTS = frozenset({
|
|
"169.254.169.254", # AWS / GCP / Azure cloud metadata
|
|
"metadata.google.internal", # GCP metadata
|
|
"metadata.azure.com", # Azure metadata
|
|
"0.0.0.0",
|
|
"::1",
|
|
"localhost",
|
|
})
|
|
|
|
|
|
def check_web_policy(url: str) -> tuple[bool, str | None]:
|
|
"""Reject URLs that target loopback / link-local / private ranges."""
|
|
from urllib.parse import urlparse
|
|
try:
|
|
parsed = urlparse(url)
|
|
except ValueError as e:
|
|
return False, f"Unparseable URL: {e}"
|
|
if parsed.scheme not in ("http", "https"):
|
|
return False, f"Unsupported scheme '{parsed.scheme}'."
|
|
host = parsed.hostname
|
|
if not host:
|
|
return False, "URL has no host component."
|
|
if host.lower() in WEB_DENYLIST_HOSTS:
|
|
return False, f"Blocked host '{host}' (loopback / metadata)."
|
|
|
|
# Resolve and check the IP family.
|
|
try:
|
|
infos = socket.getaddrinfo(host, None)
|
|
except socket.gaierror:
|
|
# Let the actual fetcher surface the DNS error.
|
|
return True, None
|
|
for info in infos:
|
|
ip = info[4][0]
|
|
try:
|
|
addr = ipaddress.ip_address(ip)
|
|
except ValueError:
|
|
continue
|
|
if addr.is_loopback or addr.is_link_local or addr.is_multicast:
|
|
return False, f"Blocked IP '{ip}' (loopback / link-local / multicast)."
|
|
if addr.is_private:
|
|
return False, f"Blocked IP '{ip}' (RFC1918 private range — SSRF guard)."
|
|
return True, None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Combined entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def check_tool_policy(
|
|
tool_name: str,
|
|
args: dict[str, Any],
|
|
working_dir: Path,
|
|
) -> tuple[bool, str | None]:
|
|
"""Run all policy layers. Returns (allowed, reason).
|
|
|
|
Called by ``agent.check_permission`` BEFORE the mode-based decision.
|
|
A False here is a hard block that no mode can override.
|
|
"""
|
|
# Layer 1: path scope.
|
|
ok, reason = check_path_scope(tool_name, args, working_dir)
|
|
if not ok:
|
|
return False, reason
|
|
|
|
# Layer 2: shell policy.
|
|
if tool_name == "run_bash":
|
|
ok, reason = check_shell_policy(args.get("command", ""))
|
|
if not ok:
|
|
return False, reason
|
|
|
|
# Layer 3: web / SSRF policy.
|
|
if tool_name == "webfetch":
|
|
ok, reason = check_web_policy(args.get("url", ""))
|
|
if not ok:
|
|
return False, reason
|
|
|
|
return True, None
|
|
|
|
|
|
__all__ = [
|
|
"DESTRUCTIVE_TOOLS",
|
|
"ALWAYS_CONFIRM_TOOLS",
|
|
"ALWAYS_CONFIRM_ARG_PATTERNS",
|
|
"is_destructive",
|
|
"always_confirm_required",
|
|
"check_path_scope",
|
|
"check_shell_policy",
|
|
"check_web_policy",
|
|
"check_tool_policy",
|
|
]
|