DAY 61 · GOVERNANCE

Portable Agent Runtime Governance (ACS)

Guardrails written as a policy file that moves with the agent, so switching frameworks no longer means rewriting them

2026-10-04 · BigCat

Are your guardrails code written inside a framework, or data that a framework enforces? The answer decides whether a framework switch makes you write them all again.

Prerequisite → Day 50 LLM Guardrails & Sandboxing (writing guardrails yourself)

// WHY THIS MATTERS

The guardrails in Day 50 rest on an unstated assumption: they live inside one framework's callbacks. Claude Code uses hooks, LangGraph uses nodes, the OpenAI Agents SDK uses guardrail decorators, and none of the three can read the others. A super individual often runs two or three agent stacks at once, so the same "no rm -rf" rule ends up written three times. When the rule changes, whichever copy you forget to update becomes the hole. Microsoft's Agent Control Specification (ACS), released at Build 2026, aims to fill that gap: policy is data (a YAML manifest plus Rego or Cedar), and the framework's only job is to call it at fixed checkpoints and enforce the verdict. It is still a 0.4.0-alpha.1 draft, so there is no rush to put it in production. It is worth reading because it states openly the engineering decisions a portable guardrail needs: how the responsibilities are split, where to intercept, how many kinds of verdict there are, and what happens on error. This issue takes each one apart and also shows where its promises fall short.

// POINT 01

PEP / PDP Split: the Framework Enforces, the Policy Decides

Portability comes from a very old split: interception and enforcement stay in the framework, and the decision moves out into a stateless function.

// Background & Mechanism

ACS borrows the classic access-control split. The host (your agent framework) is the PEP, the policy enforcement point, and the ACS runtime is the PDP, the policy decision point. At each checkpoint the host packs the current context into a complete JSON snapshot and hands it to the runtime. The runtime evaluates the policy bound in the manifest, computes a normalized verdict, and returns it for the host to enforce. The spec gives the runtime three hard constraints. It is stateless: it may keep no mutable state between evaluations that could affect a verdict. It is deterministic: the same manifest, snapshot, mode and dispatcher results must produce the same verdict. It is fail-closed: any error during evaluation must produce deny.

Portability rests on these three rules. A verdict depends only on manifest plus snapshot, so the same policy file should reach the same conclusion under any framework. Policies can be written in Rego (OPA) or Cedar. The runtime core is Rust, with Python, Node, .NET and Go bindings.

// Hands-on Example

# The spec's full worked example (verbatim): block destructive SQL at the input checkpoint
agent_control_specification_version: 0.4.0-alpha.1
policies:
  input_guard:
    type: custom
    adapter: example_blocklist
intervention_points:
  input:
    policy_target_kind: user_input
    policy:
      id: input_guard
    policy_target: $.input

# snapshot sent by the host                  {"input": {"text": "please drop table users"}}
# dispatcher response → normalized verdict    {"decision": "deny", "reason": "blocked_destructive_sql"}
# The same idea in Rego: policy input carries intervention_point / policy_target / tool / annotations
package agent.tool_guard
default verdict := {"decision": "allow"}
verdict := {"decision": "deny", "reason": "destructive_shell"} if {
  input.tool.clearance == "shell"
  regex.match(`rm\s+-rf\s+/|mkfs|dd\s+if=`, input.policy_target.command)
}
Failure mode: the PDP sees only what the PEP gives it. The spec does not prescribe a top-level snapshot structure; the host defines it, and policies read values through paths like $.input. So "portable" carries a condition: every framework's adapter has to build the snapshot in the same shape. If the LangChain adapter puts the command at args.command and your own adapter uses args.cmd, the same Rego finds nothing in the second case. Under the spec it then fails closed with runtime_error:path_missing. A denial is better than a silent allow, but it means every framework move requires re-checking the snapshot contract.
// POINT 02

Eight Checkpoints: the Announcement Says Five, the Spec Defines Eight

Where you intercept decides which attacks you can stop, and how much latency every loop iteration pays for it.

// Background & Mechanism

The announcement speaks of "five validation checkpoints: input, LLM, state, tool execution, output." In the spec these become 8 named intervention points. LLM and tool execution each split into a before-call and an after-call point, and "state" maps to session startup and shutdown. When citing it, go by the spec.

agent_startup  ─▶ config / environment check
     │
input          ─▶ before user input reaches the model
     │
 ┌──▶ pre_model_call   ─▶ before the full context goes to the model
 │       │
 │   post_model_call  ─▶ before the model's reply is acted on
 │       │
 │   pre_tool_call    ─▶ before tool name + args execute   ← stop destructive actions
 │       │
 └── post_tool_call   ─▶ before tool output re-enters context ← stop indirect injection
     │
output         ─▶ before the final reply leaves        ← stop exfiltration
     │
agent_shutdown ─▶ session end: audit and logging

The engineering point here is annotations. Each checkpoint can attach classifier, llm or endpoint annotators. They finish before the policy is called and write scores such as "how injection-like is this text" into the policy input, and the policy itself only makes deterministic decisions on those scores. So "deterministic" describes the runtime layer. An LLM annotator's network call belongs to the host and is not deterministic. The division of labor: the model makes the semantic judgment, and "what score crosses the line" lives in an auditable rule.

// Hands-on Example

# Rego: annotator scores block indirect injection at post_tool_call; pre_tool_call keeps plain arg rules
package agent.ingest_guard
default verdict := {"decision": "allow"}
verdict := {"decision": "deny", "reason": "indirect_injection"} if {
  input.intervention_point == "post_tool_call"
  input.annotations.injection_score >= 0.8        # written by a classifier annotator
}
verdict := {"decision": "warn", "reason": "injection_suspect"} if {
  input.intervention_point == "post_tool_call"
  input.annotations.injection_score >= 0.5
  input.annotations.injection_score < 0.8
}
Failure mode: the most common mistake is guarding only pre_tool_call. Destructive commands get blocked, but injection hidden in web pages, emails and retrieval results enters the context at post_tool_call (the path covered in Day 24), so guarding only before execution lets it in. Going the other way, attaching LLM annotators to all eight points adds two model round trips to every tool call and roughly doubles latency and cost on long tasks. A practical setup: cheap classifiers around tool calls, and an LLM judge only on output.
// POINT 03

Five Intents, Three Verdicts: Escalate Is a Deny That Can Be Lifted

A human approval must cover exactly the arguments that were reviewed. Otherwise you have a TOCTOU hole.

// Background & Mechanism

A policy can express five intents, and all of them normalize into three verdicts: allow, deny, transform. warn becomes allow plus an entry in warnings[]. escalate becomes a deny carrying an approval block, which the spec calls a liftable deny. It goes to the host's approval resolver, which can allow, deny or suspend. transform (redaction or rewriting) may change only the policy target itself, never the snapshot, the annotations or host state.

The approval design is worth copying. The approval object carries an enforced_identity: the SHA-256 of the canonical policy input after the transform. Right before execution, the SDK recomputes it from the current input, and on a mismatch it fails closed with host_error:approval_identity_mismatch. This closes the time-of-check/time-of-use (TOCTOU) window, where A is reviewed but B is what runs. Separately, in evaluate_only mode verdicts are computed as usual but transforms are not applied. That works as a shadow mode: you see what a policy would block before you decide to turn it on.

// Hands-on Example

# Skeleton of the host-side enforcement loop (pseudocode showing PEP duties; not the literal SDK API)
v = acs.evaluate(point="pre_tool_call", snapshot=snap, mode="enforce")
if v.decision == "allow":      run(tool, args); log(v.warnings)
elif v.decision == "transform": run(tool, v.transformed_target)      # use only the rewritten args
elif v.approval:                   # liftable deny
    ok = ask_human(v.approval, show=canonical(args))
    if ok and sha256(canonical(current_input)) == ok.enforced_identity:
        run(tool, args)
    else: refuse()                   # args changed → must be re-reviewed
else: refuse(v.reason)               # runtime_error:* lands here too
Failure mode: fail-closed costs availability. An annotator timeout becomes runtime_error:annotation_timeout and a deny, so one hiccup in the classifier service halts every agent at once. That is a trade-off you have to accept explicitly: give annotators a timeout budget and a degraded path instead of assuming they are always up. The second trap is approval fatigue. With a loose escalate threshold, people quickly get used to clicking "approve" every time, and the approval step stops meaning anything. Before going live, run a week in evaluate_only and measure how often escalate fires.
// POINT 04

For a Super Individual: Decouple Policy from Framework First, Decide on ACS Later

The benefit you can have today comes not from the ACS runtime but from the habit of treating policy as data.

// Background & Mechanism

Start by comparing it with what you already use. Claude Code's PreToolUse hook can return a permissionDecision (allow / deny / ask) and can rewrite arguments with updatedInput. Semantically that matches ACS's allow / deny / escalate / transform, except it is locked inside Claude Code. So the test is simple: with one framework, hooks are enough. An external PDP like ACS pays off once you run two or more frameworks, or need versioned and audited policy.

The ACS manifest's extends suits layered governance. Its merge is additive only: a name defined in more than one manifest must be identical everywhere or non-conflicting, and at a given checkpoint only annotations are unioned. In other words, a child manifest can add annotators on top of your base rules but cannot loosen them; a conflict fails closed. Remote extends is HTTPS-only, can be pinned with sha256, and may not point at private-network or cloud-metadata addresses.

// Hands-on Example

# A bridge you can use today: one Rego file, called from a Claude Code hook now, reusable under ACS later
# .claude/hooks/policy.sh (PreToolUse; needs opa and jq installed locally)
#!/usr/bin/env bash
in=$(cat)
snap=$(jq '{intervention_point:"pre_tool_call",
            tool:{clearance:(if .tool_name=="Bash" then "shell" else "other" end)},
            policy_target:.tool_input}' <<<"$in")
v=$(opa eval -d ~/policies/tool_guard.rego -I --format raw \
      'data.agent.tool_guard.verdict' <<<"$snap")
if [ "$(jq -r .decision <<<"$v")" = "deny" ]; then
  jq -n --arg r "$(jq -r .reason <<<"$v")" '{hookSpecificOutput:{
    hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
fi

Build the snapshot shape (tool.clearance / policy_target) to match the ACS policy input from day one. Then the adapter stays a thin layer and the policy exists in one copy, and a framework switch means rewriting only that dozen-line adapter.

Failure mode: first, the spec is still moving. The bundle_url remote policy bundle from the earlier engine has already been removed in this version, and the whole toolkit is still in Public Preview. Binding deeply to the SDK's API means signing up for migration debt; the Rego policies themselves are the most stable asset. Second, Rego is one more language to learn, and a wrong policy can also let things through silently, so write unit tests for policies (OPA ships opa test). Third, deterministic rules have a ceiling: an argument regex can stop rm -rf / but not "base64-encode the key and tuck it into a harmless-looking URL." Semantic attacks still need annotators and the Day 50 sandbox as backstops. The policy layer is just one layer of defense in depth.

// Reflections

ACS claims to be vendor-neutral. Which layer does its portability actually get stuck on?
The snapshot contract, not the policy language. Rego and Cedar are neutral already. The hard part is whether every framework's adapter packs the same facts into the same JSON: what the tool arguments are called, where annotation scores go, how "who is calling" is expressed in multi-agent setups. The spec leaves snapshot structure to the host. That is pragmatic (frameworks differ too much), but it means portability depends on adapters agreeing with each other. Compare OpenTelemetry: the protocol existed early, and what made it usable was the semantic conventions agreed later. That is exactly what ACS is missing.
"Deterministic runtime + LLM annotators": what does determinism actually guarantee here?
It guarantees that the same evidence produces the same verdict. It does not guarantee that the evidence is stable. If an LLM annotator scores 0.79 today and 0.81 tomorrow, the verdict flips between warn and deny. Its value lies in isolating the nondeterminism in one auditable place: after an incident you can tell whether the score moved or the rule was wrong. It is the same idea as Day 58 pushing nondeterminism into activities: the deterministic part becomes replayable rules, and the nondeterministic part is isolated and recorded.
Who does the additive-only extends merge protect against?
It protects against a child manifest quietly loosening its parent. An organization's base policy cannot be overridden by one agent's manifest: a conflict fails outright instead of letting the later definition win. That is the opposite of CSS cascading or Kubernetes overlays and closer to the policy-as-code convention that a security baseline can only tighten. The cost is that exceptions are hard to grant. If an agent genuinely needs wider permissions, you have to edit the base policy or start a separate manifest, which forces you to write the exception down explicitly.
For a solo developer, when is an external PDP over-engineering?
With one framework, fewer than ten rules and no audit requirement, hooks plus a few lines of shell are enough, and adding OPA and a set of Rego files just brings in two more components that can break. There are three real tipping points: the same rule has to apply in two or more places, rules need versioning and rollback, or you must prove to someone else that "this agent cannot do X." Even before you reach one, you can adopt the policy-as-data habit without rushing to the runtime.

// Further Reading