mirror of
https://github.com/andrewyng/openworker.git
synced 2026-11-04 13:27:11 +00:00
A machine joins with its own identity and holds a reverse channel to the controller. Provider keys and connector accounts deploy sealed to the machine's pinned key.
492 lines
20 KiB
Python
492 lines
20 KiB
Python
"""Audit export (remote/audit_export.py): off by default, policy-driven, content-blind."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from coworker.audit import AuditStore
|
|
from coworker.remote.audit_export import (
|
|
AuditExporter,
|
|
ExportPolicy,
|
|
load_policy,
|
|
save_policy,
|
|
to_ocsf,
|
|
)
|
|
|
|
|
|
def _row_event(i=0, **over):
|
|
return {
|
|
"session_id": "s1",
|
|
"agent": "cowork",
|
|
"workspace": "/Users/x/proj",
|
|
"tool": "github_open_pr",
|
|
"stage": "tool_call",
|
|
"status": "ok",
|
|
"approval": "allow",
|
|
"arguments": {"directory": "/Users/x/proj", "title": f"PR {i}", "token": "sk-secret"},
|
|
"result_preview": "THE PR BODY — content, must never leave",
|
|
"reason": "the model's reasoning — content, must never leave",
|
|
"resource": "acme/api#12",
|
|
"call_id": f"c{i}",
|
|
"tokens_in": 10,
|
|
"tokens_out": 5,
|
|
**over,
|
|
}
|
|
|
|
|
|
# -- policy ---------------------------------------------------------------------
|
|
|
|
|
|
def test_policy_is_off_by_default_and_never_on_by_accident():
|
|
assert ExportPolicy.from_policy(None).enabled is False
|
|
assert ExportPolicy.from_policy({}).enabled is False
|
|
assert ExportPolicy.from_policy({"audit_export": "yes"}).enabled is False
|
|
assert ExportPolicy.from_policy({"audit_export": {"enabled": False}}).enabled is False
|
|
# http sink needs a real URL, otherwise OFF.
|
|
assert ExportPolicy.from_policy({"audit_export": {"enabled": True, "sink": "http"}}).enabled is False
|
|
assert ExportPolicy.from_policy({"audit_export": {"enabled": True, "sink": "ftp"}}).enabled is False
|
|
|
|
|
|
def test_policy_parses_cloud_and_http_sinks():
|
|
cloud = ExportPolicy.from_policy({"audit_export": {"enabled": True}})
|
|
assert cloud.enabled and cloud.sink == "cloud" and cloud.batch == 200
|
|
http = ExportPolicy.from_policy(
|
|
{
|
|
"audit_export": {
|
|
"enabled": True,
|
|
"sink": "http",
|
|
"url": "https://siem.example/hec",
|
|
"headers": {"Authorization": "Splunk x"},
|
|
"batch": 5000,
|
|
"flush_seconds": 0.1,
|
|
}
|
|
}
|
|
)
|
|
assert http.sink == "http" and http.url == "https://siem.example/hec"
|
|
assert http.headers == {"Authorization": "Splunk x"}
|
|
assert http.batch == 1000 and http.flush_seconds == 1.0 # clamped
|
|
|
|
|
|
def test_policy_record_persists_for_visible_governance(tmp_path):
|
|
save_policy(tmp_path, {"audit_export": {"enabled": True}, "org_id": "org-1"})
|
|
stored = load_policy(tmp_path)
|
|
assert stored["audit_export"] == {"enabled": True} and stored["org_id"] == "org-1"
|
|
assert "received_at" in stored
|
|
assert load_policy(tmp_path / "missing") == {}
|
|
|
|
|
|
# -- OCSF mapping ------------------------------------------------------------------
|
|
|
|
|
|
def test_to_ocsf_is_content_blind(tmp_path):
|
|
store = AuditStore(tmp_path / "a.db")
|
|
store.append(_row_event())
|
|
row = store.list_since(0)[0]
|
|
ev = to_ocsf(row, machine_id="m1", machine_name="cloud-vm", org_id="org-1")
|
|
flat = json.dumps(ev)
|
|
assert "THE PR BODY" not in flat and "reasoning" not in flat
|
|
assert "sk-secret" not in flat # the local store already stripped it
|
|
assert ev["class_uid"] == 6003 and ev["category_uid"] == 6 and ev["type_uid"] == 600399
|
|
assert ev["api"] == {"operation": "github_open_pr", "service": {"name": "github"}}
|
|
# No controller-verified login on this row → automated; the persona rides unmapped.
|
|
assert ev["actor"]["user"] == {"name": "", "org": {"uid": "org-1"}}
|
|
assert ev["unmapped"]["agent"] == "cowork" and ev["unmapped"]["automated"] is True
|
|
assert ev["actor"]["session"] == {"uid": "s1"}
|
|
store.append(_row_event(1, actor="erin@l.com"))
|
|
who = to_ocsf(store.list_since(1)[0], org_id="org-1")
|
|
assert who["actor"]["user"] == {"name": "erin@l.com", "email_addr": "erin@l.com", "org": {"uid": "org-1"}}
|
|
assert who["unmapped"]["automated"] is False
|
|
assert ev["device"] == {"uid": "m1", "name": "cloud-vm"}
|
|
assert ev["unmapped"]["workspace"] == "proj" # basename only
|
|
assert ev["unmapped"]["tokens_in"] == 10 and ev["unmapped"]["tokens_out"] == 5
|
|
assert ev["unmapped"]["approval"] == "allow"
|
|
assert isinstance(ev["unmapped"]["resource"], str) # store-derived, never content
|
|
assert ev["status_id"] == 1 and ev["metadata"]["uid"] == f"m1:{row['id']}"
|
|
assert isinstance(ev["time"], int) and ev["time"] > 1_600_000_000_000
|
|
store.close()
|
|
|
|
|
|
def test_store_cursor_read_and_subscribe(tmp_path):
|
|
store = AuditStore(tmp_path / "a.db")
|
|
seen = []
|
|
store.subscribe(seen.append)
|
|
for i in range(3):
|
|
store.append(_row_event(i))
|
|
assert seen == [1, 2, 3]
|
|
assert [r["id"] for r in store.list_since(0)] == [1, 2, 3]
|
|
assert [r["id"] for r in store.list_since(2)] == [3]
|
|
assert store.list_since(1, limit=1)[0]["args"]["title"] == "PR 1"
|
|
store.close()
|
|
|
|
|
|
# -- the emitter -------------------------------------------------------------------
|
|
|
|
|
|
def test_flush_advances_cursor_only_on_ack(tmp_path):
|
|
store = AuditStore(tmp_path / "a.db")
|
|
for i in range(5):
|
|
store.append(_row_event(i))
|
|
policy = ExportPolicy.from_policy({"audit_export": {"enabled": True, "batch": 2}})
|
|
exp = AuditExporter(tmp_path, store, machine_id="m1", policy=policy)
|
|
batches = []
|
|
|
|
async def ok(events):
|
|
batches.append([e["unmapped"]["row_id"] for e in events])
|
|
return True
|
|
|
|
assert asyncio.run(exp.flush_once(ok)) == 5
|
|
assert batches == [[1, 2], [3, 4], [5]]
|
|
assert exp.status()["last_id"] == 5 and exp.status()["exported"] == 5
|
|
assert json.loads((tmp_path / "audit-export.json").read_text())["last_id"] == 5
|
|
|
|
# New rows, sink refuses: cursor stays, error recorded, nothing skipped.
|
|
store.append(_row_event(6))
|
|
|
|
async def refuse(events):
|
|
return False
|
|
|
|
with pytest.raises(ConnectionError):
|
|
asyncio.run(exp.flush_once(refuse))
|
|
assert exp.status()["last_id"] == 5 and "refused" in exp.status()["last_error"]
|
|
assert exp.status()["pending"] == 1
|
|
# A fresh exporter over the same state resumes from the persisted cursor.
|
|
again = AuditExporter(tmp_path, store, machine_id="m1", policy=policy)
|
|
assert asyncio.run(again.flush_once(ok)) == 1 and batches[-1] == [6]
|
|
store.close()
|
|
|
|
|
|
def test_run_idles_while_off_and_ships_when_turned_on(tmp_path):
|
|
store = AuditStore(tmp_path / "a.db")
|
|
exp = AuditExporter(tmp_path, store, machine_id="m1") # policy off
|
|
sent = []
|
|
|
|
async def sink(events):
|
|
sent.extend(events)
|
|
return True
|
|
|
|
async def scenario():
|
|
task = asyncio.create_task(exp.run(sink))
|
|
store.append(_row_event(0))
|
|
await asyncio.sleep(0.2)
|
|
assert sent == [] # off: nothing leaves
|
|
exp.set_policy(ExportPolicy.from_policy({"audit_export": {"enabled": True, "flush_seconds": 1}}))
|
|
for _ in range(50):
|
|
if sent:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
assert len(sent) == 1
|
|
store.append(_row_event(1)) # the append wakes the loop
|
|
for _ in range(50):
|
|
if len(sent) == 2:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
assert len(sent) == 2
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
asyncio.run(scenario())
|
|
assert exp.status()["enabled"] is True and exp.status()["sink"] == "cloud"
|
|
store.close()
|
|
|
|
|
|
def test_channel_sender_resolves_on_ack(tmp_path):
|
|
store = AuditStore(tmp_path / "a.db")
|
|
exp = AuditExporter(tmp_path, store)
|
|
frames = []
|
|
|
|
async def send_frame(frame):
|
|
frames.append(frame)
|
|
# The controller answers out of band; simulate it.
|
|
asyncio.get_running_loop().call_soon(
|
|
exp.ack, {"type": "audit_ack", "id": frame["id"], "accepted": True}
|
|
)
|
|
|
|
send = exp.channel_sender(send_frame)
|
|
assert asyncio.run(send([{"x": 1}])) is True
|
|
assert frames[0]["type"] == "audit" and frames[0]["events"] == [{"x": 1}]
|
|
|
|
async def send_frame_refused(frame):
|
|
asyncio.get_running_loop().call_soon(
|
|
exp.ack, {"type": "audit_ack", "id": frame["id"], "accepted": False}
|
|
)
|
|
|
|
assert asyncio.run(exp.channel_sender(send_frame_refused)([{"x": 2}])) is False
|
|
store.close()
|
|
|
|
|
|
# -- the channel round trip: welcome policy → box exports → controller sink → push off
|
|
|
|
|
|
class _Server:
|
|
"""The real controller app under real uvicorn on an ephemeral port."""
|
|
|
|
def __init__(self, app):
|
|
import uvicorn
|
|
|
|
self._config = uvicorn.Config(app, host="127.0.0.1", port=0, log_level="error")
|
|
self._server = uvicorn.Server(self._config)
|
|
self._task = None
|
|
|
|
async def __aenter__(self):
|
|
self._task = asyncio.create_task(self._server.serve())
|
|
while not self._server.started:
|
|
await asyncio.sleep(0.01)
|
|
port = self._server.servers[0].sockets[0].getsockname()[1]
|
|
self.base = f"http://127.0.0.1:{port}"
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
self._server.should_exit = True
|
|
await self._task
|
|
|
|
|
|
async def test_policy_rides_welcome_and_audit_batches_reach_the_sink(tmp_path):
|
|
import httpx
|
|
|
|
from coworker.remote.joiner import parse_join_url, run_joined
|
|
from coworker.server.app import create_app
|
|
from coworker.server.manager import SessionManager
|
|
|
|
ctrl = SessionManager(workspace=tmp_path / "ws", data_dir=tmp_path / "ctrl")
|
|
received: list[tuple[str, list]] = []
|
|
accept = {"ok": True}
|
|
|
|
def policy_for(machine_id, known):
|
|
return {"org_id": "org-1", "version": 1, "audit_export": {"enabled": True, "flush_seconds": 1}}
|
|
|
|
async def audit_sink(machine_id, known, events):
|
|
received.append((machine_id, events))
|
|
return accept["ok"]
|
|
|
|
ctrl.policy_for = policy_for
|
|
ctrl.audit_sink = audit_sink
|
|
ctrl_app = create_app(ctrl)
|
|
box = SessionManager(data_dir=tmp_path / "box-data")
|
|
box_app = create_app(box)
|
|
async with _Server(ctrl_app) as server:
|
|
async with httpx.AsyncClient() as client:
|
|
join_url = (await client.post(f"{server.base}/v1/remote/arm")).json()["join_url"]
|
|
_, token = parse_join_url(join_url)
|
|
state = tmp_path / "box-state"
|
|
state.mkdir()
|
|
task = asyncio.create_task(
|
|
run_joined(
|
|
state=state,
|
|
controller=server.base,
|
|
name="cloud-vm",
|
|
token=token,
|
|
app=box_app,
|
|
once=True,
|
|
log=lambda *_: None,
|
|
)
|
|
)
|
|
try:
|
|
for _ in range(100):
|
|
if getattr(box, "org_policy", None):
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
# The welcome carried the org policy: persisted + applied + visible.
|
|
assert box.org_policy["org_id"] == "org-1"
|
|
assert load_policy(state)["audit_export"]["enabled"] is True
|
|
assert box.get_settings()["audit_export"]["enabled"] is True
|
|
assert box.get_settings()["audit_export"]["sink"] == "cloud"
|
|
|
|
# A tool call on the box → one OCSF event at the controller's sink.
|
|
box.audit_store.append(_row_event(1, session_id="sess-9"))
|
|
for _ in range(100):
|
|
if received:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
machine_id, events = received[0]
|
|
assert len(events) == 1 and events[0]["class_uid"] == 6003
|
|
assert events[0]["device"]["uid"] == machine_id and events[0]["device"]["name"] == "cloud-vm"
|
|
assert events[0]["actor"]["user"]["org"] == {"uid": "org-1"}
|
|
assert events[0]["actor"]["session"] == {"uid": "sess-9"}
|
|
assert "THE PR BODY" not in json.dumps(events)
|
|
assert box.audit_exporter.status()["exported"] == 1
|
|
|
|
# A refusing sink: the cursor holds and the row is retried, not lost.
|
|
accept["ok"] = False
|
|
box.audit_store.append(_row_event(2))
|
|
for _ in range(100):
|
|
if len(received) >= 2:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
assert box.audit_exporter.status()["last_id"] == 1
|
|
accept["ok"] = True
|
|
for _ in range(200):
|
|
if box.audit_exporter.status()["last_id"] == 2:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
assert box.audit_exporter.status()["last_id"] == 2
|
|
|
|
# Governance flips it off: the send lands, the record updates, exports stop.
|
|
status, body = await ctrl_app.state.remote_acceptor.push_policy(
|
|
machine_id, {"org_id": "org-1", "version": 2, "audit_export": {"enabled": False}}
|
|
)
|
|
assert status == 200 and body["ok"] is True and body["applied"] is True and body["version"] == 2
|
|
assert box.get_settings()["audit_export"]["enabled"] is False
|
|
assert load_policy(state)["audit_export"] == {"enabled": False}
|
|
before = len(received)
|
|
box.audit_store.append(_row_event(3))
|
|
await asyncio.sleep(0.4)
|
|
assert len(received) == before
|
|
finally:
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
async def test_desktop_controller_sends_no_policy_and_refuses_audit(tmp_path):
|
|
"""A desktop (OSS) controller has no policy and no sink: the box keeps its
|
|
stored record (off), and an audit frame is answered accepted=false."""
|
|
import httpx
|
|
|
|
from coworker.remote.joiner import parse_join_url, run_joined
|
|
from coworker.server.app import create_app
|
|
from coworker.server.manager import SessionManager
|
|
|
|
ctrl = SessionManager(workspace=tmp_path / "ws", data_dir=tmp_path / "ctrl")
|
|
ctrl_app = create_app(ctrl)
|
|
box = SessionManager(data_dir=tmp_path / "box-data")
|
|
box_app = create_app(box)
|
|
async with _Server(ctrl_app) as server:
|
|
async with httpx.AsyncClient() as client:
|
|
join_url = (await client.post(f"{server.base}/v1/remote/arm")).json()["join_url"]
|
|
_, token = parse_join_url(join_url)
|
|
state = tmp_path / "box-state"
|
|
state.mkdir()
|
|
task = asyncio.create_task(
|
|
run_joined(state=state, controller=server.base, name="lap", token=token,
|
|
app=box_app, once=True, log=lambda *_: None)
|
|
)
|
|
try:
|
|
for _ in range(100):
|
|
if getattr(box, "audit_exporter", None) is not None:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
assert box.get_settings()["audit_export"]["enabled"] is False
|
|
assert load_policy(state) == {}
|
|
box.audit_store.append(_row_event(1))
|
|
await asyncio.sleep(0.3)
|
|
assert box.audit_exporter.status()["exported"] == 0
|
|
finally:
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
# -- versioned documents (spec §Versioned org policy) ----------------------------
|
|
|
|
|
|
def test_apply_policy_ignores_older_versions_and_reports(tmp_path):
|
|
from types import SimpleNamespace
|
|
|
|
from coworker.remote.joiner import apply_policy, held_policy, policy_version
|
|
|
|
manager = SimpleNamespace(org_policy=None)
|
|
app = SimpleNamespace(state=SimpleNamespace(manager=manager))
|
|
assert apply_policy(app, {"version": 3, "audit_export": {"enabled": True}}, state=tmp_path) == (True, 3)
|
|
assert policy_version(held_policy(app)) == 3
|
|
# Older: ignored, held version unchanged, file untouched.
|
|
assert apply_policy(app, {"version": 2}, state=tmp_path) == (False, 3)
|
|
assert load_policy(tmp_path)["version"] == 3
|
|
# Same version re-sent (welcome after reconnect): applied idempotently.
|
|
assert apply_policy(app, {"version": 3, "audit_export": {"enabled": False}}, state=tmp_path) == (True, 3)
|
|
assert held_policy(app)["audit_export"] == {"enabled": False}
|
|
# Unversioned documents (desktop controllers, tests) always apply.
|
|
assert apply_policy(app, {"audit_export": {"enabled": True}}, state=tmp_path) == (True, 0)
|
|
|
|
|
|
async def test_box_checks_every_interval_and_controller_records_status(tmp_path, monkeypatch):
|
|
"""The box asks with the version it holds; the controller records what the
|
|
box SAYS (checked / applied), answers current or the newer document; an
|
|
admin minting v2 reaches the box within one check without any push."""
|
|
import httpx
|
|
|
|
from coworker.remote import joiner
|
|
from coworker.remote.joiner import parse_join_url, run_joined
|
|
from coworker.server.app import create_app
|
|
from coworker.server.manager import SessionManager
|
|
|
|
monkeypatch.setattr(joiner, "POLICY_CHECK_SECONDS", 0.3)
|
|
current = {"doc": {"org_id": "org-1", "version": 1, "audit_export": {"enabled": False}}}
|
|
status_log: list[tuple[int, str]] = []
|
|
ctrl = SessionManager(workspace=tmp_path / "ws", data_dir=tmp_path / "ctrl")
|
|
ctrl.policy_for = lambda mid, known: current["doc"]
|
|
ctrl.policy_status = lambda mid, known, version, outcome: status_log.append((version, outcome))
|
|
ctrl_app = create_app(ctrl)
|
|
box = SessionManager(data_dir=tmp_path / "box-data")
|
|
box_app = create_app(box)
|
|
async with _Server(ctrl_app) as server:
|
|
async with httpx.AsyncClient() as client:
|
|
join_url = (await client.post(f"{server.base}/v1/remote/arm")).json()["join_url"]
|
|
_, token = parse_join_url(join_url)
|
|
state = tmp_path / "box-state"
|
|
state.mkdir()
|
|
task = asyncio.create_task(
|
|
run_joined(state=state, controller=server.base, name="vm", token=token,
|
|
app=box_app, once=True, log=lambda *_: None)
|
|
)
|
|
try:
|
|
for _ in range(100):
|
|
if getattr(box, "org_policy", None):
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
assert box.org_policy["version"] == 1
|
|
# First check: reports v1, nothing newer → "checked" only.
|
|
for _ in range(100):
|
|
if (1, "checked") in status_log:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
assert (1, "checked") in status_log and not any(o == "applied" for _, o in status_log)
|
|
# Admin mints v2. No push: the next check carries it down.
|
|
current["doc"] = {"org_id": "org-1", "version": 2, "audit_export": {"enabled": True, "flush_seconds": 1}}
|
|
for _ in range(200):
|
|
if (2, "applied") in status_log:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
assert (2, "applied") in status_log
|
|
assert box.org_policy["version"] == 2
|
|
assert load_policy(state)["version"] == 2
|
|
assert box.get_settings()["org_policy"]["version"] == 2
|
|
assert box.get_settings()["audit_export"]["enabled"] is True
|
|
# Subsequent checks report v2.
|
|
for _ in range(200):
|
|
if (2, "checked") in status_log:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
assert (2, "checked") in status_log
|
|
finally:
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
def test_approver_is_stamped_on_the_gated_tool_row(tmp_path):
|
|
"""The Inbox item that gated a tool call records who decided; the audit row
|
|
of that call (joined by call id) carries `approved_by`, and so does OCSF."""
|
|
from coworker.inbox import InboxStore
|
|
from coworker.server.manager import SessionManager
|
|
|
|
m = SessionManager(data_dir=tmp_path / "d")
|
|
assert isinstance(m.inbox, InboxStore)
|
|
item = m.inbox.add_approval("s1", "Run `github_push`?", tool_call_id="call_77")
|
|
assert m.inbox.resolve(item.id, "allow", by="erin@l.com")
|
|
assert m.inbox.get(item.id).resolved_by == "erin@l.com"
|
|
assert m.inbox.resolver_of("s1", "call_77") == "erin@l.com"
|
|
assert m.inbox.resolver_of("s1", "call_nope") == ""
|
|
m.note_session_actor("s1", "rohit@l.com")
|
|
m._audit_sink_for("s1")({"session_id": "s1", "tool": "github_push", "arguments": {}, "call_id": "call_77", "approval": "allow", "status": "ok"})
|
|
row = m.audit_store.list(session_id="s1")[0]
|
|
assert row["actor"] == "rohit@l.com" and row["approved_by"] == "erin@l.com"
|
|
ev = to_ocsf(row)
|
|
assert ev["unmapped"]["approved_by"] == "erin@l.com" and ev["unmapped"]["approval"] == "allow"
|
|
# A second resolve is a no-op and never rewrites the decider.
|
|
assert m.inbox.resolve(item.id, "deny", by="someone") is False
|
|
assert m.inbox.get(item.id).resolved_by == "erin@l.com"
|