Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs/cockpit-receipts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Cockpit Receipts — the trust surface

The 5th surface of `docs/cockpit-spec.md` §2, and the one no incumbent can copy: a
live, verifiable ledger of **every governed decision** the enforcing bridge made —
across all three surfaces — presented to the user.

Gartner told enterprises to *block* agentic browsers because "controls to inspect
agent intent do not exist at scale." Receipts is that control, made visible: a
running, signed log of every action the user's agents proposed and exactly what
policy did about it.

## One stream, three surfaces
`agent-control-bridge.py` attests every decision as a safe-trace `ReasoningEvent` to
one append-only stream (`reasoning-events.ndjson`):

| prefix | surface | contract |
|---|---|---|
| `browser.*` | the governed browser | `agentActionContract` |
| `iot.*` | the smart-home / IoT estate | `iotActionContract` |
| `agent.*` | the cockpit's local agent | `agentMachineActionContract` (Lane 4) |

`scripts/bearbrowser-receipts.py` reads that one stream and serves it as the ledger.
It derives the surface from the event-type prefix, so it unifies all three
automatically — a `browser.navigate` permit, an `iot.unlock-door` violation, and an
`agent.execute-shell` block all land in the same feed.

## API (loopback-only, read-only)
```
GET /health liveness + stream location
GET /receipts parsed receipts, newest first
?surface=browser|iot|agent filter by surface
&decision=permit|deny filter by verdict
&violations=1 only policy violations
&since=<ISO8601>&limit=N
GET /receipts/summary counts by surface / decision / class + violations
GET /receipts/stream Server-Sent Events live tail (new receipts)
```
The cockpit's Receipts panel (client-vue) fetches `/receipts` + subscribes to
`/receipts/stream` — pointed at this loopback service by the resolver
(`window.__COCKPIT_CONFIG__`). Nothing reaches off-device; the loopback bind is the
security boundary (CORS is permissive because the payload is read-only, non-secret).

## Each receipt
```jsonc
{ "id": "...", "at": "2026-…Z", "surface": "agent",
"action": "execute-shell", "eventType": "agent.policy.violation",
"decision": "deny", "class": "prohibited", "violation": true,
"reason": "PROHIBITED action 'execute-shell' BLOCKED at decision time",
"runRef": "...", "policyRef": "...", "trustLevel": "trusted-control-input",
"traceLevel": "workspace-safe" }
```
Safe-trace by construction: the summary carries the action + verdict, never raw page
content, secret values, or private reasoning.

## Verify
`scripts/tests/test_receipts.py` — seeds real browser + iot decisions via the bridge
plus a synthetic `agent.*` event, then asserts the projection, filters, and summary:
7 receipts, 3 surfaces unified, 3 violations flagged, 4 permits / 3 denies.

Wire alongside the other loopback sidecars (`bearbrowser-sidecar-server`,
`bearbrowser-agent-machine-gate`, the Rust `iot-sidecar`); the browser rewrites the
ephemeral port into `cockpit-config.js` at launch, same as the rest.
215 changes: 215 additions & 0 deletions scripts/bearbrowser-receipts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""BearBrowser Receipts — the cockpit's trust surface (loopback).

Every governed decision the enforcing bridge makes — across ALL three surfaces —
is attested to one evidence stream (agent-control-bridge.py writes safe-trace
ReasoningEvents to reasoning-events.ndjson):

browser.* the governed browser (agentActionContract)
iot.* the smart-home / IoT estate (iotActionContract)
agent.* the cockpit's local agent (agentMachineActionContract)

This service reads that one stream and serves it as a live, verifiable ledger — the
Receipts surface of docs/cockpit-spec.md §2. It is the trust differentiator: a
running, signed log of every action the user's agents proposed and exactly what
policy did about it — the thing Gartner says agentic browsers cannot show. Nothing
here reaches off-device; it only reads the local evidence stream and serves loopback.

GET /health liveness + stream location
GET /receipts?surface=&decision=&violations=&limit=&since=
parsed receipts, newest first
GET /receipts/summary aggregate counts per surface + verdicts
GET /receipts/stream Server-Sent Events live tail (new receipts)

BEARBROWSER_RECEIPTS_PORT listen port (default 8092)
SOURCEOS_REASONING_EVIDENCE evidence root (default ~/.local/state/sourceos/reasoning)
"""
import json
import os
import sys
import time
import threading
import pathlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs

PORT = int(os.environ.get("BEARBROWSER_RECEIPTS_PORT", "8092"))


def evidence_root() -> pathlib.Path:
env = os.environ.get("SOURCEOS_REASONING_EVIDENCE")
if env:
return pathlib.Path(env).expanduser()
return pathlib.Path.home() / ".local" / "state" / "sourceos" / "reasoning"


def stream_path() -> pathlib.Path:
return evidence_root() / "reasoning-events.ndjson"


def surface_of(event_type: str) -> str:
head = (event_type or "").split(".", 1)[0]
return head if head in ("browser", "iot", "agent") else "other"


def to_receipt(ev: dict) -> dict:
"""Project a raw ReasoningEvent into a receipt row for the surface."""
et = ev.get("eventType", "")
decision = ev.get("decision") # merged from the bridge's extra: permit|deny
is_violation = et.endswith("policy.violation")
if decision is None:
decision = "deny" if is_violation else "permit"
# action = the event type minus the surface prefix (browser.navigate -> navigate)
action = et.split(".", 1)[1] if "." in et else et
return {
"id": ev.get("id"),
"at": ev.get("capturedAt"),
"surface": surface_of(et),
"eventType": et,
"action": action,
"decision": decision,
"class": ev.get("actionClass"),
"violation": is_violation,
"reason": ev.get("summary"),
"runRef": ev.get("runRef"),
"policyRef": ev.get("policyRef"),
"approvalTokenRef": ev.get("approvalTokenRef"),
"trustLevel": ev.get("trustLevel"),
"traceLevel": ev.get("traceLevel"),
}


def read_events():
"""Yield parsed events from the stream (skips malformed lines)."""
p = stream_path()
if not p.exists():
return
with p.open("r", encoding="utf-8", errors="replace") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
continue


class ReceiptsHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"

def _json(self, obj, status=200):
body = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
# Loopback-only page (cockpit) reads this; permissive CORS is safe (no secrets,
# read-only, loopback bind). The bind — not CORS — is the security boundary.
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def do_GET(self):
u = urlparse(self.path)
q = parse_qs(u.query)

if u.path == "/health":
p = stream_path()
return self._json({"ok": True, "stream": str(p), "exists": p.exists()})

if u.path == "/receipts":
receipts = [to_receipt(e) for e in read_events()]
# filters
surf = (q.get("surface", [None])[0])
dec = (q.get("decision", [None])[0])
since = (q.get("since", [None])[0])
only_viol = q.get("violations", ["0"])[0] in ("1", "true")
if surf:
receipts = [r for r in receipts if r["surface"] == surf]
if dec:
receipts = [r for r in receipts if r["decision"] == dec]
if only_viol:
receipts = [r for r in receipts if r["violation"]]
if since:
receipts = [r for r in receipts if (r["at"] or "") > since]
receipts.reverse() # newest first
try:
limit = int(q.get("limit", ["200"])[0])
except ValueError:
limit = 200
return self._json({"count": len(receipts), "receipts": receipts[:limit]})

if u.path == "/receipts/summary":
surfaces, verdicts, violations = {}, {"permit": 0, "deny": 0}, 0
classes = {"allowed": 0, "gated": 0, "prohibited": 0}
total = 0
for e in read_events():
r = to_receipt(e)
total += 1
surfaces[r["surface"]] = surfaces.get(r["surface"], 0) + 1
verdicts[r["decision"]] = verdicts.get(r["decision"], 0) + 1
if r["class"] in classes:
classes[r["class"]] += 1
if r["violation"]:
violations += 1
return self._json({
"total": total, "bySurface": surfaces, "byDecision": verdicts,
"byClass": classes, "violations": violations,
})

if u.path == "/receipts/stream":
return self._sse_tail()

return self._json({"error": "not-found"}, status=404)

def _sse_tail(self):
"""Server-Sent Events: emit new receipts as they are appended."""
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Connection", "keep-alive")
self.end_headers()
p = stream_path()
# start at end of file; stream appended lines
pos = p.stat().st_size if p.exists() else 0
try:
while True:
if p.exists() and p.stat().st_size > pos:
with p.open("r", encoding="utf-8", errors="replace") as fh:
fh.seek(pos)
for line in fh:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
payload = json.dumps(to_receipt(ev))
self.wfile.write(f"data: {payload}\n\n".encode())
self.wfile.flush()
pos = fh.tell()
else:
self.wfile.write(b": keepalive\n\n")
self.wfile.flush()
time.sleep(1.0)
except (BrokenPipeError, ConnectionResetError):
return

def log_message(self, fmt, *args):
sys.stderr.write("[receipts] " + (fmt % args) + "\n")


def main():
srv = ThreadingHTTPServer(("127.0.0.1", PORT), ReceiptsHandler)
print(f"[receipts] serving the trust ledger on 127.0.0.1:{PORT} "
f"(stream: {stream_path()})", file=sys.stderr)
try:
srv.serve_forever()
except KeyboardInterrupt:
srv.shutdown()


if __name__ == "__main__":
main()
104 changes: 104 additions & 0 deletions scripts/tests/test_receipts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Receipts service proof — the cockpit trust surface reads the evidence stream and
unifies every governed decision across all three surfaces.

Uses a temp evidence dir. Generates REAL browser + iot decisions via the enforcing
bridge, plus a synthetic `agent.*` event (the agent-machine surface lands with Lane
4 / PR #71; the Receipts service derives the surface purely from the event-type
prefix, so it handles agent.* independently). Asserts the receipts projection,
filters, and summary.

Run: python3 scripts/tests/test_receipts.py (exit 0 = PASS)
"""
import importlib.util
import json
import os
import pathlib
import sys
import tempfile

_ROOT = pathlib.Path(__file__).resolve().parents[2]


def _load(name, rel):
spec = importlib.util.spec_from_file_location(name, _ROOT / rel)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod


def main() -> int:
tmp = tempfile.mkdtemp()
os.environ["SOURCEOS_REASONING_EVIDENCE"] = tmp

acb = _load("acb", "scripts/agent-control-bridge.py")
rec = _load("rec", "scripts/bearbrowser-receipts.py")

def fire(surface, action, params=None, token=None):
pol = acb.load_policy(surface)
acb.ControlBridge(pol, emit=True).evaluate_action(action, params or {}, token)

# REAL decisions across the two surfaces that exist on main.
fire("browser", "navigate") # allowed
fire("browser", "enter-credentials") # prohibited -> browser.policy.violation
fire("iot", "read-state") # allowed
fire("iot", "unlock-door") # prohibited -> iot.policy.violation
fire("iot", "unlock-door", {"actor": "user", "userGesture": True}, "action:unlock-door") # gated permit

# Synthetic agent-machine events (Lane 4 surface). Appended directly so Receipts
# is proven surface-complete without requiring Lane 4 to be merged.
with rec.stream_path().open("a", encoding="utf-8") as fh:
fh.write(json.dumps({"id": "ev-agent-1", "eventType": "agent.read-graph",
"decision": "permit", "actionClass": "allowed",
"summary": "read-graph permitted", "capturedAt": "2026-07-19T10:00:00Z"}) + "\n")
fh.write(json.dumps({"id": "ev-agent-2", "eventType": "agent.policy.violation",
"decision": "deny", "actionClass": "prohibited",
"summary": "PROHIBITED execute-shell BLOCKED", "capturedAt": "2026-07-19T10:00:01Z"}) + "\n")

receipts = [rec.to_receipt(e) for e in rec.read_events()]
surfaces = {r["surface"] for r in receipts}
violations = sum(1 for r in receipts if r["violation"])
permits = sum(1 for r in receipts if r["decision"] == "permit")
denies = sum(1 for r in receipts if r["decision"] == "deny")

checks = [
("read 7 receipts", len(receipts) == 7, len(receipts)),
("all 3 surfaces unified", surfaces == {"browser", "iot", "agent"}, surfaces),
("3 violations flagged", violations == 3, violations),
("permits counted", permits == 4, permits), # browser navigate, iot read, iot unlock w/ gesture, agent read
("denies counted", denies == 3, denies), # browser creds, iot unlock, agent shell
("summary agrees", rec_summary_ok(rec), True),
("surface filter works", filter_ok(rec), True),
]
print(" receipts:", len(receipts), "surfaces:", surfaces, "violations:", violations,
"permit/deny:", permits, "/", denies)
fails = [(n, got) for n, ok, got in checks if not ok]
for n, ok, got in checks:
print(f" [{'PASS' if ok else 'FAIL'}] {n}" + ("" if ok else f" (got {got})"))

print("\n" + "=" * 60)
if fails:
print(f"RESULT: FAIL ({len(fails)})")
return 1
print("RESULT: PASS — the Receipts surface unifies browser + iot + agent "
"governed decisions into one live, verifiable ledger.")
return 0


def rec_summary_ok(rec) -> bool:
surfaces, verdicts, viol, total = {}, {"permit": 0, "deny": 0}, 0, 0
for e in rec.read_events():
r = rec.to_receipt(e); total += 1
surfaces[r["surface"]] = surfaces.get(r["surface"], 0) + 1
verdicts[r["decision"]] = verdicts.get(r["decision"], 0) + 1
viol += 1 if r["violation"] else 0
return total == 7 and viol == 3 and set(surfaces) == {"browser", "iot", "agent"}


def filter_ok(rec) -> bool:
agent = [rec.to_receipt(e) for e in rec.read_events() if rec.to_receipt(e)["surface"] == "agent"]
return len(agent) == 2


if __name__ == "__main__":
sys.exit(main())
Loading