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
55 changes: 55 additions & 0 deletions docs/cockpit-lane4-governance.md
Original file line number Diff line number Diff line change
@@ -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.<action>` / `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:<name>`.

## 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.
82 changes: 82 additions & 0 deletions policy/bearbrowser-contract.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions scripts/agent-control-bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,7 @@ def _policy_ref_safe(self: Policy) -> str: # noqa: D401
_SURFACES = {
"browser": ("agentActionContract", "browser"),
"iot": ("iotActionContract", "iot"),
"agent-machine": ("agentMachineActionContract", "agent"),
}


Expand All @@ -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",
Expand Down
195 changes: 195 additions & 0 deletions scripts/bearbrowser-agent-machine-gate.py
Original file line number Diff line number Diff line change
@@ -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: <per-action token for gated actions>
"""
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()
Loading
Loading