agentcontainment 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. agent_containment/__init__.py +31 -0
  2. agent_containment/agent_tree.py +102 -0
  3. agent_containment/attack_harness.py +44 -0
  4. agent_containment/audit.py +94 -0
  5. agent_containment/blast_radius.py +17 -0
  6. agent_containment/bootstrap.py +33 -0
  7. agent_containment/cgroup_enforcer.py +154 -0
  8. agent_containment/cilium_enforcer.py +338 -0
  9. agent_containment/client.py +90 -0
  10. agent_containment/containment.py +209 -0
  11. agent_containment/control.py +660 -0
  12. agent_containment/credentials.py +41 -0
  13. agent_containment/daemon.py +56 -0
  14. agent_containment/dprovenancekit_adapter.py +113 -0
  15. agent_containment/egress.py +91 -0
  16. agent_containment/egress_enforcement.py +170 -0
  17. agent_containment/enforcer.py +90 -0
  18. agent_containment/evidence_chain.py +180 -0
  19. agent_containment/gateway.py +72 -0
  20. agent_containment/governance_event.py +51 -0
  21. agent_containment/incident.py +47 -0
  22. agent_containment/incident_evidence.py +110 -0
  23. agent_containment/incident_state.py +247 -0
  24. agent_containment/linux_supervisor.py +87 -0
  25. agent_containment/models.py +27 -0
  26. agent_containment/policy.py +71 -0
  27. agent_containment/process.py +28 -0
  28. agent_containment/provenance.py +94 -0
  29. agent_containment/regression.py +175 -0
  30. agent_containment/regression_replay.py +74 -0
  31. agent_containment/review.py +38 -0
  32. agent_containment/runtime.py +149 -0
  33. agent_containment/runtime_fence.py +102 -0
  34. agent_containment/transport.py +281 -0
  35. agent_containment/verification_signal.py +103 -0
  36. agent_containment/warden_observation.py +79 -0
  37. agent_containment/warden_observer.py +43 -0
  38. agentcontainment-0.1.0.dist-info/METADATA +364 -0
  39. agentcontainment-0.1.0.dist-info/RECORD +42 -0
  40. agentcontainment-0.1.0.dist-info/WHEEL +5 -0
  41. agentcontainment-0.1.0.dist-info/licenses/LICENSE +87 -0
  42. agentcontainment-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,31 @@
1
+ from .bootstrap import BootstrapAdmissionError, require_controller_available
2
+ from .control import ContainmentService, ManagedAgent, RecoveryAuthorization
3
+ from .cgroup_enforcer import CgroupV2Enforcer
4
+ from .cilium_enforcer import CiliumNetworkPolicyEnforcer
5
+ from .egress import EgressController, EgressLease, hard_close_socket
6
+ from .governance_event import GovernanceEvent
7
+ from .enforcer import Enforcer, EnforcementResult, EnforcementStatus, NoopEnforcer
8
+ from .gateway import ActionGateway
9
+ from .incident_state import IncidentRecord, IncidentRegistry, IncidentState
10
+ from .linux_supervisor import LinuxCgroupSupervisor
11
+ from .models import Action, Decision, DecisionType
12
+ from .policy import PolicyEngine
13
+ from .provenance import InMemoryProvenanceSink, ProvenanceEmitter, ProvenanceRecord, ProvenanceSink, provenance_record
14
+ from .regression import RegressionFixture, RegressionFixtureBuilder, assert_regression
15
+ from .regression_replay import RegressionReplayResult, assert_fixture_replays, replay_fixture
16
+ from .runtime_fence import FenceRecord, RuntimeFenceRegistry
17
+ from .warden_observation import WardenObservation
18
+
19
+ __all__ = [
20
+ "Action", "ActionGateway", "Decision", "DecisionType",
21
+ "BootstrapAdmissionError", "require_controller_available",
22
+ "ContainmentService", "ManagedAgent", "RecoveryAuthorization",
23
+ "IncidentRecord", "IncidentRegistry", "IncidentState", "GovernanceEvent",
24
+ "ProvenanceRecord", "ProvenanceSink", "ProvenanceEmitter", "InMemoryProvenanceSink", "provenance_record",
25
+ "RegressionFixture", "RegressionFixtureBuilder", "assert_regression",
26
+ "RegressionReplayResult", "replay_fixture", "assert_fixture_replays",
27
+ "EgressController", "EgressLease", "PolicyEngine", "hard_close_socket",
28
+ "LinuxCgroupSupervisor", "FenceRecord", "RuntimeFenceRegistry",
29
+ "Enforcer", "EnforcementResult", "EnforcementStatus", "NoopEnforcer",
30
+ "CgroupV2Enforcer", "CiliumNetworkPolicyEnforcer", "WardenObservation",
31
+ ]
@@ -0,0 +1,102 @@
1
+ from dataclasses import dataclass, field
2
+ from threading import RLock
3
+
4
+ from .containment import CapabilitySet, ContainmentController
5
+ from .runtime import Runtime
6
+
7
+
8
+ @dataclass
9
+ class AgentNode:
10
+ agent_id: str
11
+ parent_id: str | None
12
+ runtime: Runtime
13
+ containment: ContainmentController
14
+ children: set[str] = field(default_factory=set)
15
+
16
+
17
+ class AgentTree:
18
+ """Tracks parent/child relationships with controller-owned spawn/containment serialization."""
19
+
20
+ def __init__(self):
21
+ self.nodes: dict[str, AgentNode] = {}
22
+ self._lock = RLock()
23
+
24
+ def register_root(
25
+ self,
26
+ agent_id: str,
27
+ capabilities: set[str] | None = None,
28
+ ) -> AgentNode:
29
+ with self._lock:
30
+ if agent_id in self.nodes:
31
+ raise ValueError(f"agent already registered: {agent_id}")
32
+ runtime = Runtime(agent_id)
33
+ controller = ContainmentController(
34
+ runtime,
35
+ CapabilitySet(set(capabilities or set())),
36
+ )
37
+ node = AgentNode(agent_id, None, runtime, controller)
38
+ self.nodes[agent_id] = node
39
+ return node
40
+
41
+ def spawn(
42
+ self,
43
+ parent_id: str,
44
+ child_id: str,
45
+ capabilities: set[str] | None = None,
46
+ ) -> AgentNode:
47
+ with self._lock:
48
+ if child_id in self.nodes:
49
+ raise ValueError(f"agent already registered: {child_id}")
50
+ parent = self._require(parent_id)
51
+
52
+ if not parent.runtime.can_execute or parent.containment.contained:
53
+ raise RuntimeError(f"parent agent cannot spawn children: {parent_id}")
54
+
55
+ inherited = set(parent.containment.capabilities.capabilities)
56
+ if capabilities is not None:
57
+ inherited &= set(capabilities)
58
+
59
+ runtime = Runtime(child_id)
60
+ controller = ContainmentController(
61
+ runtime,
62
+ CapabilitySet(inherited),
63
+ )
64
+ node = AgentNode(child_id, parent_id, runtime, controller)
65
+ self.nodes[child_id] = node
66
+ parent.children.add(child_id)
67
+ return node
68
+
69
+ def contain(self, agent_id: str) -> list[str]:
70
+ """Contain an agent and every descendant known at the containment fence."""
71
+ with self._lock:
72
+ root = self._require(agent_id)
73
+ contained: list[str] = []
74
+ stack = [root.agent_id]
75
+
76
+ while stack:
77
+ current_id = stack.pop()
78
+ current = self._require(current_id)
79
+ current.containment.contain()
80
+ contained.append(current_id)
81
+ stack.extend(sorted(current.children, reverse=True))
82
+
83
+ return contained
84
+
85
+ def descendants(self, agent_id: str) -> list[str]:
86
+ with self._lock:
87
+ self._require(agent_id)
88
+ result: list[str] = []
89
+ stack = list(self.nodes[agent_id].children)
90
+
91
+ while stack:
92
+ child_id = stack.pop()
93
+ result.append(child_id)
94
+ stack.extend(self.nodes[child_id].children)
95
+
96
+ return result
97
+
98
+ def _require(self, agent_id: str) -> AgentNode:
99
+ try:
100
+ return self.nodes[agent_id]
101
+ except KeyError as exc:
102
+ raise KeyError(f"unknown agent: {agent_id}") from exc
@@ -0,0 +1,44 @@
1
+ """Controlled adversarial harness for testing containment invariants.
2
+
3
+ This module never executes real external commands, network calls, or destructive
4
+ operations. It models an agent attempting to bypass the control plane.
5
+ """
6
+ from dataclasses import dataclass, field
7
+ from .models import Action, DecisionType
8
+ from .gateway import ActionGateway
9
+ from .containment import ContainmentController
10
+
11
+ @dataclass
12
+ class AttackAttempt:
13
+ name: str
14
+ action: Action
15
+
16
+ @dataclass
17
+ class AttackResult:
18
+ name: str
19
+ decision: DecisionType
20
+ blocked: bool
21
+ reason: str
22
+
23
+ @dataclass
24
+ class AttackHarness:
25
+ gateway: ActionGateway
26
+ containment: ContainmentController
27
+ results: list[AttackResult] = field(default_factory=list)
28
+
29
+ def attempt(self, attack: AttackAttempt) -> AttackResult:
30
+ decision = self.gateway.authorize(attack.action)
31
+ result = AttackResult(
32
+ name=attack.name,
33
+ decision=decision.decision,
34
+ blocked=decision.decision is not DecisionType.ALLOW,
35
+ reason=decision.reason,
36
+ )
37
+ self.results.append(result)
38
+ return result
39
+
40
+ def assert_contained(self) -> None:
41
+ failures = [r for r in self.results if not r.blocked]
42
+ if failures:
43
+ names = ", ".join(r.name for r in failures)
44
+ raise AssertionError(f"containment bypasses detected: {names}")
@@ -0,0 +1,94 @@
1
+ """Controller-owned tamper-evident JSONL audit chain."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+ from threading import RLock
8
+ from time import time
9
+ from typing import Any
10
+
11
+ GENESIS = "0" * 64
12
+
13
+
14
+ def _canonical(value: dict[str, Any]) -> bytes:
15
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
16
+
17
+
18
+ class AuditLog:
19
+ """Append-only hash chain.
20
+
21
+ The chain is tamper-evident, not externally immutable: an attacker who can
22
+ delete the file can remove history. Verification therefore fails closed
23
+ before appending to an already-corrupt chain.
24
+ """
25
+
26
+ def __init__(self, path: str | Path):
27
+ self.path = Path(path)
28
+ self._lock = RLock()
29
+
30
+ def record(self, event_type: str, *, agent_id: str, action_id: str | None = None,
31
+ decision: str | None = None, reason: str | None = None,
32
+ timestamp: float | None = None, **fields: Any) -> dict[str, Any]:
33
+ if not isinstance(event_type, str) or not event_type:
34
+ raise ValueError("event_type must be non-empty")
35
+ if not isinstance(agent_id, str) or not agent_id:
36
+ raise ValueError("agent_id must be non-empty")
37
+ with self._lock:
38
+ if self.path.exists():
39
+ ok, verification_reason = self.verify()
40
+ if not ok:
41
+ raise RuntimeError(
42
+ f"refusing to append to invalid audit chain: {verification_reason}"
43
+ )
44
+ lines = [line for line in self.path.read_text(encoding="utf-8").splitlines() if line.strip()]
45
+ else:
46
+ lines = []
47
+
48
+ previous_hash = GENESIS
49
+ if lines:
50
+ previous_hash = json.loads(lines[-1])["hash"]
51
+
52
+ event: dict[str, Any] = {
53
+ "version": 1,
54
+ "sequence": 1 if not lines else len(lines) + 1,
55
+ "timestamp": time() if timestamp is None else timestamp,
56
+ "event_type": event_type,
57
+ "agent_id": agent_id,
58
+ "action_id": action_id,
59
+ "decision": decision,
60
+ "reason": reason,
61
+ "previous_hash": previous_hash,
62
+ **fields,
63
+ }
64
+ event["hash"] = hashlib.sha256(_canonical(event)).hexdigest()
65
+ self.path.parent.mkdir(parents=True, exist_ok=True)
66
+ with self.path.open("a", encoding="utf-8") as handle:
67
+ handle.write(json.dumps(event, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n")
68
+ return event
69
+
70
+ def verify(self) -> tuple[bool, str]:
71
+ with self._lock:
72
+ if not self.path.exists():
73
+ return True, "empty audit log"
74
+ expected_previous = GENESIS
75
+ expected_sequence = 1
76
+ try:
77
+ lines = self.path.read_text(encoding="utf-8").splitlines()
78
+ for line in lines:
79
+ if not line.strip():
80
+ continue
81
+ event = json.loads(line)
82
+ stored = event.pop("hash")
83
+ if event.get("sequence") != expected_sequence:
84
+ return False, f"sequence mismatch at {expected_sequence}"
85
+ if event.get("previous_hash") != expected_previous:
86
+ return False, f"previous hash mismatch at sequence {expected_sequence}"
87
+ actual = hashlib.sha256(_canonical(event)).hexdigest()
88
+ if stored != actual:
89
+ return False, f"hash mismatch at sequence {expected_sequence}"
90
+ expected_previous = stored
91
+ expected_sequence += 1
92
+ except (OSError, json.JSONDecodeError, KeyError, TypeError):
93
+ return False, "malformed audit log"
94
+ return True, "audit chain valid"
@@ -0,0 +1,17 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ @dataclass
4
+ class ResourceGraph:
5
+ touched: set[str] = field(default_factory=set)
6
+ modified: set[str] = field(default_factory=set)
7
+ external: set[str] = field(default_factory=set)
8
+
9
+ def record(self, resource: str, *, modified: bool = False, external: bool = False) -> None:
10
+ self.touched.add(resource)
11
+ if modified:
12
+ self.modified.add(resource)
13
+ if external:
14
+ self.external.add(resource)
15
+
16
+ def summary(self) -> dict[str, int]:
17
+ return {"touched": len(self.touched), "modified": len(self.modified), "external": len(self.external)}
@@ -0,0 +1,33 @@
1
+ """Controller-owned bootstrap admission checks.
2
+
3
+ This module is a pre-admission guard for trusted launchers. It is intentionally
4
+ not an authorization API: the agent must not be able to choose or bypass the
5
+ launcher that invokes it. Production admission should be enforced by the host
6
+ service manager or container runtime before the agent workload is started.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import socket
11
+ from pathlib import Path
12
+
13
+
14
+ class BootstrapAdmissionError(RuntimeError):
15
+ """Raised when controller availability cannot be established."""
16
+
17
+
18
+ def require_controller_available(
19
+ socket_path: str | Path, *, timeout: float = 1.0
20
+ ) -> None:
21
+ """Require a live controller IPC endpoint before admitting a workload."""
22
+ path = Path(socket_path)
23
+ if not path.exists():
24
+ raise BootstrapAdmissionError("controller socket is unavailable")
25
+
26
+ try:
27
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
28
+ client.settimeout(timeout)
29
+ client.connect(str(path))
30
+ except OSError as exc:
31
+ raise BootstrapAdmissionError(
32
+ "controller endpoint is unavailable"
33
+ ) from exc
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Mapping
7
+
8
+ from .enforcer import EnforcementResult, EnforcementStatus
9
+ from .linux_supervisor import LinuxCgroupSupervisor
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class _CgroupIdentity:
14
+ device: int
15
+ inode: int
16
+ ctime_ns: int
17
+
18
+
19
+ class CgroupV2Enforcer:
20
+ """Adapt a dedicated, identity-bound cgroup v2 workload boundary to Enforcer."""
21
+
22
+ name = "cgroup-v2"
23
+
24
+ def __init__(self, cgroup_paths: Mapping[str, str | os.PathLike[str]]):
25
+ self._cgroup_paths = {
26
+ agent_id: Path(path) for agent_id, path in cgroup_paths.items()
27
+ }
28
+ self._identities = {
29
+ agent_id: self._identity(path)
30
+ for agent_id, path in self._cgroup_paths.items()
31
+ }
32
+
33
+ @staticmethod
34
+ def _identity(path: Path) -> _CgroupIdentity:
35
+ stat = path.stat()
36
+ if not path.is_dir():
37
+ raise ValueError(f"cgroup path is not a directory: {path}")
38
+ return _CgroupIdentity(stat.st_dev, stat.st_ino, stat.st_ctime_ns)
39
+
40
+ def _path(self, agent_id: str) -> Path | None:
41
+ return self._cgroup_paths.get(agent_id)
42
+
43
+ def _missing(self, agent_id: str) -> EnforcementResult:
44
+ return EnforcementResult(
45
+ self.name,
46
+ EnforcementStatus.NOT_CONFIGURED,
47
+ f"no cgroup configured for agent {agent_id}",
48
+ )
49
+
50
+ def _verify_identity(self, agent_id: str, path: Path) -> EnforcementResult | None:
51
+ expected = self._identities[agent_id]
52
+ try:
53
+ actual = self._identity(path)
54
+ except (OSError, ValueError) as exc:
55
+ return EnforcementResult(
56
+ self.name,
57
+ EnforcementStatus.VERIFICATION_FAILED,
58
+ f"cgroup identity unavailable: {type(exc).__name__}: {exc}",
59
+ )
60
+ if actual != expected:
61
+ return EnforcementResult(
62
+ self.name,
63
+ EnforcementStatus.VERIFICATION_FAILED,
64
+ f"cgroup identity changed for {agent_id}",
65
+ )
66
+ # A directory with the right filesystem identity is not sufficient:
67
+ # require the kernel cgroup v2 control files before trusting its state.
68
+ for control in ("cgroup.events", "cgroup.kill"):
69
+ if not (path / control).is_file():
70
+ return EnforcementResult(
71
+ self.name,
72
+ EnforcementStatus.VERIFICATION_FAILED,
73
+ f"required cgroup control file is missing: {control}",
74
+ )
75
+ return None
76
+
77
+ def contain(self, agent_id: str) -> EnforcementResult:
78
+ path = self._path(agent_id)
79
+ if path is None:
80
+ return self._missing(agent_id)
81
+ try:
82
+ identity_failure = self._verify_identity(agent_id, path)
83
+ if identity_failure:
84
+ return identity_failure
85
+ LinuxCgroupSupervisor.contain(path)
86
+ return EnforcementResult(self.name, EnforcementStatus.ENFORCED)
87
+ except Exception as exc:
88
+ return EnforcementResult(
89
+ self.name, EnforcementStatus.DEGRADED, f"{type(exc).__name__}: {exc}"
90
+ )
91
+
92
+ def verify_contained(self, agent_id: str) -> EnforcementResult:
93
+ path = self._path(agent_id)
94
+ if path is None:
95
+ return self._missing(agent_id)
96
+ try:
97
+ identity_failure = self._verify_identity(agent_id, path)
98
+ if identity_failure:
99
+ return identity_failure
100
+ if LinuxCgroupSupervisor.is_populated(path):
101
+ return EnforcementResult(
102
+ self.name,
103
+ EnforcementStatus.VERIFICATION_FAILED,
104
+ f"cgroup remains populated for {agent_id}",
105
+ )
106
+ return EnforcementResult(self.name, EnforcementStatus.ENFORCED)
107
+ except Exception as exc:
108
+ return EnforcementResult(
109
+ self.name,
110
+ EnforcementStatus.VERIFICATION_FAILED,
111
+ f"{type(exc).__name__}: {exc}",
112
+ )
113
+
114
+ def release(self, agent_id: str) -> EnforcementResult:
115
+ path = self._path(agent_id)
116
+ if path is None:
117
+ return self._missing(agent_id)
118
+ try:
119
+ identity_failure = self._verify_identity(agent_id, path)
120
+ if identity_failure:
121
+ return identity_failure
122
+ if LinuxCgroupSupervisor.is_populated(path):
123
+ return EnforcementResult(
124
+ self.name,
125
+ EnforcementStatus.DEGRADED,
126
+ f"cgroup remains populated for {agent_id}",
127
+ )
128
+ return EnforcementResult(self.name, EnforcementStatus.RELEASED)
129
+ except Exception as exc:
130
+ return EnforcementResult(
131
+ self.name, EnforcementStatus.DEGRADED, f"{type(exc).__name__}: {exc}"
132
+ )
133
+
134
+ def verify_released(self, agent_id: str) -> EnforcementResult:
135
+ path = self._path(agent_id)
136
+ if path is None:
137
+ return self._missing(agent_id)
138
+ try:
139
+ identity_failure = self._verify_identity(agent_id, path)
140
+ if identity_failure:
141
+ return identity_failure
142
+ if LinuxCgroupSupervisor.is_populated(path):
143
+ return EnforcementResult(
144
+ self.name,
145
+ EnforcementStatus.VERIFICATION_FAILED,
146
+ f"cgroup is unexpectedly populated for {agent_id}",
147
+ )
148
+ return EnforcementResult(self.name, EnforcementStatus.RELEASED)
149
+ except Exception as exc:
150
+ return EnforcementResult(
151
+ self.name,
152
+ EnforcementStatus.VERIFICATION_FAILED,
153
+ f"{type(exc).__name__}: {exc}",
154
+ )