Files
openworker/tests/test_audit_export.py
Rohit C Prasad 274c9a582b Remote machines: identity, reverse channel, acceptor, sealed key wallet
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.
2026-09-05 08:00:16 -07:00

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"