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
146 changes: 134 additions & 12 deletions assets/sourceos/bin/turtle-netwatch
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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__":
Expand Down
10 changes: 8 additions & 2 deletions assets/sourceos/runbooks/netwatch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
56 changes: 56 additions & 0 deletions assets/sourceos/tests/test_turtle_netwatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]))
Loading