diff --git a/docs/cockpit-lane4-governance.md b/docs/cockpit-lane4-governance.md new file mode 100644 index 0000000..d21c1ee --- /dev/null +++ b/docs/cockpit-lane4-governance.md @@ -0,0 +1,55 @@ +# Cockpit Lane 4 — governing the local agent + +Lane 4 of `docs/cockpit-composition-plan.md`. Puts BearBrowser's enforcing policy +engine in front of the cockpit's local agent (the bundled Noetica agent-machine +loopback sidecar), so **the same engine that governs the browser and the IoT estate +governs the cockpit agent** — the Gartner "inspect agent intent + enforce per-action +policy" control, applied to our own agent. A prompt-injected `execute-shell` is +denied at decision time and attested, never merely logged after. + +## What this adds (all in-repo, no edits to the Noetica engine) +- **`spec.agentMachineActionContract`** in `policy/bearbrowser-contract.yaml` — the + action→class map for the local agent: allowed (read-only), gated (mutating/ + executing, needs a per-action approval token), prohibited (host-reaching / + destructive; denied unless a cockpit user gesture proves `actor==user && + userGesture==true`, unforgeable by the agent planner). +- **`agent-control-bridge.py --surface agent-machine`** — one more namespace on the + same enforcing bridge; emits `agent.` / `agent.policy.violation` events. +- **`scripts/bearbrowser-agent-machine-gate.py`** — a loopback governance proxy: + the cockpit talks to the gate, the gate classifies every request through the + bridge (`allowed`→forward, `gated`→forward only with a valid token, `prohibited`→ + 403 + attest), then forwards permitted requests to the real sidecar. Unrecognized + mutating routes fail closed. +- **`scripts/tests/test_agent_machine_containment.py`** — 28/28: the governance + surface + the gate's route→action mapping. (Browser 79/79 and IoT 20/20 unchanged.) + +## Data flow +``` +cockpit (client-vue) ─► am-gate 127.0.0.1:8080 ─► [classify via bridge] ─► agent-machine 127.0.0.1:8091 + prohibited → 403 + agent.policy.violation +``` +The cockpit UI sets, on DIRECT user interaction only, headers the agent cannot forge: +`X-Cockpit-Actor: user`, `X-Cockpit-User-Gesture: true`, and for gated actions +`X-Cockpit-Approval-Token: action:`. + +## Integration with Lane 3 (one change, when both land) +Lane 3's `scripts/bearbrowser-agent-machine` launcher currently starts the sidecar on +the cockpit-facing port (default 8080). To insert the gate, start the sidecar on the +**upstream** port and run the gate on the cockpit-facing port: + +```sh +# in bearbrowser-agent-machine, before exec-ing the sidecar binary: +export NOETICA_AM_PORT="${NOETICA_AM_UPSTREAM_PORT:-8091}" # sidecar moves upstream +"$BIN" & # agent-machine on :8091 +exec python3 "$SELF_DIR/bearbrowser-agent-machine-gate.py" # gate on :8080 (cockpit target) +``` +The gate reads `NOETICA_AM_UPSTREAM_PORT` (default 8091) and `BEARBROWSER_AM_GATE_PORT` +(default 8080). `cockpit-config.js` keeps pointing at the cockpit-facing port, now the +gate — so the resolver targets a *governed* endpoint transparently. + +## Route classification (heuristic, security-first) +The gate maps `(method, path)` to a governance action (see `_RULES`). It is +deliberately conservative: known reads → allowed, known mutations → gated, +`/shell|/exec|/terminal|/fs|/credential|/egress|/governance` → prohibited, and any +**unrecognized non-GET route → a prohibited action (deny)**. Tighten the map as the +agent-machine `/api/*` surface stabilizes — but the default never fails open. diff --git a/policy/bearbrowser-contract.yaml b/policy/bearbrowser-contract.yaml index c0b706c..7b246a4 100644 --- a/policy/bearbrowser-contract.yaml +++ b/policy/bearbrowser-contract.yaml @@ -367,6 +367,88 @@ spec: when: { "in": [ { "var": "includesAction" }, [ "unlock-door", "disarm-security", "open-garage-door", "disable-camera" ] ] } reclassifyTo: prohibited as: unlock-door + # --------------------------------------------------------------------------- + # agentMachineActionContract — the SAME enforcing action→class model applied to + # the cockpit's local agent (the bundled Noetica agent-machine loopback sidecar, + # 127.0.0.1). Lane 4 of docs/cockpit-composition-plan.md: the cockpit talks to + # the governance gate (scripts/bearbrowser-agent-machine-gate.py), which + # classifies every agent-machine action through THIS contract via + # agent-control-bridge.py --surface agent-machine BEFORE forwarding it to the + # sidecar — so the local agent is governed by the same engine as the browser and + # the IoT estate. A prohibited (or prompt-injected) agent action is blocked at + # decision time and attested (agent.policy.violation), never merely logged after. + # allowed → read-only surfaces (graph/knowledge/status reads, no side effect) + # gated → mutating/executing actions, need a per-action approval token + # prohibited → host-reaching / destructive; denied unless a policyCondition + # proves an explicit cockpit user gesture (unforgeable by the agent) + # --------------------------------------------------------------------------- + agentMachineActionContract: + policyRef: urn:srcos:policy:bearbrowser-agent-machine + defaultDecision: deny + actionClasses: + allowed: + - action: read-graph + replayClass: exact + eventType: agent.read-graph + - action: read-knowledge + replayClass: exact + eventType: agent.read-knowledge + - action: query-status + replayClass: exact + eventType: agent.query-status + - action: list-models + replayClass: exact + eventType: agent.list-models + - action: chat-completion + replayClass: evidence-only + eventType: agent.chat-completion + gated: + - action: run-pipeline + replayClass: non-replayable-side-effect + eventType: agent.run-pipeline + - action: write-knowledge + replayClass: non-replayable-side-effect + eventType: agent.write-knowledge + - action: mutate-graph + replayClass: non-replayable-side-effect + eventType: agent.mutate-graph + - action: devspace-command + replayClass: non-replayable-side-effect + eventType: agent.devspace-command + - action: install-package + replayClass: non-replayable-side-effect + eventType: agent.install-package + - action: network-fetch + replayClass: non-replayable-side-effect + eventType: agent.network-fetch + prohibited: + - action: execute-shell + eventType: agent.execute-shell + - action: read-host-filesystem + eventType: agent.read-host-filesystem + - action: write-host-filesystem + eventType: agent.write-host-filesystem + - action: read-credentials + eventType: agent.read-credentials + - action: disable-egress-guard + eventType: agent.disable-egress-guard + - action: disable-governance + eventType: agent.disable-governance + # A prohibited agent action is reclassified DOWN to gated ONLY on an explicit + # cockpit user gesture (actor==user AND userGesture==true) — flags the cockpit + # UI sets on direct interaction that the agent planner cannot forge. So an + # agent/injected execute-shell matches no condition → stays prohibited → denied. + policyConditions: + - id: shell-with-user-gesture + description: A shell command is permitted (as gated) only on an explicit cockpit user gesture. + appliesTo: execute-shell + when: { "and": [ { "==": [ { "var": "actor" }, "user" ] }, { "==": [ { "var": "userGesture" }, true ] } ] } + reclassifyTo: gated + - id: hostfs-write-with-user-gesture + description: Writing the host filesystem is permitted (as gated) only on an explicit cockpit user gesture. + appliesTo: write-host-filesystem + when: { "and": [ { "==": [ { "var": "actor" }, "user" ] }, { "==": [ { "var": "userGesture" }, true ] } ] } + reclassifyTo: gated audit: requiredEvents: - browser.session.started diff --git a/scripts/agent-control-bridge.py b/scripts/agent-control-bridge.py index f057007..7a9a2e1 100644 --- a/scripts/agent-control-bridge.py +++ b/scripts/agent-control-bridge.py @@ -711,6 +711,7 @@ def _policy_ref_safe(self: Policy) -> str: # noqa: D401 _SURFACES = { "browser": ("agentActionContract", "browser"), "iot": ("iotActionContract", "iot"), + "agent-machine": ("agentMachineActionContract", "agent"), } @@ -728,9 +729,12 @@ def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser( description="BearBrowser ENFORCING agent control bridge — blocks gated/prohibited " "(incl. injected) actions at decision time + attests every one.") - parser.add_argument("--surface", choices=("browser", "iot"), default="browser", - help="Which action surface to govern: browser (default) or " - "iot (smart-home / IoT via spec.iotActionContract).") + parser.add_argument("--surface", choices=("browser", "iot", "agent-machine"), + default="browser", + help="Which action surface to govern: browser (default), " + "iot (spec.iotActionContract), or agent-machine " + "(the cockpit's local Noetica agent, " + "spec.agentMachineActionContract).") parser.add_argument("--action", required=True, help="Agent action to evaluate") parser.add_argument("--url", default="", help="Target URL (for navigate, informational)") parser.add_argument("--param", action="append", default=[], metavar="KEY=VALUE", diff --git a/scripts/bearbrowser-agent-machine-gate.py b/scripts/bearbrowser-agent-machine-gate.py new file mode 100755 index 0000000..1fe9e07 --- /dev/null +++ b/scripts/bearbrowser-agent-machine-gate.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""BearBrowser agent-machine governance gate — Lane 4 of the cockpit composition. + +The cockpit's local agent (the bundled Noetica agent-machine loopback sidecar) is +powerful: it runs pipelines, mutates graphs, writes knowledge, and — if unguarded — +could reach the host shell/filesystem. This gate puts BearBrowser's enforcing +policy engine in front of it, so EVERY agent action is classified BEFORE execution +by the same `agent-control-bridge.py` that governs the browser and the IoT estate: + + cockpit (client-vue) ──► THIS gate (127.0.0.1:GATE_PORT) + │ classify (agent-control-bridge --surface agent-machine) + │ allowed → forward + │ gated → forward ONLY with a valid approval token + │ prohibited → 403, blocked at decision time + attested + ▼ + agent-machine sidecar (127.0.0.1:UPSTREAM_PORT) + +This is the Gartner "inspect agent intent + enforce per-action policy" control, +applied to the cockpit's own agent. A prompt-injected `execute-shell` is DENIED and +attested (agent.policy.violation), never merely logged after. Loopback-only; it +does not edit the Noetica engine (it sits in front of it). + + NOETICA_AM_UPSTREAM_PORT real agent-machine port (default 8091) + BEARBROWSER_AM_GATE_PORT gate listen port the cockpit targets (default 8080) + +The cockpit passes user-intent via headers the UI sets on DIRECT interaction and +the agent planner cannot forge: + X-Cockpit-Actor: user|agent X-Cockpit-User-Gesture: true|false + X-Cockpit-Approval-Token: +""" +import json +import os +import sys +import urllib.request +import urllib.error +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import importlib.util +import pathlib + +# ── Load the enforcing bridge as a library (in-process; no per-request subprocess) ── +_HERE = pathlib.Path(__file__).resolve().parent +_spec = importlib.util.spec_from_file_location("acb", _HERE / "agent-control-bridge.py") +_acb = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_acb) + +GATE_PORT = int(os.environ.get("BEARBROWSER_AM_GATE_PORT", "8080")) +UPSTREAM_HOST = "127.0.0.1" +UPSTREAM_PORT = int(os.environ.get("NOETICA_AM_UPSTREAM_PORT", "8091")) +UPSTREAM = f"http://{UPSTREAM_HOST}:{UPSTREAM_PORT}" + +# ── Route → governance action mapping ─────────────────────────────────────────── +# Security-first: reads are allowed, known mutations are gated, host-reaching / +# destructive patterns are prohibited, and ANYTHING UNRECOGNIZED fails closed +# (mapped to a prohibited action → denied). The action names correspond to +# spec.agentMachineActionContract in policy/bearbrowser-contract.yaml. +# +# Each entry: (method_predicate, path_substring, action). First match wins. +_RULES = [ + # ── prohibited: host-reaching / destructive (denied unless a cockpit user gesture) ── + (lambda m: True, "/shell", "execute-shell"), + (lambda m: True, "/exec", "execute-shell"), + (lambda m: True, "/terminal", "execute-shell"), + (lambda m: True, "/command", "execute-shell"), + (lambda m: True, "/fs/read", "read-host-filesystem"), + (lambda m: True, "/fs/write", "write-host-filesystem"), + (lambda m: True, "/file/write", "write-host-filesystem"), + (lambda m: True, "/credential", "read-credentials"), + (lambda m: True, "/secret", "read-credentials"), + (lambda m: True, "/egress", "disable-egress-guard"), + (lambda m: True, "/offline", "disable-egress-guard"), + (lambda m: True, "/governance", "disable-governance"), + # ── gated: mutating / executing ── + (lambda m: m != "GET", "/pipeline", "run-pipeline"), + (lambda m: m != "GET", "/run", "run-pipeline"), + (lambda m: m != "GET", "/devspace", "devspace-command"), + (lambda m: m != "GET", "/install", "install-package"), + (lambda m: m != "GET", "/fetch", "network-fetch"), + (lambda m: m != "GET", "/proxy", "network-fetch"), + (lambda m: m != "GET", "/knowledge", "write-knowledge"), + (lambda m: m != "GET", "/graph", "mutate-graph"), + # ── allowed: reads + chat ── + (lambda m: True, "/chat", "chat-completion"), + (lambda m: True, "/completion", "chat-completion"), + (lambda m: True, "/status", "query-status"), + (lambda m: True, "/health", "query-status"), + (lambda m: True, "/models", "list-models"), + (lambda m: m == "GET", "/knowledge", "read-knowledge"), + (lambda m: m == "GET", "/graph", "read-graph"), +] + + +def classify_action(method: str, path: str) -> str: + p = path.lower() + for pred, needle, action in _RULES: + if needle in p and pred(method): + return action + # GET to an unrecognized route → treat as a read (allowed). Any other method to + # an unrecognized route → fail closed (an unmapped prohibited action → denied). + if method == "GET": + return "query-status" + return "execute-shell" # unknown mutation → the strongest prohibited → deny + + +# One bridge/session for the gate process. evaluate_action is pure + attests. +_policy = _acb.load_policy("agent-machine") + + +def _params_from_headers(headers) -> dict: + actor = (headers.get("X-Cockpit-Actor") or "agent").strip().lower() + gesture = (headers.get("X-Cockpit-User-Gesture") or "false").strip().lower() == "true" + return {"actor": actor, "userGesture": gesture} + + +class GateHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _handle(self): + action = classify_action(self.command, self.path) + params = _params_from_headers(self.headers) + token = self.headers.get("X-Cockpit-Approval-Token") + bridge = _acb.ControlBridge(_policy, emit=True, agent="cockpit-agent-machine") + decision = bridge.evaluate_action(action, params, token) + + if not decision.permitted: + # Blocked at decision time. The bridge already attested the event. + body = json.dumps({ + "error": "blocked-by-policy", + "action": decision.action, + "class": decision.action_class, + "reason": decision.reason, + "attestedEvent": decision.event.get("id"), + }).encode() + self.send_response(403) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + self.log_message("DENY %s %s -> %s/%s", self.command, self.path, + decision.action, decision.action_class) + return + + # Permitted → forward to the real agent-machine sidecar. + self._forward() + + def _forward(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else None + req = urllib.request.Request(UPSTREAM + self.path, data=body, method=self.command) + for k, v in self.headers.items(): + if k.lower() not in ("host", "content-length"): + req.add_header(k, v) + try: + with urllib.request.urlopen(req, timeout=300) as resp: + data = resp.read() + self.send_response(resp.status) + for k, v in resp.headers.items(): + if k.lower() not in ("transfer-encoding", "content-length", "connection"): + self.send_header(k, v) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + except urllib.error.HTTPError as e: + data = e.read() + self.send_response(e.code) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + except Exception as e: # upstream unreachable + msg = json.dumps({"error": "agent-machine-unreachable", "detail": str(e)}).encode() + self.send_response(502) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(msg))) + self.end_headers() + self.wfile.write(msg) + + # Route every method through the gate. + do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _handle + + def log_message(self, fmt, *args): + sys.stderr.write("[am-gate] " + (fmt % args) + "\n") + + +def main(): + # Loopback-only. Refuse to serve anything but 127.0.0.1. + server = ThreadingHTTPServer(("127.0.0.1", GATE_PORT), GateHandler) + print(f"[am-gate] governing agent-machine: cockpit -> 127.0.0.1:{GATE_PORT} " + f"-> {UPSTREAM} (surface=agent-machine)", file=sys.stderr) + try: + server.serve_forever() + except KeyboardInterrupt: + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_agent_machine_containment.py b/scripts/tests/test_agent_machine_containment.py new file mode 100755 index 0000000..63ef569 --- /dev/null +++ b/scripts/tests/test_agent_machine_containment.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Agent-machine injection-containment proof — Lane 4 of the cockpit composition. + +The cockpit's local agent (bundled Noetica agent-machine) is governed by the SAME +enforcing bridge as the browser and the IoT estate, via spec.agentMachineActionContract +(surface="agent-machine"), fronted by scripts/bearbrowser-agent-machine-gate.py. +This proves, WITHOUT running the sidecar, that: + + * read-only agent surfaces (graph/knowledge/status/models) are permitted + attest + agent. events; + * mutating/executing actions (run-pipeline, write-knowledge, mutate-graph, + devspace-command, install-package, network-fetch) are gated — denied without a + per-action approval token, permitted with one; + * host-reaching / destructive actions (execute-shell, read/write-host-filesystem, + read-credentials, disable-egress-guard, disable-governance) are PROHIBITED — an + agent/prompt-injected request is BLOCKED at decision time and attests an + agent.policy.violation, never merely logged after; + * a prohibited action is reclassified to gated ONLY on an explicit cockpit user + gesture (actor==user AND userGesture==true) — the agent planner cannot forge it; + * the gate's route→action mapping classifies unknown mutating routes to a + prohibited action (fail-closed), and never lets an unmapped POST through. + +Run: python3 scripts/tests/test_agent_machine_containment.py (exit 0 = PASS) +""" +import importlib.util +import pathlib +import sys + +_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 + + +acb = _load("acb", "scripts/agent-control-bridge.py") +gate = _load("amgate", "scripts/bearbrowser-agent-machine-gate.py") + + +def _eval(policy, action, params=None, token=None): + b = acb.ControlBridge(policy, emit=False) + return b.evaluate_action(action, params or {}, token) + + +# ── Part 1: the governance surface (agentMachineActionContract) ────────────────── +# (name, action, params, token, expect_decision, expect_class, expect_event) +SURFACE_CASES = [ + ("allowed: read-graph", "read-graph", {}, None, "permit", "allowed", "agent.read-graph"), + ("allowed: query-status", "query-status", {}, None, "permit", "allowed", "agent.query-status"), + ("allowed: chat-completion", "chat-completion",{}, None, "permit", "allowed", "agent.chat-completion"), + ("gated: run-pipeline no tok", "run-pipeline", {}, None, "deny", "gated", "agent.run-pipeline"), + ("gated: run-pipeline w/ tok", "run-pipeline", {}, "action:run-pipeline", "permit", "gated", "agent.run-pipeline"), + ("gated: write-knowledge", "write-knowledge",{}, None, "deny", "gated", "agent.write-knowledge"), + ("gated: devspace no tok", "devspace-command",{}, None, "deny", "gated", "agent.devspace-command"), + # THE CONTAINMENT BAR: injected/agent host-reaching actions blocked outright. + ("INJECTED execute-shell", "execute-shell", {}, None, "deny", "prohibited", "agent.policy.violation"), + ("INJECTED read-credentials", "read-credentials",{}, None, "deny", "prohibited", "agent.policy.violation"), + ("INJECTED disable-egress", "disable-egress-guard",{}, None, "deny", "prohibited", "agent.policy.violation"), + ("INJECTED disable-governance","disable-governance",{}, None, "deny", "prohibited", "agent.policy.violation"), + ("INJECTED write-host-fs", "write-host-filesystem",{}, None, "deny", "prohibited", "agent.policy.violation"), + # user gesture reclassifies shell DOWN to gated — still needs a token. + ("user-gesture shell no tok", "execute-shell", {"actor": "user", "userGesture": True}, None, "deny", "gated", "agent.execute-shell"), + ("user-gesture shell w/ tok", "execute-shell", {"actor": "user", "userGesture": True}, "action:execute-shell","permit", "gated", "agent.execute-shell"), + # an agent forging userGesture but not actor==user stays prohibited. + ("forged gesture wrong actor", "execute-shell", {"actor": "agent", "userGesture": True},"action:execute-shell","deny", "prohibited", "agent.policy.violation"), + # unknown action fails closed. + ("unknown action fails closed","reprogram-brain",{}, None, "deny", "prohibited", "agent.policy.violation"), +] + +# ── Part 2: the gate's route → action classification ──────────────────────────── +# (name, method, path, expected_action) +ROUTE_CASES = [ + ("GET status", "GET", "/api/status", "query-status"), + ("GET graph read", "GET", "/api/graph/nodes", "read-graph"), + ("POST chat", "POST", "/api/chat/completions", "chat-completion"), + ("POST pipeline run", "POST", "/api/pipeline/run", "run-pipeline"), + ("POST devspace exec", "POST", "/api/devspace/exec", "execute-shell"), # /exec wins (prohibited) — good + ("POST knowledge write", "POST", "/api/knowledge/upsert", "write-knowledge"), + ("POST graph mutate", "POST", "/api/graph/edge", "mutate-graph"), + ("POST shell", "POST", "/api/shell", "execute-shell"), + ("POST fs write", "POST", "/api/fs/write", "write-host-filesystem"), + ("POST credentials", "POST", "/api/credential/get", "read-credentials"), + ("POST unknown mutation","POST", "/api/some/new/thing", "execute-shell"), # fail-closed + ("GET unknown", "GET", "/api/some/new/thing", "query-status"), +] + + +def main() -> int: + policy = acb.load_policy("agent-machine") + failures = [] + + for name, action, params, token, exp_dec, exp_cls, exp_ev in SURFACE_CASES: + d = _eval(policy, action, params, token) + got_ev = d.event["eventType"] + ok = (d.decision == exp_dec and d.action_class == exp_cls and got_ev == exp_ev + and not got_ev.startswith(("browser.", "iot."))) + if not ok: + failures.append(f"[surface] {name}: got ({d.decision},{d.action_class},{got_ev}) " + f"expected ({exp_dec},{exp_cls},{exp_ev})") + print(f" [{'PASS' if ok else 'FAIL'}] surface: {name:32s} -> {d.decision:6s} {d.action_class:10s} {got_ev}") + + for name, method, path, exp_action in ROUTE_CASES: + got = gate.classify_action(method, path) + ok = got == exp_action + if not ok: + failures.append(f"[route] {name}: {method} {path} -> {got} (expected {exp_action})") + print(f" [{'PASS' if ok else 'FAIL'}] route: {name:32s} -> {got}") + + total = len(SURFACE_CASES) + len(ROUTE_CASES) + print("\n" + "=" * 70) + print(f"PASSED {total - len(failures)} FAILED {len(failures)}") + if failures: + print("\nRESULT: FAIL — agent-machine containment NOT proven") + for f in failures: + print(" - " + f) + return 1 + print("\nRESULT: PASS — rogue/injected COCKPIT-AGENT actions BLOCKED at decision " + "time + attested.\nThe local agent is governed by the same engine as the " + "browser and the IoT estate.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())