"""Standing scoped approvals (UX-DECISIONS §25) — tool+target+task rules. Covers the four build gaps: the `permissions` create-field, target-shaped entries matched in the permission path (lifting the connector exclusion for task rules only), the task-persistent "Allow every time" on run approvals, and revocation. No network, no LLM. """ from __future__ import annotations import asyncio import aisuite as ai import pytest from coworker.automation import Schedule, ScheduledTask, Scheduler, TaskRun, TaskStore from coworker.automation.models import grant_entries, rule_entry, rule_parts from coworker.automation.tools import scheduling_tools from coworker.engine import ApprovalOutcome, PermissionRequest from coworker.permissions import Mode, PermissionEngine, standing_rule_candidate class _Meta: """Minimal tool metadata: external risk (requires_approval) + a category.""" def __init__(self, category="messaging", requires_approval=True): self.category = category self.requires_approval = requires_approval def _task(**kw) -> ScheduledTask: kw.setdefault("title", "Weekly digest") kw.setdefault("instructions", "summarize the week and post it") kw.setdefault("schedule", Schedule(kind="cron", cron="0 9 * * 1")) kw.setdefault("workspace", "/tmp/cw-standing") return ScheduledTask(**kw) def _provider(): from coworker.providers import AssistantTurn, ModelCapabilities, ProviderClient class _P(ProviderClient): def complete(self, *, model, messages, tools=None, **settings): return AssistantTurn(text="ok", finish_reason="stop") def capabilities(self, model): return ModelCapabilities() return _P() # -- rule entries --------------------------------------------------------------- def test_rule_entry_roundtrip(): assert rule_entry("send_message", "slack:T1/C1") == "send_message slack:T1/C1" assert rule_parts("send_message slack:T1/C1") == ("send_message", "slack:T1/C1") assert rule_entry("web_search") == "web_search" assert rule_parts("web_search") == ("web_search", None) # legacy name-only def test_task_rule_helpers(): t = _task(always_allowed_tools=["send_message slack:T1/C1", "web_search"]) assert t.standing_rules() == {"send_message": {"slack:T1/C1"}} assert t.name_allowed_tools() == {"web_search"} assert t.add_rule("send_message", "slack:T1/C2") is True assert t.add_rule("send_message", "slack:T1/C2") is False # dedupe assert t.add_rule("send_message", "") is False assert t.revoke_rule("send_message slack:T1/C2") is True assert t.revoke_rule("send_message slack:T1/C2") is False rules = t.public()["always_allowed"] assert { "entry": "send_message slack:T1/C1", "tool": "send_message", "target": "slack:T1/C1", } in rules assert {"entry": "web_search", "tool": "web_search", "target": None} in rules def test_grant_entries_validation(): grants = grant_entries( [ {"tool": "send_message", "target": "slack:T1/C1", "access": "write"}, {"tool": "send_message", "target": "slack:T1/C1", "access": "write"}, # dup { "tool": "github_list_commits", "target": "r/x", "access": "read", }, # read = disclosure { "tool": "run_shell", "target": "rm -rf /", "access": "write", }, # no target arg, exec { "tool": "github_create_issue", "target": "r/x", "access": "write", }, # no declared target arg {"tool": "send_message", "target": "", "access": "write"}, # empty target "garbage", ] ) assert grants == ["send_message slack:T1/C1"] assert grant_entries(None) == [] # -- eligibility + permission-path matching -------------------------------------- def test_standing_rule_candidate(): assert ( standing_rule_candidate("send_message", {"target": "slack:C1"}, _Meta()) == "slack:C1" ) # exec risk is never eligible, even with a hand-crafted arg assert standing_rule_candidate("run_shell", {"command": "ls"}, _Meta()) is None # no declared target argument → not eligible assert ( standing_rule_candidate( "github_create_issue", {"repo": "x"}, _Meta("connector") ) is None ) # eligible tool, but the call names no target assert standing_rule_candidate("send_message", {"text": "hi"}, _Meta()) is None # local writes are covered by path scoping, not standing rules assert standing_rule_candidate("write_file", {"path": "a"}, None) is None def test_engine_matches_target(tmp_path): e = PermissionEngine( workspace_root=tmp_path, task_rules={"send_message": {"slack:T1/C1"}} ) d = e.evaluate("send_message", {"target": "slack:T1/C1", "text": "hi"}, _Meta()) assert d.allowed and d.rule == "send_message → slack:T1/C1" miss = e.evaluate("send_message", {"target": "slack:T1/C2", "text": "hi"}, _Meta()) assert not miss.allowed and miss.needs_user def test_task_rules_cover_connector_tools(tmp_path): # The session allowlist deliberately excludes connector tools; a task rule's exact # target binding is what makes lifting that exclusion safe (§25 gap 4). e = PermissionEngine( workspace_root=tmp_path, task_rules={"discord_send_message": {"C9"}} ) e.allow_tool_for_session( "discord_send_message" ) # session allow alone must NOT unlock it meta = _Meta(category="connector") assert not e.evaluate( "discord_send_message", {"channel_id": "C8", "content": "x"}, meta ).allowed hit = e.evaluate("discord_send_message", {"channel_id": "C9", "content": "x"}, meta) assert hit.allowed and "standing rule" in hit.reason def test_read_only_modes_ignore_task_rules(tmp_path): # Rules are additive on top of the run's permission mode — never an upgrade. e = PermissionEngine( workspace_root=tmp_path, mode=Mode.PLAN, task_rules={"send_message": {"slack:C1"}}, ) assert not e.evaluate("send_message", {"target": "slack:C1"}, _Meta()).allowed # -- creation: the `permissions` field ------------------------------------------- def test_create_tool_stores_write_grants(tmp_path): store = TaskStore(tmp_path / "auto.db") tools = scheduling_tools( store, origin={"workspace": str(tmp_path)}, default_workspace=str(tmp_path) ) create = next(t for t in tools if t.__name__ == "create_scheduled_task") # The schema advertises the field to the agent. props = create.__coworker_schema__["function"]["parameters"]["properties"] assert "permissions" in props res = create( title="Weekly digest", instructions="post it", cron="0 9 * * 1", permissions=[ {"tool": "send_message", "target": "slack:T1/C1", "access": "write"}, {"tool": "github_list_commits", "target": "r/x", "access": "read"}, ], ) assert res["ok"] and res["always_allowed"] == ["send_message slack:T1/C1"] saved = store.get(res["id"]) assert saved.always_allowed_tools == ["send_message slack:T1/C1"] # The agent-facing update tool has no permissions field — rules are human-minted only. update = next(t for t in tools if t.__name__ == "update_scheduled_task") assert ( "permissions" not in update.__coworker_schema__["function"]["parameters"]["properties"] ) # -- store: run-session → owning task -------------------------------------------- def test_task_for_run_session(tmp_path): store = TaskStore(tmp_path / "auto.db") t = _task() store.save(t) run = TaskRun(task_id=t.id) store.add_run(run) owner = store.task_for_run_session(run.session_id) assert owner is not None and owner.id == t.id assert store.task_for_run_session("session-xyz") is None assert store.task_for_run_session("__run__missing") is None # -- run-time: park in the Inbox, mint on "Allow every time" ---------------------- async def test_scheduled_approver_parks_and_mints(tmp_path, monkeypatch): from coworker.server.manager import SessionManager monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) ws = tmp_path / "ws" ws.mkdir() manager = SessionManager(data_dir=tmp_path / "data", provider=_provider()) task = _task(workspace=str(ws), agent="cowork") manager.task_store.save(task) run = TaskRun(task_id=task.id) manager.task_store.add_run(run) approver = manager._scheduled_approver(task, run.session_id) request = PermissionRequest( tool_name="send_message", arguments={"target": "slack:T1/C1", "text": "digest"}, metadata=_Meta(), reason="requires approval", tool_call_id="tc1", ) async def click_allow_every_time(): for _ in range(500): pending = manager.inbox.pending(run.session_id) if pending: # The parked item carries the task binding + the exact target — the # in-app card's gate for offering "Allow every time". assert pending[0].data["task_id"] == task.id assert pending[0].data["standing_target"] == "slack:T1/C1" await manager.resolve_inbox(pending[0].id, "always_task") return await asyncio.sleep(0.001) raise AssertionError("approval never parked") outcome, _ = await asyncio.gather(approver(request), click_allow_every_time()) assert outcome is ApprovalOutcome.ONCE # The rule persisted to the task record… assert ( "send_message slack:T1/C1" in manager.task_store.get(task.id).always_allowed_tools ) # …and a rebuilt run engine auto-allows exactly that call, nothing else. engine = manager._build_task_engine( manager.task_store.get(task.id), session_id=run.session_id ) hit = engine.permissions.evaluate( "send_message", {"target": "slack:T1/C1"}, _Meta() ) assert hit.allowed and hit.rule assert not engine.permissions.evaluate( "send_message", {"target": "slack:T1/C2"}, _Meta() ).allowed async def test_scheduled_approver_name_allows_and_denies(tmp_path, monkeypatch): from coworker.server.manager import SessionManager monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) ws = tmp_path / "ws" ws.mkdir() manager = SessionManager(data_dir=tmp_path / "data", provider=_provider()) task = _task(workspace=str(ws), agent="cowork", always_allowed_tools=["web_search"]) manager.task_store.save(task) run = TaskRun(task_id=task.id) manager.task_store.add_run(run) approver = manager._scheduled_approver(task, run.session_id) # Legacy name-only entry: allowed without parking. outcome = await approver( PermissionRequest("web_search", {"query": "x"}, _Meta(), "", tool_call_id="tc1") ) assert outcome is ApprovalOutcome.ONCE and not manager.inbox.pending(run.session_id) # Ungranted call parks; a Deny resolves it as denied (graceful degradation). request = PermissionRequest( "send_message", {"target": "slack:T1/C1"}, _Meta(), "", tool_call_id="tc2" ) async def deny(): for _ in range(500): pending = manager.inbox.pending(run.session_id) if pending: await manager.resolve_inbox(pending[0].id, "deny") return await asyncio.sleep(0.001) outcome, _ = await asyncio.gather(approver(request), deny()) assert outcome is ApprovalOutcome.DENY assert manager.task_store.get(task.id).always_allowed_tools == ["web_search"] def test_mint_task_rule_validates(tmp_path, monkeypatch): from coworker.server.manager import SessionManager monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) manager = SessionManager(data_dir=tmp_path / "data", provider=_provider()) task = _task() manager.task_store.save(task) run = TaskRun(task_id=task.id) manager.task_store.add_run(run) # Not an automation run session → no rule, ever. assert not manager.mint_task_rule( "session-1", "send_message", {"target": "slack:C1"}, _Meta() ) # Exec risk → never mintable, even from a run session. assert not manager.mint_task_rule( run.session_id, "run_shell", {"command": "ls"}, _Meta() ) # No declared target argument → not mintable. assert not manager.mint_task_rule( run.session_id, "github_create_issue", {"repo": "x"}, _Meta() ) # Eligible call mints exactly one rule. assert manager.mint_task_rule( run.session_id, "send_message", {"target": "slack:C1"}, _Meta() ) assert manager.task_store.get(task.id).always_allowed_tools == [ "send_message slack:C1" ] def test_get_engine_seeds_run_session_rules(tmp_path, monkeypatch): from coworker.server.manager import SessionManager monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) ws = tmp_path / "ws" ws.mkdir() manager = SessionManager(data_dir=tmp_path / "data", provider=_provider()) task = _task( workspace=str(ws), agent="cowork", always_allowed_tools=["send_message slack:T1/C1"], ) manager.task_store.save(task) run = TaskRun(task_id=task.id) manager.task_store.add_run(run) # Manual "Run now" / durable resume rebuilds via get_engine — rules must ride along. engine = manager.get_engine(run.session_id, workspace=str(ws), agent="cowork") assert engine.permissions.task_rules == {"send_message": {"slack:T1/C1"}} # -- REST surface: grants at creation, revoke on the task page -------------------- def test_create_automation_grants_and_revoke(tmp_path, monkeypatch): from coworker.server.manager import SessionManager monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) manager = SessionManager(data_dir=tmp_path / "data", provider=_provider()) res = manager.create_automation( { "title": "Weekly digest", "instructions": "post it", "cron": "0 9 * * 1", "permissions": [ {"tool": "send_message", "target": "slack:T1/C1", "access": "write"}, {"tool": "github_list_commits", "target": "r/x", "access": "read"}, ], } ) assert res["ok"] rules = res["task"]["always_allowed"] assert rules == [ { "entry": "send_message slack:T1/C1", "tool": "send_message", "target": "slack:T1/C1", } ] out = manager.update_automation( res["task"]["id"], {"revoke": "send_message slack:T1/C1"} ) assert out["ok"] and out["task"]["always_allowed"] == [] assert manager.task_store.get(res["task"]["id"]).always_allowed_tools == [] # -- scheduler: a suspended run must not stall the loop --------------------------- async def test_blocked_run_does_not_stall_other_tasks(tmp_path): store = TaskStore(tmp_path / "auto.db") blocked = _task(title="blocked") quick = _task(title="quick") for t in (blocked, quick): store.save(t) store._conn.execute( "UPDATE scheduled_tasks SET next_run=1.0 WHERE id=?", (t.id,) ) store._conn.commit() gate = asyncio.Event() async def runner(task, trigger): if task.id == blocked.id: await gate.wait() # parked approval: suspended until a human answers return TaskRun(task_id=task.id, status="ok", trigger=trigger) sched = Scheduler(store, runner, tick_seconds=0.05) sched.start() await asyncio.sleep(0.2) # The quick task completed while the blocked one is still suspended. assert store.get(quick.id).run_count == 1 assert store.get(blocked.id).run_count == 0 gate.set() await asyncio.sleep(0.1) assert store.get(blocked.id).run_count == 1 await sched.stop() async def test_tick_while_run_is_parked_never_redispatches_it(tmp_path): """The overlap guard is claimed at dispatch, not inside the spawned run. Regression: a tick's due() snapshot still lists a task whose run is parked on an approval (next_run only advances on completion). When the guard lived inside the spawn, an approval landing just before that tick let the parked run finish and clear the guard before the duplicate spawn took its first step — the task ran twice (the intermittent `assert 2 == 1` above).""" store = TaskStore(tmp_path / "auto.db") task = _task(title="parked") store.save(task) store._conn.execute("UPDATE scheduled_tasks SET next_run=1.0 WHERE id=?", (task.id,)) store._conn.commit() gate = asyncio.Event() started = asyncio.Event() calls = 0 async def runner(t, trigger): nonlocal calls calls += 1 started.set() await gate.wait() return TaskRun(task_id=t.id, status="ok", trigger=trigger) sched = Scheduler(store, runner, tick_seconds=9999) await sched._tick(trigger="schedule") # dispatch; the run parks on the gate await started.wait() gate.set() # the approval lands... await sched._tick(trigger="schedule") # ...right as the next tick fires for _ in range(5): await asyncio.sleep(0) # parked run finishes; any duplicate would start now assert calls == 1 assert store.get(task.id).run_count == 1 await sched.stop() # -- engine events: standing_target on the card, the note on the tool card -------- def test_engine_events_carry_standing_context(tmp_path): from coworker.events import EventType from coworker.providers import ( AssistantTurn, ModelCapabilities, ProviderClient, ToolCall, ) from coworker.engine import TurnEngine from coworker.tools import ToolRegistry def send_message(target: str, text: str): return {"ok": True} send_message.__aisuite_tool_metadata__ = ai.ToolMetadata( name="send_message", category="messaging", risk_level="medium", capabilities=["messaging"], requires_approval=True, ) send_message.__coworker_schema__ = { "type": "function", "function": { "name": "send_message", "description": "send", "parameters": {"type": "object", "properties": {}}, }, } class _P(ProviderClient): def __init__(self): self._turns = [ AssistantTurn( tool_calls=[ ToolCall( id="c1", name="send_message", arguments={"target": "slack:T1/C1", "text": "hi"}, ) ], finish_reason="tool_calls", ), AssistantTurn(text="sent", finish_reason="stop"), ] def complete(self, *, model, messages, tools=None, **settings): return self._turns.pop(0) def capabilities(self, model): return ModelCapabilities() registry = ToolRegistry() registry.register_all([send_message]) async def approve_once(_req): return ApprovalOutcome.ONCE # Ungranted: the approval event names the exact pinnable target. engine = TurnEngine( provider=_P(), registry=registry, permissions=PermissionEngine(workspace_root=tmp_path), model="gpt-5.5", approver=approve_once, ) async def run(): return [ev async for ev in engine.run("post it")] events = asyncio.run(run()) perm = next(e for e in events if e.type == EventType.PERMISSION_REQUIRED) assert perm.data["standing_target"] == "slack:T1/C1" # Granted: no prompt, and the tool card cites the rule. audit: list[dict] = [] engine2 = TurnEngine( provider=_P(), registry=registry, permissions=PermissionEngine( workspace_root=tmp_path, task_rules={"send_message": {"slack:T1/C1"}} ), model="gpt-5.5", audit_sink=audit.append, ) async def run2(): return [ev async for ev in engine2.run("post it")] events2 = asyncio.run(run2()) assert not any(e.type == EventType.PERMISSION_REQUIRED for e in events2) finished = next(e for e in events2 if e.type == EventType.TOOL_FINISHED) assert finished.data["standing_rule"] == "send_message → slack:T1/C1" # §25 invariant: every auto-allowed call writes an audit entry citing the rule. cited = [a for a in audit if a.get("stage") == "auto_allowed"] assert cited and "send_message → slack:T1/C1" in cited[0]["reason"]