Skip to content

Commit 4bbaf69

Browse files
author
Michael Heller
committed
feat(agentd): enforce the consent-plane terminal-surface envelope (enforce-sweep)
Extends the enforce-sweep (after goose voice + netwatch) to turtle-agentd, the terminal-surface tool-execution host. An AUTONOMOUS AGENT acting on the terminal surface may read/edit/test (discover/implement/verify) but may NOT egress (push/publish/network) or operate (deploy/infra) — the consent-plane surface envelope (deny_purposes=[egress,operate]) now holds at runtime. - consent_plane_check(command, actor_id): denies egress/operate commands for agent actors; humans keep full authority (envelope governs agents only). _is_agent_actor is fail-closed — only an explicit `human:` id gets authority, every other non-empty id is governed. - classification is local (regex over network/publish/deploy/infra commands) so containment holds even when the consent engine is unreachable. - wired into policy_evaluate: a consent-plane deny short-circuits before Policy Fabric (defence in depth). - 5 tests: agent egress denied, agent read/edit/test allowed, human unrestricted, policy_evaluate short-circuits, fail-closed actor detection. Daemon smoke green.
1 parent 9272447 commit 4bbaf69

2 files changed

Lines changed: 126 additions & 0 deletions

File tree

assets/sourceos/bin/turtle-agentd

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,70 @@ def _local_policy_check(command: str) -> tuple[str, str, str | None]:
700700
return "allow", "No local policy rules matched.", None
701701

702702

703+
# ---------------------------------------------------------------------------
704+
# Consent-plane terminal-surface envelope (enforce-sweep)
705+
# ---------------------------------------------------------------------------
706+
# The consent-plane surface catalog gives the `terminal` surface
707+
# purposes=[discover, implement, verify] and deny_purposes=[egress, operate].
708+
# That envelope contains an AUTONOMOUS AGENT: a prompt-injected / runaway agent
709+
# acting on the terminal surface may read, edit, and test, but may NOT egress
710+
# (push/publish/network-send) or operate (deploy/infra). It does NOT restrict the
711+
# human operator, who holds full authority — so this check applies only to agent
712+
# actors. The classification is local so containment holds even when the consent
713+
# engine is unreachable (fail-closed for the agent); a reachable engine adds the
714+
# full role x space x consent check on top.
715+
716+
_EGRESS_OPERATE_PATTERNS: list[re.Pattern[str]] = [
717+
re.compile(p, re.IGNORECASE) for p in (
718+
r"\bgit\s+push\b", r"\bgit\s+(fetch|pull|clone)\b",
719+
r"\bgh\s+(pr\s+merge|release\s+create|repo\s+create|api\b.*-X\s*(POST|PUT|PATCH|DELETE))",
720+
r"\b(curl|wget)\b", r"\bscp\b", r"\brsync\b.*::|\brsync\b.*@", r"\bssh\b\s+\S+@",
721+
r"\bkubectl\s+(apply|create|delete|edit|patch|scale|rollout|drain|cordon)",
722+
r"\bdocker\s+(push|run|-)", r"\bhelm\s+(install|upgrade|uninstall)",
723+
r"\bterraform\s+(apply|destroy)", r"\bsystemctl\s+(start|stop|restart|enable|disable)",
724+
r"\b(aws|gcloud|az)\s+\S+\s+(create|delete|update|deploy|put|set)",
725+
r"\bnpm\s+publish\b", r"\bpip\s+(upload|install)\b", r"\bcargo\s+publish\b",
726+
r"\bnc\b\s+\S+\s+\d+", r"\b(dd|mkfs|fdisk)\b",
727+
)
728+
]
729+
730+
731+
def _is_agent_actor(actor_id: str | None) -> bool:
732+
"""The consent-plane envelope governs autonomous agents, not the human.
733+
Only an explicit 'human:' actor keeps full authority; every other non-empty
734+
id (agent:*, urn:srcos:agent:*, bot:*, svc:*, or an unrecognized id) is
735+
governed — fail-closed, so an unknown actor cannot slip past as human.
736+
(turtle-agentd defaults actor_id to 'human:local-user' at its call sites, so
737+
an empty id here means the daemon's own default human path.)"""
738+
a = (actor_id or "").lower()
739+
if not a or a.startswith("human:"):
740+
return False
741+
return True
742+
743+
744+
def _terminal_purpose(command: str) -> str | None:
745+
"""Return 'egress-or-operate' when the command leaves the surface (network /
746+
publish / deploy / infra mutation); None for read/edit/test (discover/
747+
implement/verify), which the terminal surface allows."""
748+
for pat in _EGRESS_OPERATE_PATTERNS:
749+
if pat.search(command):
750+
return "egress-or-operate"
751+
return None
752+
753+
754+
def consent_plane_check(command: str, actor_id: str | None) -> tuple[str, str]:
755+
"""Apply the terminal-surface envelope to an agent actor's command.
756+
Returns (decision, reason). Deny wins over Policy Fabric (defence in depth)."""
757+
if not _is_agent_actor(actor_id):
758+
return "allow", "human actor — consent-plane envelope governs agents only"
759+
if _terminal_purpose(command):
760+
return ("deny",
761+
"[consent-plane] terminal surface denies egress/operate for an agent actor "
762+
"(role x surface x space containment); a network/publish/deploy action is not "
763+
"admissible on the terminal surface. Escalate to a human operator.")
764+
return "allow", "[consent-plane] discover/implement/verify admissible on terminal surface"
765+
766+
703767
# ---------------------------------------------------------------------------
704768
# Policy Fabric wire (Track D)
705769
# ---------------------------------------------------------------------------
@@ -730,6 +794,16 @@ def policy_evaluate(
730794
"source": "local-policy",
731795
}
732796

797+
# Consent-plane terminal-surface envelope: an agent actor may not egress/operate
798+
# on the terminal surface. Deny short-circuits (defence in depth, holds offline).
799+
cp_decision, cp_reason = consent_plane_check(command, actor_id)
800+
if cp_decision == "deny":
801+
return {
802+
"decision": {"outcome": "deny", "reason": cp_reason, "matched_rule": "consent-plane:terminal"},
803+
"decision_id": None,
804+
"source": "consent-plane",
805+
}
806+
733807
if risk_level is None:
734808
# host is high-risk, isolated domains are lower
735809
risk_level = "high" if execution_domain == "host" else "medium"

assets/sourceos/tests/test_turtle_agentd.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,55 @@ def main() -> int:
8080

8181
if __name__ == "__main__":
8282
raise SystemExit(main())
83+
84+
85+
# --------------------------------------------------------------------- consent-plane
86+
def _agentd_module():
87+
"""Import turtle-agentd (extensionless) to unit-test the consent-plane envelope."""
88+
from importlib.machinery import SourceFileLoader
89+
import importlib.util
90+
loader = SourceFileLoader("turtle_agentd_mod", str(AGENTD))
91+
spec = importlib.util.spec_from_loader("turtle_agentd_mod", loader)
92+
mod = importlib.util.module_from_spec(spec)
93+
loader.exec_module(mod)
94+
return mod
95+
96+
97+
_ad = _agentd_module()
98+
99+
100+
def test_agent_actor_detection():
101+
assert _ad._is_agent_actor("urn:srcos:agent:turtle-copilot")
102+
assert _ad._is_agent_actor("agent:autonomous")
103+
assert _ad._is_agent_actor("mystery:actor") # unknown -> governed (fail-closed)
104+
assert not _ad._is_agent_actor("human:local-user") # only explicit human has authority
105+
assert not _ad._is_agent_actor("") # empty == daemon's default human path
106+
107+
108+
def test_agent_egress_denied_on_terminal_surface():
109+
for cmd in ("git push origin main", "kubectl apply -f x.yaml", "curl https://evil.example",
110+
"gh pr merge 12", "docker push repo/img", "scp f user@host:/tmp"):
111+
d, reason = _ad.consent_plane_check(cmd, "agent:autonomous")
112+
assert d == "deny", cmd
113+
assert "consent-plane" in reason
114+
115+
116+
def test_agent_read_edit_test_allowed_on_terminal():
117+
for cmd in ("ls -la", "cat README.md", "grep -r foo .", "git status", "git diff",
118+
"pytest -q", "cargo test"):
119+
d, _ = _ad.consent_plane_check(cmd, "agent:autonomous")
120+
assert d == "allow", cmd
121+
122+
123+
def test_human_keeps_full_authority():
124+
# the envelope governs agents only — a human may push/deploy
125+
d, _ = _ad.consent_plane_check("git push origin main", "human:local-user")
126+
assert d == "allow"
127+
128+
129+
def test_policy_evaluate_denies_agent_egress():
130+
# deny short-circuits before Policy Fabric, offline
131+
r = _ad.policy_evaluate("terminal.execute_command", "git push origin main",
132+
execution_domain="host", actor_id="agent:autonomous")
133+
assert r["decision"]["outcome"] == "deny"
134+
assert r["source"] == "consent-plane"

0 commit comments

Comments
 (0)