Skip to main content
What was read, what maps, what does not, and how the adapter is built.

What was read

The Agent Control Standard, a project of the OWASP GenAI Security Project, in GenAI-Security-Project/agent-control-standard at commit c7ad162f69386daac94b89073e3b751e8cdf28b2 (2026-08-11, “Raise the Python floor to unblock Dependabot security updates”). The repository was at release v0.1.1, with the specification version decoupled from the release version at commit 1af1f92 (2026-08-11) and tracked separately at v0.1.0. Code is Apache-2.0; documentation is CC BY-SA 4.0. Specifically: Two things the repository does not have at that commit, both of which shaped this work:
  • No reference implementation. The README’s roadmap places a Guardian Agent sample and FastMCP instrumentation at v1. There is a pyproject.toml, but it serves the docs and version-sync tooling. So there is nothing to conform to except the schemas, and this adapter is written against them directly.
  • No examples/ directory, and so no house format for a community example. examples/acs/ therefore follows CTRLRun’s own convention.

What ACS defines

Three layers: Instrument (runtime hooks and the Guardian Agent pattern), Trace (OpenTelemetry and OCSF with agent-specific conventions), and Inspect (CycloneDX, SPDX and SWID for a dynamic Agent BOM). Only Instrument is relevant here. The wire format is JSON-RPC 2.0. A host fires a hook as a request whose method is steps/<hookName>; a Guardian answers with an AcsResult carrying one of five decisions:
deny requires reasoning. modify requires reasoning and modifications. ask requires reasoning and ask_details. defer requires reasoning and defer_details. Of the 22 hooks, CTRLRun answers two, and it is worth being explicit that it declines the other twenty: SessionStart, SessionEnd, AgentTrigger, TurnStart, TurnEnd, UserMessage, AgentResponse, KnowledgeRetrieval, MemoryContextRetrieval, MemoryStore, PreCompact, PostCompact, SubagentStart, SubagentStop, SkillRegister, SkillLoad, SkillUnload, SystemPing, AgbomSnapshot, AgbomChanged. Those are checkpoints about what the model is thinking, remembering or composed of. CTRLRun’s product rule is that it decides actions that can affect the real world, and nothing else — so an unanswered method returns a JSON-RPC error in ACS’s reserved range rather than an opinion.

The mapping, hook by hook

steps/toolCallRequest → build the Action, decide it, take the reservation

Two fields stopped being read, and it is worth saying why. Under v0.2 both agent_id and environment came off the envelope, on exactly the argument v0.2 §6.5 made for MCP’s clientInfo: a policy could not address the principal, so a self-reported one misattributed a receipt and could not widen an outcome. SPEC-v0.3 §4 ends that — the principal is an authorization input now, and a grant may scope to an environment — so a value the caller sets would let the caller choose what it is authorized as and where. §8.1 removes ctrlrun gateway --principal-from-client-info over the same sentence; the ACS hook was that flag in a different module. The consequence is a required argument: an AcsControlHook built against a Control that holds an Authority, with no identity provider, raises InvalidArgument at construction. A hook with no provider still reads agent_id, unchanged, because there is then no authorization decision for it to widen — and a hook holding an Authority cannot be in that state. The branch is on the provider, not on the authority section: configuring one without an authority: section also stops agent_id being read, which is the less surprising of the two possible rules and the one that makes “with a provider, agent_id is display data” true without a footnote. A configured provider that names nobody is a refusaldeny, reason_codes: ["no_principal"], no receipt and no events — and never a fall back to the envelope: falling back would reach agent_id by an easier route than forging a credential. A credential that was verified and has since lapsed is a different refusal and says so: reason_codes: ["principal_expired"], matching the decision_reason on the receipt Control has already written. The two are told apart structurally — one is raised while the Action is being built, the other by Control.execute — and never by reading a message. One rule of §8.2 the hook does not import. §3.1’s repeated-identity-header refusal is the gateway’s, and it lives in do_POST because that is where a repeated HTTP field is still visible. AcsControlHook.handle(envelope, headers=...) is handed a Mapping[str, str], which holds one value per name — so whatever built that mapping already chose. A deployment putting this hook behind an HTTP server is responsible for that check, exactly as it is responsible for the transport. Stated because it is a real gap rather than an argued omission. operation is part of the name because two verbs on one tool are two actions, and a policy has to be able to say different things about create and void. The decision maps out: ask_details carries approver ({type: "human", id}), a question naming the request id a human answers with ctrlrun approve, and timeout_seconds. All three are required by ask-details.json. ask_details.intent_extension is not used. It grants capabilities for this_request or session, which is an authority model — CTRLRun has none until v0.3, and a grant it cannot represent is one it must not claim to honour.

steps/toolCallResult → close the reservation

ACS describes this hook as “fires after tool execution, before results reach the agent, serving as an output redaction checkpoint”. CTRLRun redacts nothing, so it always answers allow; the work is the outcome it records. exit_status is success | failure | timeout | blocked. ACS does not say what any of them means for the side effect. This is the fail-closed reading, and it is the same one SPEC-v0.2 §6.8 applies to MCP: A result whose request_id_ref matches no held reservation — a restarted Guardian, a result fired twice, one arriving out of order — is logged and nothing is written about an effect. Guessing which call it meant is how a duplicate gets committed.

Where ACS is silent

These are not criticisms of ACS; they are the seam. ACS governs whether an action may proceed. It has almost nothing to say about what happened afterwards, because that is not what the Instrument layer was built for. 1. No effect identity. params.request_id is a per-hook-invocation UUID and request_id_ref links a result to its request. Neither identifies the effect: two calls that would refund the same payment get two unrelated UUIDs. There is no idempotency key, no capability-plus-argument identity, nothing an implementer could use to recognise that a retry is a repeat. CTRLRun supplies one from the policy’s effect: template. 2. exit_status is a status of the call, not an outcome of the effect. Four values, and the vocabulary itself carries the confusion: timeout sits alongside failure as though both were kinds of not-working. The distinction that matters — did the side effect land? — has no representation. A conformant Guardian reading timeout has no way to say “unknown, and a retry is unsafe until a human resolves it”. 3. Nothing binds an approval to the exact action. ask_details carries a question and an approver. What comes back is a decision on the request, and intent_extension can widen a capability for the session. Neither pins the approval to a canonical form of the arguments, so nothing in ACS prevents an agent from getting refund(2000) approved and then calling refund(5000). CTRLRun binds to action_hash, which covers the principal, the arguments, the resource and the environment. 4. No terminal unknown state. ACS’s decisions are about the future of a call. There is no way for a Guardian to record that an effect’s outcome is unresolved and that no further call on that effect may proceed until a human says which way it went. AMBIGUOUS has no ACS counterpart, and it is the state most of CTRLRun’s design exists to protect. 5. The Guardian does not execute. ACS is advisory by construction — the platform runs the tool. That is a reasonable separation, but it means a Guardian cannot make reserve-and-execute atomic. The best available is what this adapter does: reserve at the request hook, close at the result hook, and accept that a platform which never fires the result hook leaves a reservation to lease-expire into AMBIGUOUS by the ordinary path of v0.1 §5.3 E3.

The adapter’s design

ctrlrun.acs.AcsControlHook, in ctrlrun[gateway] — it needs no new dependency, and it is kept out of core for the same reason the gateway is: import ctrlrun must not grow. The shape is forced by the seam above. ACS is advisory and CTRLRun is executing, so one action is split across two hooks and the reservation is held between them. That is exactly the shape Suspended and Control.resume were built for in SPEC-v0.2 §6.9 — a reservation held across a round trip the kernel does not control — so the adapter reuses them rather than reaching for the store itself:
Control remains the only module that composes the others (ARCHITECTURE §6). The adapter translates two vocabularies and decides nothing. What it does not do. It does not use modify — CTRLRun refuses or permits an action as proposed, and rewriting an agent’s arguments is a different product. It does not use defer. It does not answer the other twenty hooks. It makes no claim of conformance: the schemas are v0.1.0, the repository is a public preview, there is no reference implementation to test against, and there is no conformance suite. The words “ACS-compatible” do not appear in this repository’s README, docstrings or CLI output, and should not until something exists to be compatible with.

What the OTel attributes would have to become

SPEC-v0.2 §8’s sink emits ctrlrun.* attributes. ACS’s Trace layer extends OpenTelemetry with its own agent conventions and maps security events to OCSF; acs_schema.json carries TraceOtelMapping and TraceOcsfMapping definitions for that purpose. Aligning would mean renaming ctrlrun.action.name, ctrlrun.principal.agent, ctrlrun.decision and the rest onto ACS’s conventions. The cost is not the rename: it is that receipts already written carry the old names, and a receipt is evidence that has to outlive the tool that wrote it. Any alignment is therefore additive — emit both, deprecate neither — or it is a schema version bump on the receipt, which SPEC-v0.2 §11 explicitly does not do. That work is not in this release, and the mapping table is deliberately not written yet: it would be a compliance claim with nothing behind it.