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.
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.
Portability comes from a very old split: interception and enforcement stay in the framework, and the decision moves out into a stateless function.
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.
# 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)
}
$.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.Where you intercept decides which attacks you can stop, and how much latency every loop iteration pays for it.
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.
# 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
}
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.A human approval must cover exactly the arguments that were reviewed. Otherwise you have a TOCTOU hole.
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.
# 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
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.The benefit you can have today comes not from the ACS runtime but from the habit of treating policy as data.
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.
# 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.
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.