diff --git a/docs/pre-dispatch-boundary.md b/docs/pre-dispatch-boundary.md new file mode 100644 index 0000000..6362c1a --- /dev/null +++ b/docs/pre-dispatch-boundary.md @@ -0,0 +1,63 @@ +# AgentTerm Pre-Dispatch Boundary + +## Purpose + +`AgentTermPreDispatchDecision` is the decision-only boundary between an operator or interaction event and any later runtime dispatch. + +AgentTerm is the terminal-native / Matrix-first operator console. It is not the authority plane for non-human identity, grants, policy admission, sensitive context release, or side-effecting execution. + +This object exists so later participant adapters for Hermes, Codex, Claude Code, OpenCLAW, GitHub, CI, MCP, terminal, Matrix, and AgentPlane cannot dispatch from local config alone. + +## Required chain + +```text +operator / interaction event = evidence input +Agent Registry lookup = identity / session / grant / revocation evidence +Policy Fabric decision = action/context policy evaluation +AgentTerm pre-dispatch decision = local runtime readiness decision +AgentPlane / terminal / Matrix adapter = downstream execution surface +OpsHistory / SourceOSInteractionEvent = record/render path only +``` + +## Boundary rules + +A pre-dispatch decision is not execution. + +It must record: + +- participant ref and kind; +- Agent Registry ref for non-human participants; +- grant refs and session ref for non-human participants; +- revocation state; +- Policy Fabric decision refs for side-effecting actions; +- policy status; +- dispatch decision; +- target adapter; +- side-effect posture; +- sensitive-context posture; +- evidence refs; +- `performed_dispatch = false`. + +## Fail-closed cases + +The validator rejects: + +- non-human participant dispatch from local config alone; +- non-human participant without grants or session ref; +- revoked or unknown revocation state allowing dispatch; +- side-effecting action without Policy Fabric decision refs; +- sensitive context without policy admission; +- pre-dispatch record claiming dispatch already occurred. + +## Related issues + +- #8 registered non-human participants and pre-dispatch Agent Registry / Policy Fabric checks. +- #18 SourceOS Agent Machine workspace integration and governed execution handoff. +- #43 SourceOSInteractionEvent governance trace rendering. +- #44 pre-dispatch boundary issue. + +## Non-goals + +This tranche does not add live provider calls, terminal execution, Matrix sends, MCP execution, AgentPlane calls, or Agent Registry network access. + +It only defines and validates the decision object that must exist before those surfaces are wired. diff --git a/src/agent_term/pre_dispatch.py b/src/agent_term/pre_dispatch.py new file mode 100644 index 0000000..e89fb56 --- /dev/null +++ b/src/agent_term/pre_dispatch.py @@ -0,0 +1,185 @@ +"""Pre-dispatch decision boundary for AgentTerm runtime actions. + +AgentTerm renders, records, and coordinates operator events. It is not the +authority for non-human participant identity, grants, policy admission, or +side-effecting execution. This module creates a typed decision object that must +exist before dispatch is performed by an adapter. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any, Literal + +DispatchDecision = Literal["allow", "require-review", "deny", "fail-closed"] +RevocationState = Literal["not-revoked", "revoked", "unknown"] + +SIDE_EFFECTING_ACTIONS = frozenset( + { + "shell_session", + "shell_attach", + "terminal_attach", + "tool_use", + "mcp_tool_call", + "github_mutation", + "ci_retry", + "workspace_materialization", + "memory_write", + "matrix_service_send", + } +) + + +@dataclass(frozen=True) +class AgentTermPreDispatchDecision: + """Decision-only record produced before any runtime dispatch occurs.""" + + decision_id: str + requested_action: str + participant_ref: str + participant_kind: str + agent_registry_ref: str | None + grant_refs: tuple[str, ...] + session_ref: str | None + revocation_state: RevocationState + policy_decision_refs: tuple[str, ...] + policy_status: str + dispatch_decision: DispatchDecision + dispatch_target: str + side_effecting: bool + sensitive_context_requested: bool = False + context_pack_refs: tuple[str, ...] = () + evidence_refs: tuple[str, ...] = () + performed_dispatch: bool = False + reason: str | None = None + decided_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat()) + + def to_dict(self) -> dict[str, Any]: + return { + "schemaVersion": "agent-term.pre-dispatch-decision.v0.1", + "recordType": "AgentTermPreDispatchDecision", + "decision_id": self.decision_id, + "requested_action": self.requested_action, + "participant_ref": self.participant_ref, + "participant_kind": self.participant_kind, + "agent_registry_ref": self.agent_registry_ref, + "grant_refs": list(self.grant_refs), + "session_ref": self.session_ref, + "revocation_state": self.revocation_state, + "policy_decision_refs": list(self.policy_decision_refs), + "policy_status": self.policy_status, + "dispatch_decision": self.dispatch_decision, + "dispatch_target": self.dispatch_target, + "side_effecting": self.side_effecting, + "sensitive_context_requested": self.sensitive_context_requested, + "context_pack_refs": list(self.context_pack_refs), + "evidence_refs": list(self.evidence_refs), + "performed_dispatch": self.performed_dispatch, + "reason": self.reason, + "decided_at": self.decided_at, + } + + +def build_pre_dispatch_decision( + *, + decision_id: str, + requested_action: str, + participant_ref: str, + participant_kind: str, + dispatch_target: str, + agent_registry_ref: str | None = None, + grant_refs: tuple[str, ...] = (), + session_ref: str | None = None, + revocation_state: RevocationState = "unknown", + policy_decision_refs: tuple[str, ...] = (), + policy_status: str = "unknown", + sensitive_context_requested: bool = False, + context_pack_refs: tuple[str, ...] = (), + evidence_refs: tuple[str, ...] = (), +) -> AgentTermPreDispatchDecision: + """Build a decision-only pre-dispatch record. + + This function never performs dispatch. It classifies whether a later adapter + may dispatch after Agent Registry and Policy Fabric evidence has been checked. + """ + + side_effecting = requested_action in SIDE_EFFECTING_ACTIONS + decision: DispatchDecision = "allow" + reason: str | None = "all required pre-dispatch gates satisfied" + + if participant_kind != "human" and not agent_registry_ref: + decision = "fail-closed" + reason = "non-human participant missing Agent Registry resolution" + elif participant_kind != "human" and (not grant_refs or not session_ref): + decision = "fail-closed" + reason = "non-human participant missing grant or session reference" + elif revocation_state != "not-revoked": + decision = "deny" if revocation_state == "revoked" else "fail-closed" + reason = f"participant revocation_state={revocation_state}" + elif side_effecting and not policy_decision_refs: + decision = "fail-closed" + reason = "side-effecting action missing Policy Fabric decision refs" + elif sensitive_context_requested and policy_status != "allow": + decision = "deny" if policy_status in {"deny", "denied"} else "require-review" + reason = "sensitive context requires explicit policy admission" + elif policy_status in {"deny", "denied"}: + decision = "deny" + reason = "Policy Fabric denied dispatch" + elif policy_status in {"pending", "require-review"}: + decision = "require-review" + reason = "Policy Fabric requires review before dispatch" + + record = AgentTermPreDispatchDecision( + decision_id=decision_id, + requested_action=requested_action, + participant_ref=participant_ref, + participant_kind=participant_kind, + agent_registry_ref=agent_registry_ref, + grant_refs=grant_refs, + session_ref=session_ref, + revocation_state=revocation_state, + policy_decision_refs=policy_decision_refs, + policy_status=policy_status, + dispatch_decision=decision, + dispatch_target=dispatch_target, + side_effecting=side_effecting, + sensitive_context_requested=sensitive_context_requested, + context_pack_refs=context_pack_refs, + evidence_refs=evidence_refs, + performed_dispatch=False, + reason=reason, + ) + validate_pre_dispatch_decision(record) + return record + + +def validate_pre_dispatch_decision(record: AgentTermPreDispatchDecision | dict[str, Any]) -> None: + """Reject collapsed pre-dispatch records.""" + + data = record.to_dict() if isinstance(record, AgentTermPreDispatchDecision) else record + if data.get("recordType") != "AgentTermPreDispatchDecision": + raise ValueError("recordType must be AgentTermPreDispatchDecision") + if data.get("performed_dispatch") is not False: + raise ValueError("pre-dispatch decisions must not claim dispatch was performed") + + participant_kind = str(data.get("participant_kind", "")) + if participant_kind != "human": + if not data.get("agent_registry_ref"): + raise ValueError("non-human participant requires Agent Registry ref") + if not data.get("grant_refs"): + raise ValueError("non-human participant requires grant refs") + if not data.get("session_ref"): + raise ValueError("non-human participant requires session ref") + + if data.get("revocation_state") in {"revoked", "unknown"} and data.get("dispatch_decision") == "allow": + raise ValueError("revoked or unknown revocation state cannot allow dispatch") + + if data.get("side_effecting") is True and not data.get("policy_decision_refs"): + raise ValueError("side-effecting dispatch requires policy decision refs") + + if data.get("sensitive_context_requested") is True and data.get("policy_status") not in {"allow", "admitted"}: + raise ValueError("sensitive context requires policy admission") + + if data.get("dispatch_decision") == "allow" and data.get("policy_status") in {"deny", "denied", "pending", "require-review"}: + raise ValueError("allow dispatch is inconsistent with policy status") diff --git a/tests/test_pre_dispatch.py b/tests/test_pre_dispatch.py new file mode 100644 index 0000000..9ebe2aa --- /dev/null +++ b/tests/test_pre_dispatch.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import pytest + +from agent_term.pre_dispatch import build_pre_dispatch_decision, validate_pre_dispatch_decision + + +def test_pre_dispatch_allows_resolved_non_human_with_policy_refs(): + decision = build_pre_dispatch_decision( + decision_id="pre-dispatch:ok-001", + requested_action="tool_use", + participant_ref="agent://codex", + participant_kind="agent", + agent_registry_ref="agent-registry://codex", + grant_refs=("grant://codex/tool-use",), + session_ref="session://codex/001", + revocation_state="not-revoked", + policy_decision_refs=("policy-decision://tool-use/allow",), + policy_status="allow", + dispatch_target="adapter://codex", + evidence_refs=("evidence://agent-term/pre-dispatch/ok-001",), + ) + + assert decision.dispatch_decision == "allow" + assert decision.performed_dispatch is False + assert decision.side_effecting is True + validate_pre_dispatch_decision(decision) + + +def test_pre_dispatch_rejects_local_config_only_non_human(): + with pytest.raises(ValueError, match="Agent Registry ref"): + build_pre_dispatch_decision( + decision_id="pre-dispatch:local-only-invalid", + requested_action="tool_use", + participant_ref="agent://codex", + participant_kind="agent", + dispatch_target="adapter://codex", + policy_decision_refs=("policy-decision://tool-use/allow",), + policy_status="allow", + ) + + +def test_pre_dispatch_rejects_revoked_grant_dispatch(): + decision = build_pre_dispatch_decision( + decision_id="pre-dispatch:revoked-001", + requested_action="tool_use", + participant_ref="agent://codex", + participant_kind="agent", + agent_registry_ref="agent-registry://codex", + grant_refs=("grant://codex/tool-use",), + session_ref="session://codex/001", + revocation_state="revoked", + policy_decision_refs=("policy-decision://tool-use/allow",), + policy_status="allow", + dispatch_target="adapter://codex", + ) + + assert decision.dispatch_decision == "deny" + with pytest.raises(ValueError, match="revoked or unknown"): + mutated = decision.to_dict() + mutated["dispatch_decision"] = "allow" + validate_pre_dispatch_decision(mutated) + + +def test_pre_dispatch_rejects_side_effect_without_policy_refs(): + with pytest.raises(ValueError, match="policy decision refs"): + build_pre_dispatch_decision( + decision_id="pre-dispatch:no-policy-invalid", + requested_action="terminal_attach", + participant_ref="human://operator", + participant_kind="human", + revocation_state="not-revoked", + policy_status="allow", + dispatch_target="adapter://terminal", + ) + + +def test_pre_dispatch_rejects_sensitive_context_without_policy_admission(): + with pytest.raises(ValueError, match="sensitive context"): + build_pre_dispatch_decision( + decision_id="pre-dispatch:sensitive-invalid", + requested_action="context_pack", + participant_ref="human://operator", + participant_kind="human", + revocation_state="not-revoked", + policy_decision_refs=("policy-decision://context/pending",), + policy_status="pending", + sensitive_context_requested=True, + context_pack_refs=("context-pack://secret",), + dispatch_target="adapter://context", + ) + + +def test_pre_dispatch_rejects_record_that_claims_dispatch_performed(): + decision = build_pre_dispatch_decision( + decision_id="pre-dispatch:mutated-invalid", + requested_action="read", + participant_ref="human://operator", + participant_kind="human", + revocation_state="not-revoked", + policy_status="allow", + dispatch_target="adapter://noop", + ) + payload = decision.to_dict() + payload["performed_dispatch"] = True + with pytest.raises(ValueError, match="must not claim dispatch"): + validate_pre_dispatch_decision(payload)