mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 23:03:22 +00:00
1. Grant validation. POST /v1/inbox/{id}/resolve takes a raw resolution string
and approval_outcome() previously honoured whatever it named. The GUI
deliberately withholds the tool-wide "always allow" for run_shell (the
command-scoped grant is the narrower option), for save_skill (every skill
proposal gets its own review) and for connectors; Slack mirrors render only
approve/deny. So any local API caller could mint a session-wide,
any-argument shell grant -- a vocabulary the design says must not exist.
_grant_offered() now mirrors the card's own rules on the server and
downgrades an unoffered grant to a one-time approval, writing a
`grant_refused` audit row. Applied to the "always" channel vocabulary too,
so a Slack reply cannot mint what the in-app card would refuse. MCP tools
are covered alongside connectors: they are not category=connector but are
external, and the grant would be unbounded over every future argument.
A failed always_task mint is now audited rather than silently downgraded.
2. Autonomy transitions. Mode changes (WS set_mode) and the unattended toggle
were unrecorded, so "who turned on auto mode, and when" was unanswerable
from the audit store -- at odds with the per-call trail the engine keeps
everywhere else. Both now write an audit row tagged raised/lowered, so
autonomy increases can be filtered. set_unattended moves onto the manager
so REST and any future surface record it the same way; no-op flips are not
recorded.
15 new tests. test_server's two failures are pre-existing on the unmodified
tree (Windows file-permission errors in pathlib), unrelated to this change.
Design of record: ocw-context/docs/reviewed-auto-mode.md Part 3.
157 lines
5.5 KiB
Python
157 lines
5.5 KiB
Python
"""PR4 — the server validates approval answers, and autonomy changes are recorded.
|
|
|
|
`POST /v1/inbox/{id}/resolve` takes a raw resolution string, so without validation any
|
|
local API caller could mint a grant the UI deliberately never offers — e.g. a session-wide,
|
|
any-argument shell grant, which `ApprovalCard.tsx` withholds on purpose in favour of the
|
|
narrower command-scoped one.
|
|
|
|
See `ocw-context/docs/reviewed-auto-mode.md` Part 3.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from coworker.engine import ApprovalOutcome
|
|
from coworker.server.manager import SessionManager
|
|
|
|
|
|
@pytest.fixture
|
|
def manager(tmp_path):
|
|
mgr = SessionManager(workspace=str(tmp_path), data_dir=tmp_path / "data")
|
|
yield mgr
|
|
mgr.audit_store.close()
|
|
|
|
|
|
def _request(tool: str, arguments=None, metadata=None):
|
|
return SimpleNamespace(
|
|
tool_name=tool, arguments=arguments or {}, metadata=metadata, reason=""
|
|
)
|
|
|
|
|
|
def _stages(manager, session_id: str) -> list[str]:
|
|
rows = manager.audit_store.list(session_id=session_id)
|
|
return [r.get("stage") for r in rows]
|
|
|
|
|
|
# -- grants the UI never offers are downgraded ----------------------------------
|
|
def test_always_tool_refused_for_shell(manager):
|
|
out = manager.approval_outcome(
|
|
"always_tool", _request("run_shell", {"command": "rm -rf /"}), "s1"
|
|
)
|
|
assert out is ApprovalOutcome.ONCE, "a session-wide any-command shell grant must not stand"
|
|
assert "grant_refused" in _stages(manager, "s1")
|
|
|
|
|
|
def test_always_tool_refused_for_connector_and_external(manager):
|
|
connector = SimpleNamespace(requires_approval=True, category="connector")
|
|
assert (
|
|
manager.approval_outcome("always_tool", _request("gmail_send", {}, connector), "s2")
|
|
is ApprovalOutcome.ONCE
|
|
)
|
|
# An MCP tool is not category=connector but is still external — same reasoning applies:
|
|
# the grant would be unbounded over every future argument.
|
|
mcp = SimpleNamespace(requires_approval=True, category="mcp")
|
|
assert (
|
|
manager.approval_outcome(
|
|
"always_tool", _request("mcp__github__create_issue", {}, mcp), "s3"
|
|
)
|
|
is ApprovalOutcome.ONCE
|
|
)
|
|
|
|
|
|
def test_always_tool_refused_for_save_skill(manager):
|
|
assert (
|
|
manager.approval_outcome("always_tool", _request("save_skill", {}), "s4")
|
|
is ApprovalOutcome.ONCE
|
|
)
|
|
|
|
|
|
def test_always_command_only_for_shell(manager):
|
|
assert (
|
|
manager.approval_outcome(
|
|
"always_command", _request("write_file", {"path": "a"}), "s5"
|
|
)
|
|
is ApprovalOutcome.ONCE
|
|
)
|
|
|
|
|
|
def test_always_domain_only_for_egress_with_a_url(manager):
|
|
assert (
|
|
manager.approval_outcome("always_domain", _request("write_file", {"path": "a"}), "s6")
|
|
is ApprovalOutcome.ONCE
|
|
)
|
|
assert (
|
|
manager.approval_outcome("always_domain", _request("web_fetch", {}), "s7")
|
|
is ApprovalOutcome.ONCE
|
|
)
|
|
|
|
|
|
# -- the legitimate grants still work -------------------------------------------
|
|
def test_legitimate_grants_survive(manager):
|
|
assert (
|
|
manager.approval_outcome(
|
|
"always_tool", _request("write_file", {"path": "a.txt"}), "ok1"
|
|
)
|
|
is ApprovalOutcome.ALWAYS_TOOL
|
|
)
|
|
assert (
|
|
manager.approval_outcome(
|
|
"always_command", _request("run_shell", {"command": "npm test"}), "ok2"
|
|
)
|
|
is ApprovalOutcome.ALWAYS_COMMAND
|
|
)
|
|
assert (
|
|
manager.approval_outcome(
|
|
"always_domain", _request("web_fetch", {"url": "https://docs.python.org/3"}), "ok3"
|
|
)
|
|
is ApprovalOutcome.ALWAYS_DOMAIN
|
|
)
|
|
assert "grant_refused" not in _stages(manager, "ok1")
|
|
|
|
|
|
def test_plain_vocabularies_unchanged(manager):
|
|
req = _request("write_file", {"path": "a.txt"})
|
|
assert manager.approval_outcome("allow", req, "s8") is ApprovalOutcome.ONCE
|
|
assert manager.approval_outcome("once", req, "s8") is ApprovalOutcome.ONCE
|
|
assert manager.approval_outcome("deny", req, "s8") is ApprovalOutcome.DENY
|
|
assert manager.approval_outcome("nonsense", req, "s8") is ApprovalOutcome.DENY
|
|
|
|
|
|
def test_always_maps_to_tool_grant_and_is_validated(manager):
|
|
# The Inbox/channel vocabulary "always" maps to a tool grant — and is validated too, so
|
|
# a Slack reply cannot mint what the in-app card would refuse.
|
|
assert (
|
|
manager.approval_outcome("always", _request("run_shell", {"command": "x"}), "s9")
|
|
is ApprovalOutcome.ONCE
|
|
)
|
|
|
|
|
|
# -- autonomy changes are recorded ----------------------------------------------
|
|
def test_unattended_transition_audited(manager):
|
|
manager.set_unattended("s10", True)
|
|
rows = manager.audit_store.list(session_id="s10")
|
|
assert [r["stage"] for r in rows] == ["unattended_changed"]
|
|
assert rows[0]["status"] == "raised"
|
|
|
|
manager.set_unattended("s10", False)
|
|
assert [r["stage"] for r in manager.audit_store.list(session_id="s10")][-1] == (
|
|
"unattended_changed"
|
|
)
|
|
|
|
|
|
def test_unattended_no_op_not_audited(manager):
|
|
manager.set_unattended("s11", False) # already off
|
|
assert _stages(manager, "s11") == []
|
|
|
|
|
|
def test_mode_change_audited_with_direction(manager):
|
|
manager.audit_autonomy_change("s12", "mode", "interactive", "auto")
|
|
manager.audit_autonomy_change("s12", "mode", "auto", "plan")
|
|
# AuditStore.list is newest-first (ORDER BY id DESC), so reverse for chronology.
|
|
rows = list(reversed(manager.audit_store.list(session_id="s12")))
|
|
assert [r["status"] for r in rows] == ["raised", "lowered"]
|
|
assert rows[0]["reason"] == "mode: interactive → auto"
|