Files
agent-harness/agent-security/tool_policy.py

326 lines
12 KiB
Python

"""Tool permission & parameter-scoping policy.
Implements confirmation for destructive actions and
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, reason).
# The regex is matched (case-insensitive) against the JSON-serialized
# args dict.
#
# NOTE: for ``run_bash``, anything already covered by
# ``SHELL_DENYLIST_BINARIES`` or ``SHELL_DENYLIST_PATTERNS`` (rm -rf,
# sudo/su, docker, chmod, curl/wget/nc/netcat/ncat) is a hard block in
# layer 1 (``check_tool_policy``) and never reaches this layer, so it
# is deliberately NOT duplicated here. Only patterns that layer 1
# does *not* already hard-block belong in this list.
ALWAYS_CONFIRM_ARG_PATTERNS: list[tuple[str, re.Pattern[str], str]] = [
# force-push to git (git itself is not on the shell denylist)
("run_bash", re.compile(r"\bgit\s+push\s+(-f|--force)", re.I),
"force-push to git (rewrites remote history)"),
# overwriting a file with empty content = delete
("write_file", re.compile(r'"content"\s*:\s*"\s*"'),
"write_file with empty content on an existing file (delete-via-empty)"),
]
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, _reason 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]
) -> tuple[bool, str | None]:
"""Return (True, reason) if the call must prompt the user regardless
of mode, else (False, None).
Note: for ``run_bash`` this only needs to catch cases NOT already
hard-blocked by the shell policy (layer 1); anything on
``SHELL_DENYLIST_BINARIES`` / ``SHELL_DENYLIST_PATTERNS`` never
reaches this function at all.
"""
if tool_name in ALWAYS_CONFIRM_TOOLS:
return True, f"'{tool_name}' always requires confirmation."
for name, pat, reason in ALWAYS_CONFIRM_ARG_PATTERNS:
if name == tool_name and pat.search(_args_blob(args)):
return True, reason
return False, None
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",
]