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
18 changes: 18 additions & 0 deletions src/agent_term/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions src/agent_term/dispatch_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand All @@ -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:
Expand Down
162 changes: 162 additions & 0 deletions src/agent_term/policy_fabric_service.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
119 changes: 119 additions & 0 deletions tests/test_dispatch_policy_fabric_service.py
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading