mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 13:00:37 +00:00
The mode from ocw-context/docs/reviewed-auto-mode.md (rev. 4), v1 scope.
coworker/reviewer.py (new)
- The 8.3 prompt verbatim, cache-shaped: instructions + known world (folders
and remotes only) + user-message history in the stable prefix; this turn's
request and ONE action in the suffix.
- parse_verdict: any defect (empty, non-JSON, unknown verdict) -> unsure.
There is no parse path that results in execution (8.5).
- Reviewer.review never raises: provider errors and timeouts -> unsure.
Metering counters (checks / verdicts / tokens) for 1.7.
- AGENT_DENY_MESSAGE: the terse, non-diagnostic refusal the agent gets on a
deny; the full reason goes to the user only (8.4 asymmetry).
coworker/engine.py
- Reviewer consulted ONLY when: attached, mode is AUTO_APPROVE, session
explicitly attended (unset is_attended counts as NOT attended, so
automations can never be reviewed), fewer than two denials this turn.
- Consulted ONLY on decisions the gate marked needs_user - hard denies
never reach it, so it can only turn "ask" into "allow" (1.2).
- One action per request, fired concurrently for all of a turn's escalating
calls before the sequential authorize loop (8.6): a verdict cannot land
on the wrong action, and approval cards still reach the human one at a
time in call order.
- allow -> runs, audited with the reason. deny -> blocked; user event
carries the full reviewer reason + allow_anyway; agent message carries
only AGENT_DENY_MESSAGE. unsure -> today's card.
- Reviewer sees the user's words only, extracted mechanically from
role=user messages - never agent output, never tool results (4.4).
coworker/permissions.py
- Mode.AUTO renamed Mode.BYPASS_APPROVALS ("bypass-approvals"); legacy
"auto" still parses via _missing_ so configs, saved sessions, and the
golden decision table are untouched.
- Mode.AUTO_APPROVE ("auto-approve"): gate-identical to INTERACTIVE except
session grants ("always allow this ...") no longer auto-allow - they
route to the reviewer instead (1.5: out-of-band standing policy may skip
the judge; an in-flow click may not). Config allowlists still skip.
- _domain_allowed(include_session=False) checks the user-settings list only.
coworker/config.py: auto_approve flag, off by default, _GLOBAL_ONLY (a
cloned repo cannot hand itself a looser reviewer). agent.py attaches the
Reviewer only when the flag is on; without it AUTO_APPROVE behaves exactly
like INTERACTIVE.
server/manager.py: autonomy audit ranks auto-approve above interactive
(turning the reviewer on IS raising autonomy) and below bypass.
GUI: mode picker label "Full access" -> "Bypass approvals" (wire value
"auto" kept). Verified live against the real sidecar; e2e spec updated;
tsc and all 111 GUI unit tests pass.
Tests: tests/test_auto_approve.py (33) - gate behaviour per mode, fail-
closed parsing, prompt shape, deny asymmetry, retry guard, attended
gating, hard-deny isolation, per-action verdict landing, and that the
reviewer never sees agent prose. Permission suites + golden table: 146
passing unchanged.
101 lines
4.4 KiB
Python
101 lines
4.4 KiB
Python
"""PR2 — the permission system protects its own settings, and in-project files that
|
|
execute later are never auto-approved.
|
|
|
|
The escalation being blocked: approve one ordinary-looking command, it quietly appends to
|
|
`risk_overrides.json`, and every future session is more permissive. That happens in the
|
|
DEFAULT interactive mode, so the protection must be a floor — not a property of a sandbox
|
|
or of any one mode. See `ocw-context/docs/reviewed-auto-mode.md` Part 3.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from coworker.permissions import Mode, PermissionEngine, protected_paths
|
|
from coworker.secrets import state_dir
|
|
|
|
ALL_MODES = [Mode.DISCUSS, Mode.PLAN, Mode.INTERACTIVE, Mode.CUSTOM, Mode.AUTO_APPROVE, Mode.BYPASS_APPROVALS]
|
|
|
|
|
|
def _engine(tmp_path, mode=Mode.INTERACTIVE, **kw):
|
|
# The state dir is redirected per-test by the autouse fixture in conftest, so the
|
|
# protected paths point somewhere isolated.
|
|
return PermissionEngine(workspace_root=tmp_path, mode=mode, **kw)
|
|
|
|
|
|
# -- the settings files ---------------------------------------------------------
|
|
@pytest.mark.parametrize("mode", ALL_MODES)
|
|
def test_write_to_settings_blocked_in_every_mode(tmp_path, mode):
|
|
eng = _engine(tmp_path, mode)
|
|
target = str(state_dir() / "risk_overrides.json")
|
|
d = eng.evaluate("write_file", {"path": target, "content": "x"}, None)
|
|
assert not d.allowed
|
|
assert not d.needs_user, "must be a hard refusal, not an approval the user can grant"
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ALL_MODES)
|
|
def test_shell_touching_settings_blocked_in_every_mode(tmp_path, mode):
|
|
eng = _engine(tmp_path, mode)
|
|
target = str(state_dir() / "risk_overrides.json")
|
|
d = eng.evaluate("run_shell", {"command": f'echo "{{}}" > "{target}"'}, None)
|
|
assert not d.allowed and not d.needs_user
|
|
|
|
|
|
def test_settings_write_beats_auto_mode_and_allowlists(tmp_path):
|
|
# Every auto-approve path must lose to the floor.
|
|
target = str(state_dir() / "config.toml")
|
|
auto = _engine(tmp_path, Mode.BYPASS_APPROVALS)
|
|
assert not auto.evaluate("write_file", {"path": target, "content": "x"}, None).allowed
|
|
|
|
custom = _engine(tmp_path, Mode.CUSTOM, auto_allow_tools={"write_file"})
|
|
assert not custom.evaluate("write_file", {"path": target, "content": "x"}, None).allowed
|
|
|
|
session = _engine(tmp_path)
|
|
session.allow_tool_for_session("write_file")
|
|
assert not session.evaluate("write_file", {"path": target, "content": "x"}, None).allowed
|
|
|
|
|
|
def test_settings_protected_via_patch_blob(tmp_path):
|
|
# The patch path is extracted from the blob, so this route is covered too.
|
|
eng = _engine(tmp_path, Mode.BYPASS_APPROVALS)
|
|
target = str(state_dir() / "workspace_trust.json")
|
|
patch = f"*** Begin Patch\n*** Update File: {target}\n@@\n-a\n+b\n*** End Patch"
|
|
assert not eng.evaluate("apply_patch", {"patch": patch}, None).allowed
|
|
|
|
|
|
def test_protected_paths_cover_the_grant_and_trust_stores():
|
|
names = {p.name for p in protected_paths()}
|
|
assert {"config.toml", "risk_overrides.json", "workspace_trust.json", "coworker.db"} <= names
|
|
|
|
|
|
# -- in-project files that run later --------------------------------------------
|
|
@pytest.mark.parametrize(
|
|
"rel",
|
|
[
|
|
".git/hooks/pre-commit",
|
|
".github/workflows/ci.yml",
|
|
".vscode/tasks.json",
|
|
".coworker/config.toml",
|
|
],
|
|
)
|
|
def test_protected_in_project_never_auto_approved(tmp_path, rel):
|
|
# Writable (inside the root) but must always reach a human, even in auto mode.
|
|
for mode in (Mode.BYPASS_APPROVALS, Mode.CUSTOM, Mode.INTERACTIVE):
|
|
eng = _engine(tmp_path, mode, auto_allow_tools={"write_file"})
|
|
eng.allow_tool_for_session("write_file")
|
|
d = eng.evaluate("write_file", {"path": rel, "content": "x"}, None)
|
|
assert not d.allowed, f"{rel} auto-approved in {mode.value}"
|
|
assert d.needs_user, f"{rel} should ask, not hard-deny, in {mode.value}"
|
|
|
|
|
|
def test_ordinary_project_file_still_auto_approves(tmp_path):
|
|
# The protection must not leak onto normal edits.
|
|
eng = _engine(tmp_path, Mode.BYPASS_APPROVALS)
|
|
assert eng.evaluate("write_file", {"path": "src/app.py", "content": "x"}, None).allowed
|
|
|
|
|
|
def test_lookalike_paths_are_not_protected(tmp_path):
|
|
# A file merely NAMED like a hook, outside the protected dirs, is ordinary.
|
|
eng = _engine(tmp_path, Mode.BYPASS_APPROVALS)
|
|
assert eng.evaluate("write_file", {"path": "docs/pre-commit.md", "content": "x"}, None).allowed
|