diff --git a/assets/sourceos/bin/turtle-netwatch b/assets/sourceos/bin/turtle-netwatch index f6808466ddf..592c47d8178 100755 --- a/assets/sourceos/bin/turtle-netwatch +++ b/assets/sourceos/bin/turtle-netwatch @@ -290,9 +290,10 @@ def _consent_enforce(request: dict[str, Any]) -> tuple[bool, list[str], str]: policy-fabric engine when a sibling checkout is present; FAILS CLOSED (deny) otherwise — a network mutation must never proceed ungated.""" root = os.environ.get("PROPHET_POLICY_FABRIC") - candidates = [root] if root else [] - for p in Path(__file__).resolve().parents: - candidates.append(str(p / "policy-fabric")) + if root: + candidates = [root] # explicit root honored alone -> a miss fails closed, no fallback + else: + candidates = [str(p / "policy-fabric") for p in Path(__file__).resolve().parents] for c in candidates: pf = Path(c) if c else None if pf and (pf / "policy_fabric" / "purpose_admissibility_gate.py").exists(): @@ -337,16 +338,126 @@ def cmd_propose(args) -> int: _print(args, {"decision": "deny", "denyReasons": reasons, "receipt": receipt["kind"]}) print("REFUSED (fail-closed): network action not admissible.", file=sys.stderr) return 3 - # admitted -> route to Governor (guardrail-fabric) for human approval; do NOT - # auto-apply. Applying requires --apply AND an approval token from the Governor. - approval = os.environ.get("NETWATCH_GOVERNOR_APPROVAL") - receipt = {"kind": "netwatch.action.proposed", "action": action, - "governor": "guardrail-fabric", "approved": bool(approval and args.apply), + # admitted by consent -> route to the Governor (guardrail-fabric) for a real + # PolicyDecision. Nothing auto-applies: the Governor default is ESCALATE + # (human required); only a recorded ADMIT decision + --apply applies; a + # recorded DENY refuses. + gov = _governor_decision(spec["capability"], args.target) + if gov == "deny": + receipt = {"kind": "netwatch.action.refused", "action": action, + "denyReasons": ["Governor (guardrail-fabric) denied this action"], "ts": utc_now()} + _write_receipt(receipt) + _print(args, {"decision": "deny", "by": "governor", "receipt": receipt["kind"]}) + print("REFUSED: Governor denied.", file=sys.stderr) + return 3 + approved = gov == "allow" + receipt = {"kind": "netwatch.action.proposed", "action": action, "governor": "guardrail-fabric", + "governorDecision": gov, "applied": bool(approved and args.apply), "seal": _seal(action), "ts": utc_now()} _write_receipt(receipt) - status = "applied" if receipt["approved"] else "awaiting-governor-approval" - _print(args, {"decision": "admit", "status": status, "action": action["capability"], - "target": args.target, "receipt": receipt["kind"]}) + if approved and args.apply: + status = "applied" + elif approved: + status = "governor-approved (re-run with --apply to act)" + else: + status = "awaiting-governor-approval (run: turtle-netwatch decide --action ... --target ... --approve)" + _print(args, {"decision": "admit", "governorDecision": gov, "status": status, + "action": action["capability"], "target": args.target, "receipt": receipt["kind"]}) + return 0 + + +# --------------------------------------------------------------------------- governor (guardrail-fabric) +def _guardrail(): + """Bind to guardrail-fabric's decision module (the Governor). Discovery: + $PROPHET_GUARDRAIL_FABRIC -> a sibling guardrail-fabric checkout. Returns the + module or None; a network mutation cannot be *authorized* without it.""" + root = os.environ.get("PROPHET_GUARDRAIL_FABRIC") + if root: + cands = [root] # explicit root honored alone -> a miss fails closed, no fallback + else: + cands = [str(p / "guardrail-fabric") for p in Path(__file__).resolve().parents] + for c in cands: + gf = Path(c) if c else None + if gf and (gf / "guardrail_fabric" / "decision.py").exists(): + sys.path.insert(0, str(gf)) + try: + from guardrail_fabric import decision as gd # type: ignore + return gd + except Exception: + return None + return None + + +def _action_key(capability: str, target: str) -> str: + return f"{capability}|{target}" + + +def _decisions_path() -> Path: + return state_dir() / "decisions.jsonl" + + +def _governor_decision(capability: str, target: str) -> str: + """The latest recorded Governor decision for this action, or 'escalate' + (human required) when none exists — never a silent admit.""" + latest = "escalate" + p = _decisions_path() + if p.exists(): + for line in p.read_text().splitlines(): + if not line.strip(): + continue + r = json.loads(line) + if r.get("actionKey") == _action_key(capability, target): + latest = r.get("decision", latest) + return latest + + +def cmd_decide(args) -> int: + """The Governor surface: record a guardrail-fabric PolicyDecision (admit/deny) + for a proposed action. Fail-closed: without guardrail-fabric a network + mutation cannot be governed, so it cannot be authorized.""" + gd = _guardrail() + if gd is None: + print("Governor unavailable: guardrail-fabric not found (set $PROPHET_GUARDRAIL_FABRIC); " + "a network mutation cannot be authorized.", file=sys.stderr) + return 3 + capability = _ACTIONS[args.action]["capability"] # key by capability, as propose/pending do + dec = gd.Decision.ALLOW if args.approve else gd.Decision.DENY + pd = gd.PolicyDecision.create( + policy_id="netwatch/network-mutation", + decision=dec, + reason=args.reason or ("operator approved after review" if args.approve else "operator denied"), + remediation="review netwatch findings (beaconing/egress) before applying", + ) + rec = {"kind": "netwatch.governor.decision", "actionKey": _action_key(capability, args.target), + "decision": dec.value, "governorDecisionId": pd.decisionId, + "policyDecision": pd.to_dict(), "ts": utc_now()} + with _decisions_path().open("a") as fh: + fh.write(json.dumps(rec) + "\n") + _print(args, {"governor": "guardrail-fabric", "action": args.action, "target": args.target, + "decision": dec.value, "decisionId": pd.decisionId}) + return 0 + + +def cmd_pending(args) -> int: + """List proposed actions still awaiting a Governor decision (escalate).""" + sink = state_dir() / "actions.jsonl" + pending, seen = [], set() + if sink.exists(): + for line in sink.read_text().splitlines(): + if not line.strip(): + continue + r = json.loads(line) + if r.get("kind") != "netwatch.action.proposed": + continue + a = r["action"] + key = _action_key(a["capability"], a["args"].get("target", "")) + if key in seen: + continue + seen.add(key) + if _governor_decision(a["capability"], a["args"].get("target", "")) == "escalate": + pending.append({"capability": a["capability"], "target": a["args"].get("target"), + "seal": r.get("seal"), "ts": r.get("ts")}) + _print(args, pending if args.json else {"pending": len(pending), "actions": pending}) return 0 @@ -387,9 +498,20 @@ def main(argv=None) -> int: pr.add_argument("--target", required=True) pr.add_argument("--apply", action="store_true") pr.add_argument("--json", action="store_true") + de = sub.add_parser("decide") # the Governor surface + de.add_argument("--action", required=True, choices=list(_ACTIONS)) + de.add_argument("--target", required=True) + g = de.add_mutually_exclusive_group(required=True) + g.add_argument("--approve", action="store_true") + g.add_argument("--deny", action="store_true") + de.add_argument("--reason", default=None) + de.add_argument("--json", action="store_true") + pe = sub.add_parser("pending") + pe.add_argument("--json", action="store_true") args = p.parse_args(argv) return {"snapshot": cmd_snapshot, "observe": cmd_observe, "graph": cmd_graph, - "detect": cmd_detect, "propose": cmd_propose}[args.cmd](args) + "detect": cmd_detect, "propose": cmd_propose, "decide": cmd_decide, + "pending": cmd_pending}[args.cmd](args) if __name__ == "__main__": diff --git a/assets/sourceos/runbooks/netwatch.yaml b/assets/sourceos/runbooks/netwatch.yaml index 58dec4653d3..acbe08cbea9 100644 --- a/assets/sourceos/runbooks/netwatch.yaml +++ b/assets/sourceos/runbooks/netwatch.yaml @@ -15,6 +15,12 @@ steps: - cmd: turtle-netwatch detect desc: "Scan the window for anomalies — beaconing (low-variance periodic contact) and egress fan-out. Exit 2 if any found." - cmd: turtle-netwatch propose --action block-domain --target suspicious.example - desc: "Propose a network Action. It is ADMITTED-OR-REFUSED by the consent plane (operate purpose, system-space); on admit it is routed to the Governor for approval and NOT auto-applied. Deny => refused fail-closed." + desc: "Propose a network Action. It is ADMITTED-OR-REFUSED by the consent plane (operate purpose, system-space); on consent-admit it goes to the Governor, which defaults to ESCALATE (human required) — NOT auto-applied. Deny => refused fail-closed." + - cmd: turtle-netwatch pending + desc: "List proposed actions awaiting a Governor decision." + - cmd: turtle-netwatch decide --action block-domain --target suspicious.example --approve --reason 'confirmed after review' + desc: "The Governor decision (human-in-loop): records a real guardrail-fabric PolicyDecision (allow/deny). Fail-closed — without guardrail-fabric a network mutation cannot be authorized." + - cmd: turtle-netwatch propose --action block-domain --target suspicious.example --apply + desc: "Re-propose with --apply: now consent-admit + Governor-allow => APPLIED. A Governor deny would refuse it here instead." - cmd: cat "${SOURCEOS_TERMINAL_RECEIPTS:-$HOME/.local/state/sourceos/terminal/receipts}/../netwatch/actions.jsonl" - desc: "Review the action receipts (proposed / refused) — every decision is hash-sealed and auditable." + desc: "Review the action + Governor-decision receipts — every step is hash-sealed and auditable." diff --git a/assets/sourceos/tests/test_turtle_netwatch.py b/assets/sourceos/tests/test_turtle_netwatch.py index dfccfca5dc2..b228ac9356c 100644 --- a/assets/sourceos/tests/test_turtle_netwatch.py +++ b/assets/sourceos/tests/test_turtle_netwatch.py @@ -97,6 +97,62 @@ def test_snapshot_runs_and_is_shaped(tmp_path): json.loads(r.stdout) # a list (possibly empty if no ss/lsof) — must be valid JSON +# ------------------------------------------------------------------ Governor loop +def _gov_env(tmp_path): + env = dict(os.environ) + env["SOURCEOS_TERMINAL_RECEIPTS"] = str(tmp_path / "r") + return env + + +def test_governor_defaults_to_escalate(tmp_path, monkeypatch): + monkeypatch.setenv("SOURCEOS_TERMINAL_RECEIPTS", str(tmp_path / "r")) + # no decision recorded -> never a silent admit + assert nw._governor_decision("net.block", "evil.example") == "escalate" + + +def test_governor_reads_recorded_decision(tmp_path, monkeypatch): + monkeypatch.setenv("SOURCEOS_TERMINAL_RECEIPTS", str(tmp_path / "r")) + d = nw._decisions_path() + d.parent.mkdir(parents=True, exist_ok=True) + d.write_text(json.dumps({"actionKey": "net.block|evil.example", "decision": "allow"}) + "\n") + assert nw._governor_decision("net.block", "evil.example") == "allow" + # a later deny supersedes + with d.open("a") as fh: + fh.write(json.dumps({"actionKey": "net.block|evil.example", "decision": "deny"}) + "\n") + assert nw._governor_decision("net.block", "evil.example") == "deny" + + +def test_decide_fails_closed_without_guardrail(tmp_path): + env = _gov_env(tmp_path) + env["PROPHET_GUARDRAIL_FABRIC"] = str(tmp_path / "nope") + r = subprocess.run([sys.executable, str(BIN), "decide", "--action", "block-domain", + "--target", "x", "--approve"], env=env, text=True, capture_output=True) + assert r.returncode == 3 # a network mutation can't be governed -> can't be authorized + assert "guardrail-fabric" in r.stderr + + +def test_pending_excludes_decided(tmp_path, monkeypatch): + monkeypatch.setenv("SOURCEOS_TERMINAL_RECEIPTS", str(tmp_path / "r")) + sink = nw.state_dir() / "actions.jsonl" + for tgt in ("a.example", "b.example"): + rec = {"kind": "netwatch.action.proposed", + "action": {"capability": "net.block", "args": {"target": tgt}}, + "seal": "s", "ts": "t"} + with sink.open("a") as fh: + fh.write(json.dumps(rec) + "\n") + # decide b.example -> only a.example remains pending + nw._decisions_path().write_text(json.dumps({"actionKey": "net.block|b.example", "decision": "allow"}) + "\n") + + class A: # minimal args + json = True + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + nw.cmd_pending(A()) + out = json.loads(buf.getvalue()) + assert [x["target"] for x in out] == ["a.example"] + + if __name__ == "__main__": import pytest sys.exit(pytest.main([__file__, "-q"]))