Files
openworker/coworker/permissions.py
T
Devika Verma 5aa27e2c76 Step 3b: web_search -> EGRESS + the 1.9 egress cards
web_search reclassified EGRESS (spec 2.2, decided 2026-08-12): the destination is
fixed (the configured provider) but the query is model-chosen free text - the same
outbound channel web_fetch's URL is. It ran completely ungated in every mode until
now; it gates like any egress from here on, which also puts it in front of the
Auto-Approve reviewer.

The egress approval cards (spec 1.9):
- web_fetch offers "Always allow <host> this session" -> ALWAYS_DOMAIN. Tool-wide
  "always" is gone from the card AND server-refused (_grant_offered): it would
  cover every future destination, and the live A/B showed exactly that (one click
  on a bbc.com card ran promptless fetches to hosts no card ever named).
- www. stripped at grant minting (allow_domain_for_session) - pure spelling only,
  never eTLD+1. The card button shows the exact spelling the grant mints.
- web_search offers "Always allow searches this session" -> ALWAYS_TOOL (tool-wide
  IS provider-wide for a fixed destination), with the card naming the LIVE
  destination: "Queries go to your configured search provider (currently: <name>)".
  Provider resolved when the card is raised (engine.approval_extras hook), not at
  session start.
- Provider-change invalidation: set_web_search clears the web_search session grant
  in every live engine when the provider actually changes - the grant was consent
  to a named destination.
- Auto-Approve fall-through cards hide every session "always" button: grants don't
  skip the reviewer there (1.5), and a button that lies is worse than none.
- scopeNote tells the truth for egress: "leaves this computer -> <host>" replaces
  "stays on this computer" on fetch/search cards.

Corpora gain web_search cases (benign 22 / dangerous 17 / injection 14), including
query-borne secret exfiltration and a planted search-the-credentials injection.

Tests: test_egress_and_overrides (EGRESS class, gating, www-strip, 1.5 in
Auto-Approve), test_approval_integrity (tool-wide refused for URL-carrying egress,
kept for web_search; provider-change invalidation), ApprovalCard.test.tsx (domain
button + www-strip, provider line, Auto-Approve hides always). Full suites pass;
the 22 pre-existing failures (Slack fake-gateway timeouts, a Windows file-lock
rename) fail identically on the pre-change tree.
2026-08-13 08:41:44 -07:00

542 lines
24 KiB
Python

"""Permission engine — decides allow / deny / ask-user for each proposed tool call.
Modes: Plan (read-only) · Interactive (auto reads, ask on writes/commands) · Auto
(allow, still path-scoped). Refined by argument patterns (path-under-root, command
prefixes) and a session allowlist. The engine only *decides*; the turn engine routes
`needs_user` decisions to a surface for approval and records the outcome.
"""
from __future__ import annotations
import re
import shlex
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlsplit
# Constructs whose *contents* we cannot evaluate, so a command carrying one is never
# eligible for prefix auto-run: command/process substitution, redirection (writes anywhere
# the allowlist never vetted), and variable expansion (the value was set out of view).
_OPAQUE_CONSTRUCTS = ("`", "$(", "$", ">", "<", "(")
# Separators that chain several commands into one string. Each part is checked independently
# against the allowlist — the old behaviour rejected the whole command outright, which both
# refused harmless `git status && git diff` and (because `-exec` needs no separator) still
# auto-allowed `find . -exec rm {} +` under a `find` prefix.
_SEPARATORS = ("&&", "||", ";", "|&", "|", "&", "\n", "\r")
# Programs that run *another* program named in their arguments. A prefix rule on the outer
# program can never vouch for the inner one, so these always fall through to approval.
_ARG_EXECUTORS = {
"xargs", "env", "nohup", "nice", "stdbuf", "timeout", "watch", "sudo", "doas",
"ssh", "docker", "podman", "kubectl", "npx", "pnpx", "bunx", "uvx",
}
# Interpreters carrying inline code, e.g. `python -c "..."`, `node -e "..."`.
_INLINE_CODE_FLAGS = {"-c", "-e", "--eval", "--command", "-Command", "-EncodedCommand"}
_INTERPRETERS = {
"sh", "bash", "zsh", "dash", "ksh", "fish", "powershell", "pwsh", "cmd",
"python", "python3", "node", "deno", "bun", "ruby", "perl", "php",
}
# Flags that turn a search/list tool into an execution or deletion tool.
_DANGEROUS_FLAGS = {"-exec", "-execdir", "-delete", "-ok", "-okdir", "-fprintf"}
def _split_commands(command: str) -> list[str]:
"""Split a compound command on its separators. Longest separators first so `&&` isn't
read as two `&`. Purely textual — quoted separators are not respected, which is
deliberate: over-splitting only ever produces MORE parts to justify, never fewer."""
parts = [command]
for sep in _SEPARATORS:
parts = [chunk for part in parts for chunk in part.split(sep)]
return [p.strip() for p in parts if p.strip()]
def _is_prefix_eligible(argv: list[str]) -> bool:
"""False when a parsed command can never be vouched for by a prefix rule, because it
runs code the rule never saw: another program named in its arguments, inline source, or
an execution/deletion flag."""
if not argv:
return False
program = Path(argv[0]).name.lower()
program = program[:-4] if program.endswith(".exe") else program
if program in _ARG_EXECUTORS:
return False
if program in _INTERPRETERS and any(a in _INLINE_CODE_FLAGS for a in argv[1:]):
return False
if any(a.lower() in _DANGEROUS_FLAGS for a in argv[1:]):
return False
return True
def protected_paths() -> list[Path]:
"""Files that govern the permission system itself. Nothing the agent does may write
these — in any mode, through any tool. The escalation this blocks is: approve one
ordinary-looking command, it quietly appends to the rule file, every future session is
more permissive. That happens in the DEFAULT interactive mode, so this cannot be a
property of a sandbox or of any one mode; it is a floor."""
from .secrets import state_dir
base = state_dir()
return [
base / "config.toml",
base / "risk_overrides.json",
base / "workspace_trust.json",
base / "unattended.json",
base / "coworker.db", # session records carry the saved "always allow" grants
base / "secrets.json",
base / "inbox_routing.json",
]
# Files INSIDE a workspace that execute on a later, innocuous-looking action. An edit here
# is a deferred command: writing `.git/hooks/pre-commit` and then running `git commit` runs
# it. They stay writable, but never WITHOUT a human — no auto-approve path may clear them.
_PROTECTED_IN_PROJECT = (
".git/hooks/",
".github/workflows/",
".gitlab-ci.yml",
".vscode/tasks.json",
".coworker/", # workspace policy + skills the agent would otherwise self-grant
)
def _is_protected_in_project(candidate: Path) -> bool:
posix = candidate.as_posix()
return any(
(f"/{marker}" in posix or posix.startswith(marker))
if marker.endswith("/")
else posix.endswith("/" + marker)
for marker in _PROTECTED_IN_PROJECT
)
def _host_of(url_or_domain: str) -> str:
"""The lowercased host of a URL, or a bare domain as-is. `''` when there's nothing
usable. Accepts both `https://docs.python.org/x` and `docs.python.org`."""
s = (url_or_domain or "").strip().lower()
if not s:
return ""
if "://" in s:
return urlsplit(s).hostname or ""
return urlsplit("//" + s).hostname or s
# The argument that names a write tool's target path, when it's a single top-level field.
# Patch/diff tools carry their paths inside the blob instead — extracted in `write_paths`.
_PATH_ARG: dict[str, str] = {"write_file": "path", "replace_in_file": "path"}
# apply_patch (Codex format) file headers, and unified-diff `+++ b/<path>` headers.
_APPLY_PATCH_FILE = re.compile(
r"^\*\*\* (?:Add|Update|Delete) File: (.+)$", re.MULTILINE
)
_APPLY_PATCH_MOVE = re.compile(r"^\*\*\* Move to: (.+)$", re.MULTILINE)
_UNIFIED_DIFF_FILE = re.compile(r"^\+\+\+ (?:b/)?(.+?)\s*$", re.MULTILINE)
def write_paths(tool_name: str, arguments: dict[str, Any]) -> tuple[list[str], bool]:
"""Every filesystem path a write tool would touch, for root scoping.
Returns ``(paths, located)``. ``located`` is False when the path can't be determined
(an unknown write tool, or a patch/diff blob with no parseable file header) — the caller
must then fail closed rather than skip scoping, so an unscoped write can't slip through
auto/custom mode.
"""
arg = _PATH_ARG.get(tool_name)
if arg is not None:
value = arguments.get(arg)
return ([str(value)], True) if value else ([], False)
if tool_name == "apply_patch":
blob = str(arguments.get("patch", ""))
paths = _APPLY_PATCH_FILE.findall(blob) + _APPLY_PATCH_MOVE.findall(blob)
return ([p.strip() for p in paths], bool(paths))
if tool_name == "apply_unified_diff":
blob = str(arguments.get("diff", ""))
paths = [p for p in _UNIFIED_DIFF_FILE.findall(blob) if p and p != "/dev/null"]
return (paths, bool(paths))
# Unknown write tool (e.g. one promoted to write via a user override): we cannot locate
# its path, so it cannot be auto-scoped.
return ([], False)
from .risk import ( # re-exported for back-compat (manager.py imports WRITE_TOOLS)
SHELL_TOOL,
WRITE_TOOLS,
RiskClass,
RiskOverrides,
classify,
is_consequential,
)
class Mode(str, Enum):
DISCUSS = "discuss" # read-only conversation: no edits, no planning workflow
PLAN = (
"plan" # read-only + the planning contract (explore → propose_plan → execute)
)
INTERACTIVE = "interactive" # ask for approval (default)
# Renamed from "auto" (spec §1.5, 2026-08-12): "bypass" names the action — switching a
# safety system off — and can't be confused with AUTO_APPROVE in a picker. Deliberately
# NOT "bypass-ALL-approvals": Phase 1's floors (settings files, out-of-root writes,
# `.git/hooks`) still hold in this mode, so "all" would be a false promise.
BYPASS_APPROVALS = "bypass-approvals" # full access (minus the hard floors)
# Interactive, but an LLM reviewer judges each would-be approval card first: clear
# allows run without a prompt, everything else still reaches the human. The reviewer
# can only turn "ask" into "allow", never "blocked" into "allow" (spec §1.2). With no
# reviewer plugged into the engine this mode behaves exactly like INTERACTIVE.
AUTO_APPROVE = "auto-approve"
CUSTOM = "custom" # interactive + auto-allow the config's `auto_allow` tools
@classmethod
def _missing_(cls, value: object) -> "Mode | None":
# Legacy spelling from configs, saved sessions, and older UIs.
if value == "auto":
return cls.BYPASS_APPROVALS
return None
# Modes whose enforcement is read-only. DISCUSS and PLAN share the same gate; they differ
# only in intent — PLAN additionally drives the agent toward a propose_plan approval.
READ_ONLY_MODES = frozenset({Mode.DISCUSS, Mode.PLAN})
@dataclass
class Decision:
allowed: bool
reason: str = ""
needs_user: bool = False # True → surface should prompt the user for approval
# Set when a task-scoped standing rule allowed the call ("tool → target") so the
# engine can audit the exact rule and the tool card can say so (§25).
rule: str = ""
def standing_rule_candidate(
tool_name: str,
arguments: dict[str, Any],
metadata: Any = None,
overrides: Optional[RiskOverrides] = None,
) -> Optional[str]:
"""The target value iff this call is eligible for a task-scoped standing rule
(UX-DECISIONS §25): external-risk only (never exec/write-local — shell asks forever),
the tool must declare a target argument, and the call must actually name a target.
Returns None otherwise — ineligible calls keep parking approvals as today."""
from .connectors.tool_defs import target_arg_for
if classify(tool_name, metadata, overrides) is not RiskClass.EXTERNAL:
return None
arg = target_arg_for(tool_name)
if arg is None:
return None
value = str((arguments or {}).get(arg) or "").strip()
return value or None
@dataclass
class PermissionEngine:
workspace_root: Path
mode: Mode = Mode.INTERACTIVE
allowed_commands: list[str] = field(default_factory=list)
auto_allow_tools: set[str] = field(default_factory=set)
session_allow_tools: set[str] = field(default_factory=set)
session_allow_commands: set[str] = field(default_factory=set)
# Egress domains that auto-run without a prompt: `allowed_domains` from user config, plus
# `session_allow_domains` minted by "Always allow this domain". Matched by exact host or
# subdomain suffix (see `_domain_allowed`).
allowed_domains: list[str] = field(default_factory=list)
session_allow_domains: set[str] = field(default_factory=set)
# Task-scoped standing rules (§25): {tool: {allowed targets}}, seeded from the owning
# ScheduledTask's target-shaped entries. Kept by reference and re-read every check, so a
# rule minted mid-run ("Allow every time") applies to the run's next call too.
task_rules: dict[str, set[str]] = field(default_factory=dict)
# User-local risk override resolver (Phase 2). None → use the base classification.
risk_overrides: Optional[RiskOverrides] = None
# Shared, possibly-mutable list of roots (RootDir-like / dicts). When omitted, the single
# `workspace_root` is the sole writable root (back-compat). Kept by reference and re-read on
# every check, so runtime add/remove of folders takes effect without rebuilding the engine.
roots: Optional[list] = None
def __post_init__(self) -> None:
self.workspace_root = Path(self.workspace_root).expanduser().resolve()
self.auto_allow_tools = set(self.auto_allow_tools)
if self.roots is None:
self.roots = [{"path": self.workspace_root, "writable": True}]
def _resolved_roots(self) -> list[tuple[Path, bool]]:
out: list[tuple[Path, bool]] = []
for r in self.roots or []:
if isinstance(r, dict):
p, w = r["path"], bool(r.get("writable", False))
elif isinstance(r, (str, Path)):
p, w = r, True
else: # duck-typed RootDir-like
p, w = getattr(r, "path"), bool(getattr(r, "writable", False))
out.append((Path(p).expanduser().resolve(), w))
return out
def evaluate(
self, tool_name: str, arguments: dict[str, Any], metadata: Any = None
) -> Decision:
arguments = arguments or {}
is_connector = getattr(metadata, "category", "") == "connector"
risk = classify(tool_name, metadata, self.risk_overrides)
is_write = risk is RiskClass.WRITE_LOCAL
is_shell = risk is RiskClass.EXEC
is_egress = risk is RiskClass.EGRESS
consequential = is_consequential(risk)
# SELF-PROTECTION FLOOR — runs before mode, allowlists and every auto-approve path,
# because the escalation it blocks happens in the DEFAULT mode. No verdict below can
# reach these files, and no human click in the flow can grant it either: loosening
# requires editing the files out-of-band.
if is_write or is_shell:
hit = self._touches_protected(tool_name, arguments, is_shell)
if hit is not None:
return Decision(
False,
f"refusing to modify OpenWorker's own settings: {hit}",
needs_user=False,
)
# Discuss / plan modes: read-only.
if self.mode in READ_ONLY_MODES and consequential:
return Decision(
False, f"{self.mode.value} mode is read-only", needs_user=False
)
# Path scoping for writes (all modes): every path the write touches must land in a
# writable root. A write whose path can't be located is not scoped-able, so it fails
# closed to approval rather than slipping through auto/custom unscoped.
needs_human_for_protected = False
if is_write:
paths, located = write_paths(tool_name, arguments)
if not located:
return Decision(
False,
"cannot determine the write path to scope",
needs_user=True,
)
for path in paths:
if not self._under_writable_root(path):
return Decision(
False, f"path is not in a writable directory: {path}"
)
# In-project files that run on a later action (git hooks, CI configs) may be
# edited, but never by an auto-approve path — a human must see it.
if _is_protected_in_project(self._candidate(path)):
needs_human_for_protected = True
# Non-consequential tools always run.
if not consequential:
return Decision(True, "low risk")
# A protected in-project target (git hooks, CI config) skips every auto-approve path
# below — including auto mode and the session/config allowlists — and asks.
if needs_human_for_protected:
return Decision(
False,
"this file runs automatically later — approval required",
needs_user=True,
)
# Full access.
if self.mode is Mode.BYPASS_APPROVALS:
return Decision(True, "full access")
# interactive / custom / auto-approve: allowlists.
#
# In AUTO_APPROVE, session grants ("always allow this …" clicks) deliberately do
# NOT auto-allow (spec §1.5): out-of-band standing policy — the user-settings
# allowlists checked via `_command_allowed` / config `allowed_domains` — may skip
# the judge, but an in-flow click may not. A domain grant matches on host only and
# is blind to the path and query string (where exfiltration rides), and command
# grants replay as exact text; both are precisely what the reviewer should see.
# The skipped checks return `needs_user` instead, which routes to the reviewer.
honor_session_grants = self.mode is not Mode.AUTO_APPROVE
if is_shell:
command = str(arguments.get("command", ""))
if self._command_allowed(command):
return Decision(True, "command on allowlist")
if (
honor_session_grants
and command
and command in self.session_allow_commands
):
return Decision(True, "command allowed for session")
if is_egress:
url = str(arguments.get("url", ""))
if self._domain_allowed(url, include_session=honor_session_grants):
return Decision(True, "domain on allowlist")
if (
honor_session_grants
and tool_name in self.session_allow_tools
and not is_connector
):
return Decision(True, "tool allowed for session")
# Task-scoped standing rules (§25): tool + exact target, owned by the automation.
# Deliberately NOT subject to the connector exclusion above — the exact-target
# binding is what makes auto-allowing a connector tool safe. Never for exec risk
# (candidate extraction is external-risk-only), and additive on top of the mode:
# read-only modes already returned before this point.
if tool_name in self.task_rules:
target = standing_rule_candidate(
tool_name, arguments, metadata, self.risk_overrides
)
if target and target in self.task_rules[tool_name]:
rule = f"{tool_name}{target}"
return Decision(True, f"allowed by standing rule: {rule}", rule=rule)
# Custom mode auto-approves the configured tools.
if self.mode is Mode.CUSTOM and tool_name in self.auto_allow_tools:
return Decision(True, "auto-allowed by config")
# Otherwise: ask the user.
return Decision(False, "requires approval", needs_user=True)
# -- session memory ---------------------------------------------------------
def allow_tool_for_session(self, tool_name: str) -> None:
self.session_allow_tools.add(tool_name)
def allow_command_for_session(self, command: str) -> None:
if command:
self.session_allow_commands.add(command)
def allow_domain_for_session(self, url_or_domain: str) -> None:
"""Remember an egress destination for this session ("Always allow this domain").
A leading `www.` is stripped at minting (§1.9): `bbc.com` and `www.bbc.com` are one
site in every user's mental model, and the suffix match in `_domain_allowed` already
treats `www.bbc.com` as a subdomain of `bbc.com`. Pure spelling only — never eTLD+1
or any broader normalisation, which would silently widen the grant."""
host = _host_of(url_or_domain)
if host.startswith("www."):
host = host[4:]
if host:
self.session_allow_domains.add(host)
# -- helpers ----------------------------------------------------------------
def _candidate(self, path: str) -> Path:
# Relative paths resolve against the primary (workspace_root); absolute/`~` taken as-is.
p = Path(path).expanduser()
return p.resolve() if p.is_absolute() else (self.workspace_root / p).resolve()
def _under_root(self, path: str) -> bool:
candidate = self._candidate(path)
for rp, _ in self._resolved_roots():
try:
candidate.relative_to(rp)
return True
except ValueError:
continue
return False
def _under_writable_root(self, path: str) -> bool:
candidate = self._candidate(path)
for rp, writable in self._resolved_roots():
if not writable:
continue
try:
candidate.relative_to(rp)
return True
except ValueError:
continue
return False
def _touches_protected(
self, tool_name: str, arguments: dict[str, Any], is_shell: bool
) -> Optional[str]:
"""The protected settings path this call would modify, or None.
For writes we resolve the real target. For shell we can only inspect the command
text — parser depth, so it stops accidents and casual attempts, not a determined
adversary (that needs the OS sandbox). Cheap and worth having regardless.
Shell matching is on the FULL path only, never a bare filename: matching
`secrets.json` anywhere in a command would refuse unrelated work that merely
mentions the name. A command naming the real settings path is refused whether it
reads or writes — we cannot tell which from text, and the conservative direction is
the right one for these files.
"""
targets = [str(p) for p in protected_paths()]
if is_shell:
command = str(arguments.get("command", ""))
if not command:
return None
lowered = command.replace("\\", "/").lower()
for target in targets:
if target.replace("\\", "/").lower() in lowered:
return target
return None
paths, located = write_paths(tool_name, arguments)
if not located:
return None # unlocatable writes are already failed closed by the caller
resolved = {str(self._candidate(p)) for p in paths}
for target in targets:
if str(Path(target).resolve()) in resolved:
return target
return None
def _domain_allowed(self, url: str, *, include_session: bool = True) -> bool:
"""True when the URL's host is an allowed egress destination — an exact match or a
subdomain of an allowed domain (so `docs.python.org` matches `python.org`, but
`evil-python.org` never matches `python.org`).
`include_session=False` (AUTO_APPROVE mode) checks the user-settings list only:
mid-session "always allow this domain" clicks don't bypass the reviewer there."""
host = _host_of(url)
if not host:
return False
allowed = {d for d in (_host_of(x) for x in self.allowed_domains) if d}
if include_session:
allowed |= self.session_allow_domains
for dom in allowed:
if host == dom or host.endswith("." + dom):
return True
return False
def _command_allowed(self, command: str) -> bool:
"""True only when EVERY part of a (possibly compound) command is independently
covered by an allowlist entry.
An allowlist entry auto-runs without approval, and a prefix rule can only vouch for
the words it matched — everything after is unexamined. So this does two jobs:
guarantee the unexamined tail can only be arguments, then match the beginning.
- Constructs whose contents we can't evaluate (substitution, redirection, variable
expansion) disqualify the whole command.
- Compound commands are split and each part checked on its own, so
`git status && git diff` runs when both are allowed, while
`git status && rm -rf ~` does not.
- Parts that run code named in their arguments (`xargs`, `sh -c`, `find -exec`,
`-delete`) are never prefix-eligible: a `find` rule must not auto-run
`find . -exec rm {} +`.
- Matching is on parsed words, not text, so `git status` covers `git status -s` but
never `git statusfoo` or a bare `git`.
"""
if not command.strip():
return False
if any(tok in command for tok in _OPAQUE_CONSTRUCTS):
return False
parts = _split_commands(command)
if not parts:
return False
prefixes: list[list[str]] = []
for allowed in self.allowed_commands:
try:
prefix = shlex.split(allowed)
except ValueError:
continue
if prefix:
prefixes.append(prefix)
if not prefixes:
return False
for part in parts:
try:
argv = shlex.split(part)
except ValueError:
return False # unbalanced quotes etc. — treat as not-allowlisted
if not argv or not _is_prefix_eligible(argv):
return False
if not any(argv[: len(p)] == p for p in prefixes):
return False
return True