diff --git a/src/agent_term/config.py b/src/agent_term/config.py index c2f72fb..9dfb32d 100644 --- a/src/agent_term/config.py +++ b/src/agent_term/config.py @@ -47,6 +47,15 @@ class AgentRegistrationConfig: timeout_seconds: float = 5.0 +@dataclass(frozen=True) +class PolicyFabricConfig: + repository: str = "SocioProphet/policy-fabric" + fixture_path: str | None = None + endpoint_url: str | None = None + token_env: str = "AGENT_TERM_POLICY_FABRIC_TOKEN" + timeout_seconds: float = 5.0 + + @dataclass(frozen=True) class ParticipantConfig: key: str @@ -87,6 +96,7 @@ class AgentTermConfig: event_store: EventStoreConfig = field(default_factory=EventStoreConfig) matrix: MatrixConfig = field(default_factory=MatrixConfig) agent_registration: AgentRegistrationConfig = field(default_factory=AgentRegistrationConfig) + policy_fabric: PolicyFabricConfig = field(default_factory=PolicyFabricConfig) planes: dict[str, PlaneConfig] = field(default_factory=dict) participants: dict[str, ParticipantConfig] = field(default_factory=dict) local_runtime: LocalRuntimeFixture = field(default_factory=LocalRuntimeFixture) @@ -124,6 +134,7 @@ def config_from_dict(raw: dict[str, Any]) -> AgentTermConfig: event_store_raw = _dict(raw.get("eventStore")) matrix_raw = _dict(raw.get("matrix")) registration_raw = _dict(raw.get("agentRegistration")) + policy_fabric_raw = _dict(raw.get("policyFabric")) participants_raw = _dict(raw.get("participants")) planes_raw = _dict(raw.get("planes")) local_runtime_raw = _dict(raw.get("localRuntime")) @@ -167,6 +178,13 @@ def config_from_dict(raw: dict[str, Any]) -> AgentTermConfig: token_env=str(registration_raw.get("tokenEnv") or "AGENT_TERM_AGENT_REGISTRY_TOKEN"), timeout_seconds=float(registration_raw.get("timeoutSeconds") or 5.0), ), + policy_fabric=PolicyFabricConfig( + repository=str(policy_fabric_raw.get("repository") or "SocioProphet/policy-fabric"), + fixture_path=_optional_str(policy_fabric_raw.get("fixturePath")), + endpoint_url=_optional_str(policy_fabric_raw.get("endpointUrl")), + token_env=str(policy_fabric_raw.get("tokenEnv") or "AGENT_TERM_POLICY_FABRIC_TOKEN"), + timeout_seconds=float(policy_fabric_raw.get("timeoutSeconds") or 5.0), + ), planes=planes, participants=participants, local_runtime=LocalRuntimeFixture( diff --git a/src/agent_term/dispatch_cli.py b/src/agent_term/dispatch_cli.py index 2f9f7c2..fb84a8a 100644 --- a/src/agent_term/dispatch_cli.py +++ b/src/agent_term/dispatch_cli.py @@ -41,6 +41,7 @@ PolicyFabricAdapter, ) from agent_term.policy_fabric import action_for_event +from agent_term.policy_fabric_service import build_policy_fabric_backend_from_config from agent_term.store import DEFAULT_DB_PATH, EventStore from agent_term.workspace import ( InMemoryProphetWorkspaceBackend, @@ -150,7 +151,7 @@ def build_policy_backend( args: argparse.Namespace, event: AgentTermEvent, config: AgentTermConfig, -) -> InMemoryPolicyFabricBackend: +): decisions: list[PolicyDecision] = [] for action in (*config.local_runtime.allow_policies, *args.allow_policy): decisions.append(_decision(action, ALLOW, args.policy_ref)) @@ -164,7 +165,8 @@ def build_policy_backend( elif args.sensitive_context and not decisions: decisions.append(_decision(action_for_event(event), ALLOW, args.policy_ref)) - return InMemoryPolicyFabricBackend(decisions) + fallback = InMemoryPolicyFabricBackend(decisions) + return build_policy_fabric_backend_from_config(config, fallback=fallback) def _decision(action: str, status: str, policy_ref: str, reason: str | None = None) -> PolicyDecision: diff --git a/src/agent_term/policy_fabric_service.py b/src/agent_term/policy_fabric_service.py new file mode 100644 index 0000000..e42dd70 --- /dev/null +++ b/src/agent_term/policy_fabric_service.py @@ -0,0 +1,162 @@ +"""Service-backed Policy Fabric backends. + +AgentTerm is not the authority for policy. This module adds file and HTTP decision +lookup seams behind the existing PolicyFabricBackend protocol while keeping CI +offline-safe and fail-closed. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urljoin +from urllib.request import Request, urlopen + +from agent_term.config import AgentTermConfig +from agent_term.events import AgentTermEvent +from agent_term.policy_fabric import PolicyDecision, PolicyFabricBackend, action_for_event + + +class PolicyFabricServiceError(RuntimeError): + """Raised when a service-backed Policy Fabric lookup cannot be completed.""" + + +class JsonFilePolicyFabricBackend: + """Policy Fabric backend backed by a local JSON fixture file. + + Supported shape: + + ```json + { + "decisions": [ + { + "decision_id": "decision.allow.github.pr.create", + "action": "github.pr.create", + "status": "allow", + "policy_ref": "policy://github/pr-create" + } + ] + } + ``` + """ + + def __init__(self, path: Path | str) -> None: + self.path = Path(path) + self._decisions = self._load() + + def evaluate(self, event: AgentTermEvent) -> PolicyDecision | None: + return self._decisions.get(action_for_event(event)) + + def _load(self) -> dict[str, PolicyDecision]: + with self.path.open("r", encoding="utf-8") as handle: + raw = json.load(handle) + if not isinstance(raw, dict): + raise ValueError("Policy Fabric fixture must be a JSON object") + decisions = (_decision_from_record(record) for record in _records(raw.get("decisions"))) + return {decision.action: decision for decision in decisions} + + +class HttpPolicyFabricBackend: + """Minimal HTTP Policy Fabric backend. + + Expected endpoint: + + - `GET {endpoint}/decisions/{action}` returns a policy decision object or 404. + + A bearer value is optional and read from an environment variable, never JSON config. + """ + + def __init__( + self, + *, + endpoint_url: str, + token: str | None = None, + timeout_seconds: float = 5.0, + ) -> None: + self.endpoint_url = endpoint_url.rstrip("/") + "/" + self.token = token + self.timeout_seconds = timeout_seconds + + def evaluate(self, event: AgentTermEvent) -> PolicyDecision | None: + action = action_for_event(event) + record = self._get_json(f"decisions/{quote(action, safe='')}") + return _decision_from_record(record) if record is not None else None + + def _get_json(self, path: str) -> dict[str, Any] | None: + url = urljoin(self.endpoint_url, path) + headers = {"Accept": "application/json"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + request = Request(url, headers=headers, method="GET") + try: + with urlopen(request, timeout=self.timeout_seconds) as response: # noqa: S310 + raw = response.read().decode("utf-8") + except HTTPError as exc: + if exc.code == 404: + return None + raise PolicyFabricServiceError(f"Policy Fabric HTTP error {exc.code}: {url}") from exc + except URLError as exc: + raise PolicyFabricServiceError(f"Policy Fabric connection error: {url}") from exc + value = json.loads(raw) + if not isinstance(value, dict): + raise PolicyFabricServiceError("Policy Fabric response must be a JSON object") + return value + + +def build_policy_fabric_backend_from_config( + config: AgentTermConfig, + *, + fallback: PolicyFabricBackend, +) -> PolicyFabricBackend: + """Build a Policy Fabric backend from config, falling back to local fixtures.""" + + if config.policy_fabric.fixture_path: + return JsonFilePolicyFabricBackend(config.policy_fabric.fixture_path) + + if config.policy_fabric.endpoint_url: + return HttpPolicyFabricBackend( + endpoint_url=config.policy_fabric.endpoint_url, + token=os.environ.get(config.policy_fabric.token_env), + timeout_seconds=config.policy_fabric.timeout_seconds, + ) + + return fallback + + +def _records(value: object) -> list[dict[str, Any]]: + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + if isinstance(value, dict): + return [item for item in value.values() if isinstance(item, dict)] + return [] + + +def _decision_from_record(record: dict[str, Any]) -> PolicyDecision: + known = { + "decision_id", + "decisionId", + "action", + "status", + "policy_ref", + "policyRef", + "reason", + "obligations", + } + obligations_raw = record.get("obligations") or [] + obligations = tuple(str(item) for item in obligations_raw if item is not None) + return PolicyDecision( + decision_id=str(record.get("decision_id") or record.get("decisionId")), + action=str(record.get("action")), + status=str(record.get("status")), + policy_ref=str(record.get("policy_ref") or record.get("policyRef")), + reason=_optional_str(record.get("reason")), + obligations=obligations, + metadata={key: value for key, value in record.items() if key not in known}, + ) + + +def _optional_str(value: object) -> str | None: + return str(value) if value is not None else None diff --git a/tests/test_config.py b/tests/test_config.py index b16f69f..21d2901 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -29,6 +29,13 @@ def test_loads_example_config_shape(tmp_path): "tokenEnv": "AGENT_TERM_AGENT_REGISTRY_TOKEN", "timeoutSeconds": 2.5, }, + "policyFabric": { + "repository": "SocioProphet/policy-fabric", + "fixturePath": "fixtures/policy-fabric.json", + "endpointUrl": "https://policy-fabric.example.org", + "tokenEnv": "AGENT_TERM_POLICY_FABRIC_TOKEN", + "timeoutSeconds": 3.5, + }, "participants": { "codex": { "enabled": False, @@ -63,6 +70,11 @@ def test_loads_example_config_shape(tmp_path): assert config.agent_registration.endpoint_url == "https://agent-registry.example.org" assert config.agent_registration.token_env == "AGENT_TERM_AGENT_REGISTRY_TOKEN" assert config.agent_registration.timeout_seconds == 2.5 + assert config.policy_fabric.repository == "SocioProphet/policy-fabric" + assert config.policy_fabric.fixture_path == "fixtures/policy-fabric.json" + assert config.policy_fabric.endpoint_url == "https://policy-fabric.example.org" + assert config.policy_fabric.token_env == "AGENT_TERM_POLICY_FABRIC_TOKEN" + assert config.policy_fabric.timeout_seconds == 3.5 assert config.participant_agent_id("codex") == "agent.codex" assert config.participants["codex"].require_policy_approval_for_mutation is True assert config.planes["policyFabric"].repository == "SocioProphet/policy-fabric" @@ -75,6 +87,9 @@ def test_defaults_are_safe_without_config_file(): assert config.agent_registration.require_registered_participants is True assert config.agent_registration.token_env == "AGENT_TERM_AGENT_REGISTRY_TOKEN" assert config.agent_registration.timeout_seconds == 5.0 + assert config.policy_fabric.repository == "SocioProphet/policy-fabric" + assert config.policy_fabric.token_env == "AGENT_TERM_POLICY_FABRIC_TOKEN" + assert config.policy_fabric.timeout_seconds == 5.0 assert config.matrix.require_encrypted_room_posture_for_sensitive_context is True assert config.pipeline_config().require_agent_registry_for_participants is True assert config.pipeline_config().require_matrix_posture_for_sensitive_context is True diff --git a/tests/test_dispatch_policy_fabric_service.py b/tests/test_dispatch_policy_fabric_service.py new file mode 100644 index 0000000..388bf27 --- /dev/null +++ b/tests/test_dispatch_policy_fabric_service.py @@ -0,0 +1,119 @@ +import json + +from agent_term.dispatch_cli import main +from agent_term.store import EventStore + + +def test_dispatch_cli_uses_file_backed_policy_fabric(tmp_path, capsys): + db_path = tmp_path / "configured-events.sqlite3" + fixture_path = tmp_path / "policy-fabric.json" + fixture_path.write_text( + json.dumps( + { + "decisions": [ + { + "decision_id": "decision.allow.memory", + "action": "memory-mesh.memory_recall", + "status": "allow", + "policy_ref": "fixture://policy/memory", + "obligations": ["record-audit"], + } + ] + } + ), + encoding="utf-8", + ) + config_path = tmp_path / "agent-term.json" + config_path.write_text( + json.dumps( + { + "eventStore": {"driver": "sqlite", "path": str(db_path)}, + "policyFabric": {"fixturePath": str(fixture_path)}, + } + ), + encoding="utf-8", + ) + + exit_code = main( + [ + "memory-mesh", + "memory_recall", + "!memory-mesh", + "Recall workroom context", + "--config", + str(config_path), + "--metadata-json", + '{"query":"workroom context","policy_action":"memory-mesh.memory_recall"}', + ] + ) + + captured = capsys.readouterr() + assert exit_code == 0 + assert "dispatch_status=ok" in captured.out + + store = EventStore(db_path) + try: + events = store.tail(limit=10) + finally: + store.close() + assert events[1].source == "policy-fabric" + assert events[1].metadata["policy_decision_id"] == "decision.allow.memory" + assert events[1].metadata["policy_obligations"] == ["record-audit"] + assert events[-1].metadata["policy_decision_ref"] == "decision.allow.memory" + + +def test_dispatch_cli_blocks_file_backed_policy_denial(tmp_path, capsys): + db_path = tmp_path / "configured-events.sqlite3" + fixture_path = tmp_path / "policy-fabric.json" + fixture_path.write_text( + json.dumps( + { + "decisions": [ + { + "decision_id": "decision.deny.memory", + "action": "memory-mesh.memory_recall", + "status": "deny", + "policy_ref": "fixture://policy/memory", + "reason": "memory recall denied", + } + ] + } + ), + encoding="utf-8", + ) + config_path = tmp_path / "agent-term.json" + config_path.write_text( + json.dumps( + { + "eventStore": {"driver": "sqlite", "path": str(db_path)}, + "policyFabric": {"fixturePath": str(fixture_path)}, + } + ), + encoding="utf-8", + ) + + exit_code = main( + [ + "memory-mesh", + "memory_recall", + "!memory-mesh", + "Recall workroom context", + "--config", + str(config_path), + "--metadata-json", + '{"query":"workroom context","policy_action":"memory-mesh.memory_recall"}', + ] + ) + + captured = capsys.readouterr() + assert exit_code == 1 + assert "dispatch_status=blocked" in captured.out + assert "blocked_reason=memory recall denied" in captured.out + + store = EventStore(db_path) + try: + events = store.tail(limit=10) + finally: + store.close() + assert events[-1].metadata["policy_decision_id"] == "decision.deny.memory" + assert events[-1].metadata["deny_reason"] == "memory recall denied" diff --git a/tests/test_policy_fabric_service.py b/tests/test_policy_fabric_service.py new file mode 100644 index 0000000..e36bb87 --- /dev/null +++ b/tests/test_policy_fabric_service.py @@ -0,0 +1,156 @@ +import json +from http.server import BaseHTTPRequestHandler, HTTPServer +from threading import Thread + +from agent_term.config import config_from_dict +from agent_term.events import AgentTermEvent +from agent_term.policy_fabric import ALLOW, DENY, InMemoryPolicyFabricBackend +from agent_term.policy_fabric_service import ( + HttpPolicyFabricBackend, + JsonFilePolicyFabricBackend, + build_policy_fabric_backend_from_config, +) + + +def make_event(action: str) -> AgentTermEvent: + return AgentTermEvent( + channel="!policyfabric", + sender="@operator", + kind="github_mutation", + source="github", + body="policy test", + metadata={"policy_action": action}, + ) + + +def test_json_file_policy_fabric_backend_resolves_decisions(tmp_path): + fixture = tmp_path / "policy-fabric.json" + fixture.write_text( + json.dumps( + { + "decisions": [ + { + "decision_id": "decision.allow.github.pr.create", + "action": "github.pr.create", + "status": "allow", + "policy_ref": "fixture://policy/github-pr-create", + "obligations": ["record-audit"], + }, + { + "decision_id": "decision.deny.github.repo.delete", + "action": "github.repo.delete", + "status": "deny", + "policy_ref": "fixture://policy/github-delete", + "reason": "repo delete not allowed", + }, + ] + } + ), + encoding="utf-8", + ) + + backend = JsonFilePolicyFabricBackend(fixture) + allowed = backend.evaluate(make_event("github.pr.create")) + denied = backend.evaluate(make_event("github.repo.delete")) + missing = backend.evaluate(make_event("github.unknown")) + + assert allowed is not None + assert allowed.status == ALLOW + assert allowed.obligations == ("record-audit",) + assert denied is not None + assert denied.status == DENY + assert denied.reason == "repo delete not allowed" + assert missing is None + + +def test_build_policy_fabric_backend_uses_fixture_path(tmp_path): + fixture = tmp_path / "policy-fabric.json" + fixture.write_text( + json.dumps( + { + "decisions": [ + { + "decision_id": "decision.allow.github.pr.create", + "action": "github.pr.create", + "status": "allow", + "policy_ref": "fixture://policy/github-pr-create", + } + ] + } + ), + encoding="utf-8", + ) + config = config_from_dict({"policyFabric": {"fixturePath": str(fixture)}}) + + backend = build_policy_fabric_backend_from_config( + config, + fallback=InMemoryPolicyFabricBackend(), + ) + + assert isinstance(backend, JsonFilePolicyFabricBackend) + assert backend.evaluate(make_event("github.pr.create")) is not None + + +def test_build_policy_fabric_backend_uses_fallback_without_service_config(): + fallback = InMemoryPolicyFabricBackend() + + backend = build_policy_fabric_backend_from_config(config_from_dict({}), fallback=fallback) + + assert backend is fallback + + +def test_http_policy_fabric_backend_resolves_decision(): + server = _PolicyFabricHttpFixtureServer() + try: + backend = HttpPolicyFabricBackend(endpoint_url=server.url) + + allowed = backend.evaluate(make_event("github.pr.create")) + missing = backend.evaluate(make_event("github.unknown")) + finally: + server.close() + + assert allowed is not None + assert allowed.decision_id == "decision.allow.github.pr.create" + assert allowed.status == ALLOW + assert missing is None + + +class _Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + if self.path == "/decisions/github.pr.create": + self._send( + 200, + { + "decision_id": "decision.allow.github.pr.create", + "action": "github.pr.create", + "status": "allow", + "policy_ref": "http://policy/github-pr-create", + }, + ) + return + self._send(404, {"error": "not found"}) + + def log_message(self, format, *args): # noqa: A002 + return + + def _send(self, status, payload): + raw = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + +class _PolicyFabricHttpFixtureServer: + def __init__(self): + self._server = HTTPServer(("127.0.0.1", 0), _Handler) + self._thread = Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + host, port = self._server.server_address + self.url = f"http://{host}:{port}/" + + def close(self): + self._server.shutdown() + self._thread.join(timeout=5) + self._server.server_close()