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
205 changes: 205 additions & 0 deletions docs/ACS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# CTRLRun and the Agent Control Standard

What was read, what maps, what does not, and how the adapter is built.

## What was read

The [Agent Control Standard](https://agentcontrolstandard.org/), a project of the OWASP GenAI
Security Project, in
[`GenAI-Security-Project/agent-control-standard`](https://github.com/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:

| File | What it gave |
|---|---|
| `specification/ACS/acs_schema.json` | the v0.1.0 aggregator; 22 hooks; `oneOf` a RequestEnvelope or a ResponseEnvelope |
| `specification/v0.1.0/request-envelope.json` | JSON-RPC 2.0; method namespaces; `AcsParams` and its `metadata` identity fields |
| `specification/v0.1.0/response-envelope.json` | `AcsResult`, and the five decisions |
| `specification/v0.1.0/hooks/tool-call-request.json` | `steps/toolCallRequest` — `tool`, `arguments`, `operation`, `capability`, `intent` |
| `specification/v0.1.0/hooks/tool-call-result.json` | `steps/toolCallResult` — `exit_status`, `outputs`, `request_id_ref`, `duration_ms` |
| `specification/v0.1.0/ask-details.json` | what an `ask` decision must carry |
| `CONTRIBUTING.md` | DCO required (`git commit -s`); spec changes open a Discussion first; no CLA |

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:

```text
allow · deny · modify · ask · defer
```

`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

| ACS field | CTRLRun |
|---|---|
| `params.metadata.agent_id` | `Principal.agent` |
| `params.metadata.user_context.user_id` | `Principal.user` |
| `params.metadata.environment` | `Action.environment` (ACS's enum passes through; v0.1 §2.1 takes any non-empty string) |
| `payload.tool.name`, `payload.tool.provider`, `payload.operation` | `Action.name`, as `<prefix>.<provider>.<tool>[.<operation>]` |
| `payload.arguments` | `Action.arguments`, unwrapped from `{name: {value, provenance}}` to `{name: value}` |
| — *(ACS has no resource field)* | `Action.resource`, from the policy's `resource:` template (SPEC-v0.2 §3) |
| `params.request_id` | the continuation that joins this hook to its result |

`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:

| CTRLRun | ACS |
|---|---|
| `ALLOW` | `allow` |
| `DENY` | `deny` + `reasoning` + `reason_codes` |
| `APPROVE` (`ApprovalRequired`) | `ask` + `reasoning` + `ask_details` |
| `DuplicateEffect` | `deny`, `reason_codes: ["ctrlrun.duplicate_effect", <state>]` |
| `AmbiguousEffect` | `deny`, `reason_codes: ["ctrlrun.ambiguous_effect"]` |
| `ApprovalMismatch` | `deny`, `reason_codes: ["ctrlrun.blocked", <reason>]` |

`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:

| `exit_status` | Effect state | Why |
|---|---|---|
| `success` | `COMMITTED` | the only status that asserts the effect happened |
| `blocked` | `FAILED` | a control refused it before dispatch — this is what `NotExecuted` means |
| `failure` | `AMBIGUOUS` | a tool that failed *after* acting and one that failed *before* send the same string |
| `timeout` | `AMBIGUOUS` | the same, and the case this library exists for |
| `failure` **with** `mcp.not_executed_on_error: true` | `FAILED` | an operator's per-tool assertion (SPEC-v0.2 §3.1) |

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:

```text
steps/toolCallRequest
→ Action ← envelope metadata + payload + policy templates
→ Control.execute(action, executor=raise Suspended(request_id), effect_key)
· policy decided, approval consumed, effect reserved, EXECUTION_STARTED
· executor suspends: no outcome, no receipt, lease extended, continuation held
→ allow | deny | ask

steps/toolCallResult
→ Control.resume(request_id_ref, executor=report(exit_status))
· the same outcome mapping execute uses (v0.1 §5.5)
· commit / fail / ambiguous, one receipt, same action_id and attempt
→ allow
```

`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.
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Standards: none. `THREAT_MODEL.md` is the only compliance-adjacent claim.

Adoption story: *existing MCP server + one CTRLRun gateway = action safety.*

Standards: OWASP ACS adapter (code), OpenTelemetry export (code), MCP gateway. First use of "ACS-compatible", and only once the adapter exists.
Standards: OpenTelemetry export (code), MCP gateway. The OWASP ACS adapter shipped in v0.2 (see `docs/ACS.md`); "ACS-compatible" is still unearned and waits on an ACS conformance suite to measure against.

## v0.3 — Authority

Expand Down
38 changes: 32 additions & 6 deletions docs/SPEC-v0.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1130,17 +1130,31 @@ A retry is a new action and therefore a new span; the two are correlated by

---

## 9. ACS: design note only
## 9. ACS: the design note, and the adapter it earned

Item 7 writes `docs/ACS-NOTE.md`. **No code, no adapter, no claim.**
**Amended.** This section said `docs/ACS-NOTE.md`, no code, no adapter, no claim — on the
reading that ACS had no stable interface to build against. Reading the v0.1.0 schemas at
commit `c7ad162` (2026-08-11) settled that differently: the hook payloads, the envelope and
the five decisions are specified precisely enough to write against, and `steps/toolCallResult`
gives a post-execution checkpoint the earlier draft assumed did not exist.

So: `docs/ACS.md` (renamed from `ACS-NOTE.md`), **and** an adapter in `ctrlrun[gateway]` —
`ctrlrun.acs.AcsControlHook` — with acceptance tests T51-T55. `ROADMAP.md`'s "OWASP ACS
adapter (code)" line moves from v0.3 into this release.

**The no-claim rule is untouched and is the reason the adapter is safe to ship.** There is no
ACS reference implementation and no conformance suite at that commit, so there is nothing to
be conformant *with*. The words "ACS-compatible" MUST NOT appear in the README, in docstrings,
or in CLI output. `docs/ACS.md` states what was read, what maps, and — at length — where ACS
is silent.

The Agent Control Standard is an open specification for runtime agent governance, published at
v0.1.0 on 27 May 2026 as a project of the OWASP GenAI Security Project
(<https://agentcontrolstandard.org/>). It defines validation checkpoints across an agent's
lifecycle, expresses policy as YAML, and extends OpenTelemetry with agent-specific semantic
conventions while mapping security events to OCSF.

The note states, and does no more than state:
`docs/ACS.md` states:

- where CTRLRun would sit in that model — the tool-call checkpoint, and only that one;
- what an adapter would carry across, and what has no counterpart on the ACS side: effect
Expand All @@ -1150,8 +1164,8 @@ The note states, and does no more than state:
conventions, and the cost of doing that to receipts already written.

`ROADMAP.md`'s standards rule governs: integrate first, map second, never claim compliance.
The words "ACS-compatible" MUST NOT appear in the README, in docstrings, or in CLI output in
v0.2. They are earned by an adapter with tests, which is not in this release.
The adapter is the "integrate" step. "ACS-compatible" is the "claim" step, and it is not
earned by an adapter alone — it needs something on the ACS side to be measured against.

---

Expand Down Expand Up @@ -1304,6 +1318,18 @@ An intercepted `tools/call` answered with an SSE stream carrying `id:` fields re
client with those fields stripped, so it cannot request a resume of a stream whose outcome the
gateway owns. A non-intercepted stream reaches the client with its `id:` fields intact.

### T51-T55 — The ACS control hook
Against a minimal fake ACS runtime, per §9 and `docs/ACS.md`. **T51**: a hooked
`steps/toolCallRequest` becomes an Action with the name, arguments and principal the envelope
names, arguments unwrapped from their provenance envelopes and the resource from the policy
template. **T52**: `ALLOW`/`DENY`/`APPROVE` map to `allow`/`deny`/`ask`, each carrying what
`response-envelope.json` requires of it. **T53**: an approval is re-presented by re-sending the
identical call, and a mutated call gets a fresh question rather than the granted one; a second
call on a committed or held effect is denied. **T54**: `exit_status` maps by v0.1 §5.5's
asymmetry — `success` committed, `blocked` failed, `failure` and `timeout` ambiguous unless
`not_executed_on_error` says otherwise. **T55**: an unanswered method is a JSON-RPC error in
ACS's reserved range, and `import ctrlrun` does not import the adapter.

### T31 — Every example runs and every template loads
Each script under `examples/` exits 0 with no network, against its own state directory, and
prints the refusal it exists to demonstrate. Every `examples/policies/<sector>.yaml` loads
Expand Down Expand Up @@ -1401,6 +1427,6 @@ and OTLP/HTTP exporter). Neither is in `dependencies`.
Everything in v0.1 §9 that v0.2 does not deliver, and specifically: the deprecated
`2024-11-05` HTTP+SSE transport; relaying SSE resumption for an intercepted call (§6.2);
binding an approval across an elicitation round trip (§6.9.5); more than one upstream per
gateway; multi-host reservation; an ACS adapter or any ACS claim;
gateway; multi-host reservation; any ACS compliance claim;
authenticating the principal or the approver; signed receipts; MCP `tasks`, `apps` or any other
extension; `ctrlrun verify`; framework adapters; Postgres; anything in `VISION.md`.
24 changes: 24 additions & 0 deletions examples/acs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# The double-refund scenario, through ACS hooks

ACS has no `examples/` directory of its own as of commit `c7ad162` (2026-08-11), so this
follows CTRLRun's own convention: one runnable script, its policy beside it, no network, and
its own state directory.

```
python examples/acs/main.py
```

## What it shows

A tool call fires two ACS hooks. `steps/toolCallRequest` is where a Guardian decides;
`steps/toolCallResult` is where the platform reports what happened. CTRLRun answers both:
it takes the reservation at the first and closes it at the second, which is what makes the
retry refusable — the first call's effect record is still open when the second arrives.

The remote commits the refund and the response goes missing. ACS reports `exit_status:
"timeout"`, and **ACS does not say what a timeout means for the side effect**. CTRLRun
records `AMBIGUOUS` and refuses the retry, because a tool that timed out after acting and one
that timed out before acting send the same string.

See [`docs/ACS.md`](../../docs/ACS.md) for the full mapping and for where the two models
disagree.
19 changes: 19 additions & 0 deletions examples/acs/ctrlrun.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# The policy this example runs under. Unknown actions are denied; there is no default-allow.
#
# The rule these decisions follow, so you can apply it to actions not listed here:
# cheap to undo → allow (autonomous)
# leaves the building → approve (a human, every time)
# destroys the evidence → deny (not an agent action at any size)
#
# Action names are built by the ACS hook as <prefix>.<provider>.<tool>, so a policy written
# against an ACS deployment addresses one stable string per tool.
schema: ctrlrun.policy/v2

actions:
acs.stripe.create_refund:
effect: "refund:{payment_id}"
resource: "payment:{payment_id}"
rules:
- when: { amount_gte: 0, amount_lte: 50000 } # $0 to $500 — autonomous
decision: allow
- decision: deny
Loading
Loading