Tutorial 55: Agent Control Specification¶
Time: 20 minutes · Level: Intermediate · Prerequisites: Python 3.11+, a repository checkout, and
opaonPATH
What you will build¶
Build an ACS policy enforcement point for an email tool. The host sends ACS a complete snapshot before and after the tool call, then enforces the returned verdict.
Prefer a runnable repository example?
Start with examples/acs-email-tool in a repository checkout. It demonstrates the canonical Python host path through AgentControl, HostSession, and SnapshotBuilder without OPA. This tutorial adds a Rego policy to expose the native manifest and policy-input contract.
You will create:
- a flat ACS manifest
- a Rego policy bundle
- a Python host that calls
AgentControl.run_tool() - three outcomes:
allow,transform, anddeny
Public Preview
ACS is vendored into AGT under policy-engine/ as the AGT 5.0 policy layer. The APIs and manifest shape may change before GA.
How ACS fits in AGT¶
ACS makes the policy decision. Your application or adapter enforces it.
Each evaluation includes its full context: the intervention point, tool call, tool result, labels, and metadata. ACS retains no session state between calls.
Step 1: Install the Python SDK from the repo¶
From the repository root:
The agent-control-specification distribution builds the native Rust core with maturin when installed from source. It includes AgentControl, HostSession, and SnapshotBuilder for Python hosts.
OPA-backed Rego examples require the opa CLI on PATH.
Step 2: Create the tutorial workspace¶
Step 3: Write the ACS manifest¶
Create manifest.yaml:
agent_control_specification_version: "0.3.1-beta"
metadata:
name: acs-email-tutorial
policies:
email_policy:
type: rego
bundle: ./policy
query: data.agent_control_specification.email_policy.verdict
intervention_points:
pre_tool_call:
policy_target: $.tool_call.args
policy_target_kind: tool_args
tool_name_from: $.tool_call.name
policy:
id: email_policy
post_tool_call:
policy_target: $.tool_result.value
policy_target_kind: tool_result
tool_name_from: $.tool_call.name
policy:
id: email_policy
tools:
send_email:
type: Tool
id: send_email
clearance: internal
The manifest binds the same Rego policy at two intervention points:
| Intervention point | What ACS evaluates |
|---|---|
pre_tool_call | The outbound tool arguments before the email tool runs |
post_tool_call | The tool result before it returns to the caller |
Step 4: Write the Rego policy¶
Create policy/email_policy.rego:
package agent_control_specification.email_policy
import rego.v1
default verdict := {"decision": "allow"}
verdict := {
"decision": "deny",
"reason": "external_recipient_blocked",
"message": "Messages to external recipients are blocked."
} if {
input.intervention_point == "pre_tool_call"
input.tool.name == "send_email"
endswith(input.policy_target.value.to, "@example.net")
}
verdict := {
"decision": "transform",
"reason": "redact_tracking_token",
"message": "Tracking token redacted before tool execution.",
"transform": {
"path": "$policy_target.body",
"value": "Your case is ready. Tracking token: [REDACTED]"
}
} if {
input.intervention_point == "pre_tool_call"
input.tool.name == "send_email"
contains(input.policy_target.value.body, "TRACK-")
}
The policy returns:
| Input | Verdict |
|---|---|
| Normal internal email | allow |
| Internal email with a tracking token | transform |
External @example.net recipient | deny |
Step 5: Write the host¶
Create run.py:
import asyncio
from pathlib import Path
from agent_control_specification import AgentControl, AgentControlBlocked
ROOT = Path(__file__).parent
async def send_email(args):
return {"sent": True, "to": args["to"], "body": args["body"]}
async def main():
control = AgentControl.from_path(str(ROOT / "manifest.yaml"))
allowed = await control.run_tool(
"send_email",
{"to": "customer@example.com", "body": "Your case is ready."},
send_email,
tool_call_id="email-1",
)
print(allowed.value)
transformed = await control.run_tool(
"send_email",
{
"to": "customer@example.com",
"body": "Your case is ready. Tracking token: TRACK-123",
},
send_email,
tool_call_id="email-2",
)
print(transformed.value)
try:
await control.run_tool(
"send_email",
{"to": "partner@example.net", "body": "Hello."},
send_email,
tool_call_id="email-3",
)
except AgentControlBlocked as exc:
print(exc.result.verdict.reason)
asyncio.run(main())
run_tool() evaluates pre_tool_call before execution and post_tool_call after the tool returns.
Step 6: Run it¶
Expected output:
{'sent': True, 'to': 'customer@example.com', 'body': 'Your case is ready.'}
{'sent': True, 'to': 'customer@example.com', 'body': 'Your case is ready. Tracking token: [REDACTED]'}
external_recipient_blocked
The second call applies the transform verdict before execution. The third blocks the tool after a deny verdict.
Step 7: Inspect the policy input shape¶
ACS policies evaluate a canonical policy input. For pre_tool_call, the Rego policy receives fields like:
{
"intervention_point": "pre_tool_call",
"policy_target": {
"path": "$.tool_call.args",
"kind": "tool_args",
"value": {
"to": "customer@example.com",
"body": "Your case is ready."
}
},
"tool": {
"name": "send_email",
"id": "send_email",
"clearance": "internal"
}
}
The input may include more snapshot, annotation, and manifest-derived fields. Read only the canonical fields the policy needs; do not depend on host-local state.
Step 8: Try a fail-closed case¶
Change the manifest so tool_name_from points at a missing path:
Run the host again. ACS fails closed with a runtime error verdict instead of allowing the call.
Malformed manifests, missing paths, policy dispatcher failures, and invalid transform targets produce deny verdicts with reserved runtime-error reasons.
Next steps¶
- Run the framework-neutral AGT host example at
examples/acs-email-tool. - Inspect the ACS plus ATR annotator example.
- Read the Agent Control Specification package page.
- Compare Rego and Cedar in OPA / Rego / Cedar Policies.
- Add human review with Approval Workflows.
- Review policy composition with Policy Composition.