Files
openworker/coworker/risk.py
T
Devika Verma a442e0e0b8 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.
2026-08-11 11:37:46 -07:00

80 lines
3.4 KiB
Python

"""Risk classes for tools — the intrinsic side-effect category that drives permission
gating (and, later in Phase 2, unattended Inbox routing).
This replaces the hardcoded ``WRITE_TOOLS`` / ``SHELL_TOOL`` name sets the permission engine
used to carry inline: risk is now a declared property a single ``classify`` reads.
A tool's *effective* risk = an optional user-local override (Phase 2) ?? the base
classification here. Built-in vetted tools are classified by name; anything else falls back
to its aisuite metadata (``requires_approval`` → external) or is treated as read.
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Callable, Optional
class RiskClass(str, Enum):
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
EXEC = "exec" # runs commands — mode-gated
EXTERNAL = "external" # side effects off the machine — the unattended Inbox hook
# 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"}
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] = {
**{name: RiskClass.WRITE_LOCAL for name in WRITE_TOOLS},
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).
RiskOverrides = Callable[[str], Optional["RiskClass"]]
def classify(
tool_name: str, metadata: Any = None, overrides: Optional[RiskOverrides] = None
) -> RiskClass:
"""Effective risk of a tool call. A user override may *relax* a metadata/MCP tool (the
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:
ov = overrides(tool_name)
if ov is not None:
if base is None or _STRICTNESS[ov] >= _STRICTNESS[base]:
return ov
# A loosening override on a built-in is ignored: fall through to the base class.
if base is not None:
return base
if bool(getattr(metadata, "requires_approval", False)):
return RiskClass.EXTERNAL
return RiskClass.READ
def is_consequential(risk: RiskClass) -> bool:
"""Anything but a pure read needs the permission engine's attention."""
return risk is not RiskClass.READ