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
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
"""Action-bound, fail-closed approval protocol (ADR-0030).

Reference schema and coordinator for the ``require_approval`` enforcement
outcome. This is step 1 of the ADR-0030 migration: the protocol foundation,
purely additive and not yet wired into the policy evaluator or the legacy
``agentmesh.governance.approval`` handlers.

Example::

from agentmesh.governance.approval_protocol import (
ActionBinding, ActionTarget, ApprovalChain, ApprovalStage,
ApprovalCoordinator, InMemoryApprovalStore, ApproverKind, EntryDecision,
)

chain = ApprovalChain(
chain_id="high-risk-tools",
version="3",
stages=(ApprovalStage(0, allowed_identities=frozenset({"did:web:example.com:users:alice"})),),
)
coordinator = ApprovalCoordinator(InMemoryApprovalStore(), {chain.chain_id: chain})

binding = ActionBinding(
operation="tool.invoke",
agent_id="agent-123",
target=ActionTarget("sql_execute", "2", resource="prod-db"),
parameters={"statement": "UPDATE accounts SET status = ? WHERE id = ?", "values": ["closed", 42]},
)
decision, request = coordinator.open_request(
binding, policy_rule_id="production-db-writes",
policy_version="2026.06.11", chain_id=chain.chain_id, ttl_seconds=600,
)
coordinator.submit_entry(
request.approval_request_id, stage_index=0,
approver_kind=ApproverKind.HUMAN,
approver_identity="did:web:example.com:users:alice",
identity_assurance="oidc", decision=EntryDecision.ALLOW,
)
verdict = coordinator.validate_for_execution(
request.approval_request_id,
current_action_digest=binding.digest(),
current_policy_version="2026.06.11",
current_chain_version=chain.version,
)
assert verdict.allowed
"""

from .binding import SCHEMA_VERSION, ActionBinding, ActionTarget
from .coordinator import (
ApprovalChain,
ApprovalCoordinator,
ApprovalProtocolError,
ApprovalStage,
ExecutionDecision,
ReasonCode,
)
from .digest import DIGEST_PREFIX, canonicalize, sha256_jcs
from .models import (
ApprovalChainEntry,
ApprovalRequest,
ApprovalResolution,
ApprovalStatus,
ApproverKind,
EntryDecision,
Outcome,
PolicyDecisionRecord,
Verdict,
utcnow,
)
from .store import ApprovalStore, InMemoryApprovalStore

__all__ = [
# digest
"canonicalize",
"sha256_jcs",
"DIGEST_PREFIX",
# binding
"ActionBinding",
"ActionTarget",
"SCHEMA_VERSION",
# models
"Verdict",
"ApprovalStatus",
"ApproverKind",
"EntryDecision",
"Outcome",
"PolicyDecisionRecord",
"ApprovalRequest",
"ApprovalChainEntry",
"ApprovalResolution",
"utcnow",
# store
"ApprovalStore",
"InMemoryApprovalStore",
# coordinator
"ApprovalCoordinator",
"ApprovalChain",
"ApprovalStage",
"ExecutionDecision",
"ReasonCode",
"ApprovalProtocolError",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
"""Action binding for the action-bound approval protocol (ADR-0030 section 2).

An :class:`ActionBinding` captures the exact executable request an approval
authorizes. Its ``action_digest`` is the SHA-256 over the RFC 8785 JCS
serialization of the binding, so an approval for one binding can never
authorize a different action: change a parameter, the target, the tool schema
version, the acting agent, or the represented subject, and the digest changes.

The full parameters need not be persisted in the approval record, but the
execution boundary MUST be able to recompute the digest from the action it is
about to execute (see :mod:`.coordinator`).
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Mapping, Optional

from .digest import sha256_jcs

#: Schema version stamped into every binding. Bump only with a migration.
SCHEMA_VERSION = "1.0"


@dataclass(frozen=True)
class ActionTarget:
"""The tool/resource an action operates on."""

tool_name: str
tool_schema_version: str
resource: Optional[str] = None

def to_canonical(self) -> dict[str, Any]:
return {
"tool_name": self.tool_name,
"tool_schema_version": self.tool_schema_version,
"resource": self.resource,
}


@dataclass(frozen=True)
class ActionBinding:
"""The exact executable request an approval is bound to.

Args:
operation: Operation kind, e.g. ``"tool.invoke"``.
agent_id: The acting agent.
target: The tool/resource being acted on.
parameters: The exact parameters the action will execute with. Values
must be JSON types; non-JSON values raise from :func:`digest`.
subject_id: The represented principal, if any.
schema_version: Binding schema version (defaults to current).
"""

operation: str
agent_id: str
target: ActionTarget
parameters: Mapping[str, Any] = field(default_factory=dict)
subject_id: Optional[str] = None
schema_version: str = SCHEMA_VERSION

def to_canonical(self) -> dict[str, Any]:
"""Return the canonical mapping hashed into the action digest."""
return {
"schema_version": self.schema_version,
"operation": self.operation,
"agent_id": self.agent_id,
"subject_id": self.subject_id,
"target": self.target.to_canonical(),
"parameters": dict(self.parameters),
}

def digest(self) -> str:
"""Return the ``"sha256:<hex>"`` action digest for this binding."""
return sha256_jcs(self.to_canonical())
Loading
Loading