From 9dfa2908418a4e08c477cfaf53a41f254f9f8f5b Mon Sep 17 00:00:00 2001 From: Bar Kaduri Date: Tue, 13 Oct 2026 19:17:28 +0300 Subject: [PATCH 1/3] Record every decision failure and count them at session end Section 6.4 now requires an audit record for every decision failure under either posture, kept in the deployment's own audit log and emitted on Trace as acs.decision.failure when the deployment claims ACS-Trace. Section 4.1 surfaces a session started unguarded as the same event with class handshake_failure. sessionEnd.summary gains decision_failures, a per-class count the Observed Agent SHOULD report, and a session that ends without it reads as unmeasurable. conformance.md and the hooks.md sessionEnd summary follow, and a test keeps the counted classes equal to the acs.failure.class values in the OTel mapping. Signed-off-by: Bar Kaduri --- docs/spec/conformance.md | 2 +- docs/spec/instrument/hooks.md | 2 +- docs/spec/instrument/specification.md | 4 +-- specification/v0.1.0/hooks/session-end.json | 8 ++++++ tests/test_decision_failure_classes.py | 28 +++++++++++++++++++++ 5 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 tests/test_decision_failure_classes.py diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index dd937e95..bb588a1a 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -34,7 +34,7 @@ A v0.1.0-conformant deployment MUST implement ACS-Core. ACS-Core comprises: - **SessionContext and Intent**: `session_id`, `chain_hash` (rolling SHA-256), append-only ContextEntry chain, with the Guardian publishing the chain head (`chain_hash`) on responses for content-bearing steps ([§8](./instrument/specification.md#8-sessioncontext-and-intent)). Intent is optional but normative when IBAC is the enforcement paradigm. - **Replay protection** — `request_id` (UUID) and `timestamp` on every request; Guardians MUST reject replays per [§10.3](./instrument/specification.md#103-replay-protection). - **Baseline integrity**: every request and response carries a signature over the canonical envelope ([§10](./instrument/specification.md#10-cryptographic-signatures)). `HMAC-SHA256` with an HKDF-derived per-session key from deployment-provided key material is the baseline; asymmetric and post-quantum algorithms are the ACS-Crypto profile. -- **Decision honoring**: the Observed Agent MUST wait for the Guardian's decision up to the negotiated timeout and apply it; on a decision failure (timeout, transport failure, or an error without a decision) it applies the `on_decision_failure` posture (default `proceed`, fail-open) and records every fail-open proceed as an audit event ([§6.4](./instrument/specification.md#64-honoring-decisions-normative)). Handshake failure follows the deployment's startup posture ([§4.1](./instrument/specification.md#41-handshake-failure-normative)). +- **Decision honoring**: the Observed Agent MUST wait for the Guardian's decision up to the negotiated timeout and apply it; on a decision failure (timeout, transport failure, or an error without a decision) it applies the `on_decision_failure` posture (default `proceed`, fail-open) and records every decision failure as an audit event, fail-open or fail-closed ([§6.4](./instrument/specification.md#64-honoring-decisions-normative)). Handshake failure follows the deployment's startup posture ([§4.1](./instrument/specification.md#41-handshake-failure-normative)). - **Liveness** — `system/ping` SHOULD be implemented: the Observed Agent declares it in `methods_implemented` and sends it, and the Guardian answers it per [§13](./instrument/specification.md#13-liveness-system-methods). A deployment MAY omit it only if the Observed Agent relies on an alternative liveness mechanism that the deployment's configuration names (for example: TCP keepalive or HTTP/2 PING on HTTP transports; process supervision or an out-of-band heartbeat on stdio; or continuous observed hook traffic where the session's own cadence acts as the probe). ACS does not put that declaration on the wire in v0.1, as [§9.2](./instrument/specification.md#92-approver-incapable-clients-normative) does not for approver capability. Omitting `system/ping` without a declared alternative is non-conformant: the chain of defaults (`startup_posture: proceed`, `on_decision_failure: proceed`, no liveness) produces silent fail-open on Guardian outage, which the Core baseline cannot allow. - **Wrapped MCP** — `protocols/MCP/*` ([Hooks](./instrument/hooks.md#protocolsmcp)) MUST be implemented by deployments whose sessions involve MCP at any point (tools, resources, prompts, notifications), so that `protocols/MCP/resources/read` — the canonical prompt-injection ingress for attacker-controlled external content — is not bypassed. MCP `tools/call` MAY instead flow through the generic `steps/toolCallRequest` / `steps/toolCallResult` hooks when tool-level policy suffices; the requirement exists for the surfaces the generic hooks cannot see (capability negotiation, prompt fetches, resource reads, notifications). Support is declared in the handshake via `wrapped_protocols`. Deployments whose sessions never involve MCP MAY omit the namespace. **Known v0.1 limitation:** `wrapped_protocols` is declared once, at handshake, and v0.1 defines no renegotiation. A session that declared no MCP and then gains an MCP server mid-session (a mutation `agbom/changed` reports, and the `acs-inspect-dynamic` profile exists to surface) cannot begin wrapping mid-session: the generic tool hooks still govern that server's tool calls, the addition is visible through `agbom/changed`, and wrapped coverage begins at the next session's handshake. Renegotiation is v0.2 scope. diff --git a/docs/spec/instrument/hooks.md b/docs/spec/instrument/hooks.md index 9187081f..b7929fed 100644 --- a/docs/spec/instrument/hooks.md +++ b/docs/spec/instrument/hooks.md @@ -315,7 +315,7 @@ Schema: [`hooks/session-end.json`](https://genai-security-project.github.io/agen Session termination, audit finalization. The Guardian seals the chain at this point. -**Payload:** `session_reason` (`completed`, `cancelled`, `error`, `timeout`), final aggregates. +**Payload:** `reason` (`completed`, `cancelled`, `error`, `timeout`, `abandoned`), and an optional `summary` with the outcome, step count, errors, and `decision_failures`, the number of decision failures in each failure class (Specification §6.4). **Decision:** Audit only. diff --git a/docs/spec/instrument/specification.md b/docs/spec/instrument/specification.md index 7292a076..9b7881c0 100644 --- a/docs/spec/instrument/specification.md +++ b/docs/spec/instrument/specification.md @@ -99,7 +99,7 @@ sequenceDiagram `on_decision_failure` is negotiated in the handshake, so it cannot govern a handshake that never completes. When `handshake/hello` fails — the Guardian is unreachable, silent past the deployment's connection timeout, or errors without producing a ServerHello — the Observed Agent applies its **startup posture**, configured out of band: `proceed` starts the session unguarded, `refuse` does not start it. The default is `proceed`, matching `on_decision_failure`, so a Guardian outage does not block new sessions. -A Guardian that answers with a refusal (`SESSION_REFUSED`, `UNSUPPORTED_VERSION`, `PROVENANCE_REQUIRED`) has decided; the startup posture applies only when no answer arrives. A session started unguarded MUST be recorded in the deployment's own audit log — there is no Guardian to receive the event — and SHOULD be surfaced on Trace events when the deployment claims ACS-Trace. The Observed Agent SHOULD retry the handshake for subsequent sessions. Whether a Guardian can attach mid-flight to a session that started unguarded is undefined in v0.1; renegotiation is deferred to v0.2 alongside `system/handshake_renegotiate` (§13). +A Guardian that answers with a refusal (`SESSION_REFUSED`, `UNSUPPORTED_VERSION`, `PROVENANCE_REQUIRED`) has decided; the startup posture applies only when no answer arrives. A session started unguarded MUST be recorded in the deployment's own audit log — there is no Guardian to receive the event — and SHOULD be surfaced on Trace as an `acs.decision.failure` event with class `handshake_failure` on the session span when the deployment claims ACS-Trace. The Observed Agent SHOULD retry the handshake for subsequent sessions. Whether a Guardian can attach mid-flight to a session that started unguarded is undefined in v0.1; renegotiation is deferred to v0.2 alongside `system/handshake_renegotiate` (§13). ## 5. Hook Taxonomy @@ -199,7 +199,7 @@ A hook is a control point only if the Observed Agent waits for the verdict and a A step suffers a **decision failure** when no usable decision arrives within the negotiated timeout: the Guardian stays silent, the transport fails (connection refused, TLS failure, malformed response), or the Guardian returns an error instead of a decision. All three resolve the same way: the Observed Agent applies the deployment's failure posture, declared as `on_decision_failure` in the handshake (§4) and defaulting to `proceed` (fail-open) so that a slow, erroring, or unreachable Guardian does not halt production. A deployment MAY set `on_decision_failure: deny` (fail-closed). The negotiated timeout bounds every failure mode: an error from the §17.1 registry carries a recovery action the agent MAY attempt within the remaining budget, and an unambiguous failure (a refused connection) MAY resolve immediately rather than waiting out the clock. -Every step that proceeds without a decision MUST be recorded as an audit event, so the bypass is visible rather than silent. When a decision does arrive within the timeout, the agent MUST honor it regardless of the posture. Fail-open trades enforcement for availability under disruption: an adversary who can disrupt the channel converts control into audit. Deployments for which that trade is unacceptable set `on_decision_failure: deny`. +Every decision failure MUST be recorded as an audit event, under either posture. Under `proceed` the record makes the bypass visible rather than silent, and under `deny` it shows why the step was blocked. The Observed Agent records it in the deployment's own audit log, the rule §4.1 sets for a session started unguarded, and SHOULD also emit it as an `acs.decision.failure` event when the deployment claims ACS-Trace ([Trace Events](../trace/events.md)). At `sessionEnd`, the Observed Agent SHOULD report how many failures it recorded in each failure class, in `summary.decision_failures`. A reader treats a session that ends without that count as unmeasurable. When a decision does arrive within the timeout, the agent MUST honor it regardless of the posture. Fail-open trades enforcement for availability under disruption: an adversary who can disrupt the channel converts control into audit. Deployments for which that trade is unacceptable set `on_decision_failure: deny`. ### 6.5 MODIFY-incapable clients (normative) diff --git a/specification/v0.1.0/hooks/session-end.json b/specification/v0.1.0/hooks/session-end.json index c1b1a873..5cf532c7 100644 --- a/specification/v0.1.0/hooks/session-end.json +++ b/specification/v0.1.0/hooks/session-end.json @@ -19,6 +19,14 @@ "errors": { "type": "array", "items": { "type": "string" } + }, + "decision_failures": { + "type": "object", + "description": "Decision failures the Observed Agent recorded in this session, counted per failure class (Specification 6.4). The classes are the acs.failure.class values in trace/otel-mapping.json. A class with no failures MAY be omitted or set to 0. A reader treats a session that ends without this object as unmeasurable.", + "propertyNames": { + "enum": ["timeout", "unreachable", "malformed_response", "guardian_error", "observed_agent_error", "signature_invalid", "handshake_failure", "other"] + }, + "additionalProperties": { "type": "integer", "minimum": 0 } } } }, diff --git a/tests/test_decision_failure_classes.py b/tests/test_decision_failure_classes.py new file mode 100644 index 00000000..5eb0bd87 --- /dev/null +++ b/tests/test_decision_failure_classes.py @@ -0,0 +1,28 @@ +"""The sessionEnd failure count and the Trace failure event name the same classes. + +`summary.decision_failures` in hooks/session-end.json counts decision failures per +class, and `acs.failure.class` on the `acs.decision.failure` event names the class of +each one. A class added to one list and not the other would leave a failure that one +side can record and the other cannot count. +""" + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SESSION_END = ROOT / "specification" / "v0.1.0" / "hooks" / "session-end.json" +OTEL_MAPPING = ROOT / "specification" / "v0.1.0" / "trace" / "otel-mapping.json" + + +def test_session_end_counts_exactly_the_trace_failure_classes(): + session_end = json.loads(SESSION_END.read_text(encoding="utf-8")) + counted = session_end["properties"]["summary"]["properties"]["decision_failures"]["propertyNames"]["enum"] + + mapping = json.loads(OTEL_MAPPING.read_text(encoding="utf-8")) + failure_event = mapping["properties"].get("decision_failure_event") + assert failure_event is not None, "otel-mapping.json defines no decision_failure_event" + emitted = failure_event["properties"]["attribute_values"]["default"]["acs.failure.class"] + + assert sorted(counted) == sorted(emitted), ( + f"session-end.json counts {sorted(counted)}; acs.failure.class takes {sorted(emitted)}" + ) From dffca95c39310ef21a2259f3c701f47e4e1e3234 Mon Sep 17 00:00:00 2001 From: Bar Kaduri Date: Sat, 17 Oct 2026 23:24:20 +0300 Subject: [PATCH 2/3] Keep rejected requests off a genuine request's pair, and give handshake_failure its posture A Guardian emits no acs.decision.failure or acs.decision event under the pair of a request it rejects before trusting it: an envelope that fails validation, a failed signature, a replay, or a timestamp outside the window. Such a request can carry a genuine request's pair. handshake_failure now covers the refuse startup posture too, and its acs.failure.posture comes from the startup posture, since on_decision_failure is never negotiated. Section 4.1 records an unguarded session with posture fail_open. Signed-off-by: Bar Kaduri --- docs/spec/instrument/specification.md | 2 +- docs/spec/trace/events.md | 2 +- specification/v0.1.0/trace/otel-mapping.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/spec/instrument/specification.md b/docs/spec/instrument/specification.md index 9b7881c0..c4f9ebd7 100644 --- a/docs/spec/instrument/specification.md +++ b/docs/spec/instrument/specification.md @@ -99,7 +99,7 @@ sequenceDiagram `on_decision_failure` is negotiated in the handshake, so it cannot govern a handshake that never completes. When `handshake/hello` fails — the Guardian is unreachable, silent past the deployment's connection timeout, or errors without producing a ServerHello — the Observed Agent applies its **startup posture**, configured out of band: `proceed` starts the session unguarded, `refuse` does not start it. The default is `proceed`, matching `on_decision_failure`, so a Guardian outage does not block new sessions. -A Guardian that answers with a refusal (`SESSION_REFUSED`, `UNSUPPORTED_VERSION`, `PROVENANCE_REQUIRED`) has decided; the startup posture applies only when no answer arrives. A session started unguarded MUST be recorded in the deployment's own audit log — there is no Guardian to receive the event — and SHOULD be surfaced on Trace as an `acs.decision.failure` event with class `handshake_failure` on the session span when the deployment claims ACS-Trace. The Observed Agent SHOULD retry the handshake for subsequent sessions. Whether a Guardian can attach mid-flight to a session that started unguarded is undefined in v0.1; renegotiation is deferred to v0.2 alongside `system/handshake_renegotiate` (§13). +A Guardian that answers with a refusal (`SESSION_REFUSED`, `UNSUPPORTED_VERSION`, `PROVENANCE_REQUIRED`) has decided; the startup posture applies only when no answer arrives. A session started unguarded MUST be recorded in the deployment's own audit log — there is no Guardian to receive the event — and SHOULD be surfaced on Trace as an `acs.decision.failure` event with class `handshake_failure` and posture `fail_open` on the session span when the deployment claims ACS-Trace. The Observed Agent SHOULD retry the handshake for subsequent sessions. Whether a Guardian can attach mid-flight to a session that started unguarded is undefined in v0.1; renegotiation is deferred to v0.2 alongside `system/handshake_renegotiate` (§13). ## 5. Hook Taxonomy diff --git a/docs/spec/trace/events.md b/docs/spec/trace/events.md index 25d271c4..c23d6440 100644 --- a/docs/spec/trace/events.md +++ b/docs/spec/trace/events.md @@ -54,7 +54,7 @@ An `acs.decision` event records the Guardian's verdict. Only the Observed Agent A decision failure gets its own event because it has no disposition and no evaluator. It goes on the step span, except `handshake_failure`, which goes on the `acs.session` span: the session started without a completed handshake, so none of its steps was governed. `signature_invalid` is a response whose signature failed verification, kept apart from `malformed_response` so a tampered response does not blend into organic failures. The Observed Agent MUST record every decision failure, fail-open or fail-closed, in the deployment's own audit log. It SHOULD emit `acs.decision.failure` when it claims ACS-Trace. A Guardian claiming ACS-Trace SHOULD emit the event for the failures it sees itself: an error it returned (`guardian_error`) and a decision it finished after the negotiated timeout (`timeout`). The mapping lists every class and posture value. -In v0.1 the Observed Agent reports this event about its own conduct. A missing event does not show that no failure happened, and a count built from these events is a lower bound. Events from both parties for one request describe one failure, so a reader counts distinct `acs.session.id` and `acs.request_id` pairs. That counts failed attempts, not steps: a step the Observed Agent recovered by retrying still shows its failed attempts. A pair with a Guardian event and no Observed Agent event still counts. A Guardian MUST NOT emit the event for a request it rejects as a replay (`REPLAY_DETECTED`), because that pair belongs to the earlier request. `acs.failure.detail` MUST be text the emitter composes itself, and MUST NOT copy bytes from the request, the response, or an error's `data`. +In v0.1 the Observed Agent reports this event about its own conduct. A missing event does not show that no failure happened, and a count built from these events is a lower bound. Events from both parties for one request describe one failure, so a reader counts distinct `acs.session.id` and `acs.request_id` pairs. That counts failed attempts, not steps: a step the Observed Agent recovered by retrying still shows its failed attempts. A pair with a Guardian event and no Observed Agent event still counts. A Guardian MUST NOT emit the event or an `acs.decision` event under the pair of a request it rejects before trusting it: an envelope that fails validation, a signature that fails verification (`SIGNATURE_INVALID`), a replay (`REPLAY_DETECTED`), or a timestamp outside the window (`TIMESTAMP_OUT_OF_WINDOW`). Such a request can carry any pair, including a genuine request's. A deployment that records rejected requests does so under attribute names other than `acs.session.id` and `acs.request_id`. `acs.failure.detail` MUST be text the emitter composes itself, and MUST NOT copy bytes from the request, the response, or an error's `data`. Some unusable responses are not decision failures, because the spec already defines their outcome. A response whose `modifications` break §6.3 gets `DENY`, whether or not schema validation also rejects it. A `MODIFY` the client cannot apply gets `DENY`, or at `postCompact` the original summary (§6.5). `malformed_response` covers a response that does not parse, or that fails schema validation for any other reason. diff --git a/specification/v0.1.0/trace/otel-mapping.json b/specification/v0.1.0/trace/otel-mapping.json index aa76ebed..f513fe6a 100644 --- a/specification/v0.1.0/trace/otel-mapping.json +++ b/specification/v0.1.0/trace/otel-mapping.json @@ -174,7 +174,7 @@ }, "decision_failure_event": { "type": "object", - "description": "A decision failure is recorded as a span event named 'acs.decision.failure' on the step span, separate from acs.decision, or on the acs.session span for handshake_failure. It records a step whose decision did not arrive or was not usable, and the deployment's failure posture. The Observed Agent MUST record every decision failure, fail-open or fail-closed, in the deployment's own audit log, and SHOULD emit this event when it claims ACS-Trace. A Guardian claiming ACS-Trace SHOULD emit it for the failures it sees itself: an error it returned (guardian_error) and a decision it finished after the negotiated timeout (timeout). In v0.1 the Observed Agent reports this event about its own conduct, so a missing event does not show that no failure happened, and a count built from these events is a lower bound. acs.failure.reported_by, acs.failure.posture and acs.failure.payload_bytes are the emitter's own claims. Events from both parties for one request describe one failure, so a reader counts distinct acs.session.id and acs.request_id pairs. That counts failed attempts, not steps: a step the Observed Agent recovered by retrying still shows its failed attempts. A pair with a Guardian event and no Observed Agent event is still counted. A Guardian MUST NOT emit this event for a request it rejects as a replay (REPLAY_DETECTED), because that pair belongs to the earlier request. A reader cross-checking the two sides takes them from separately produced streams. acs.failure.detail is required when acs.failure.class is other. It MUST be text the emitter composes itself, and MUST NOT copy bytes from the request, the response, or an error's data, including parser messages that quote their input. acs.failure.payload_bytes SHOULD be emitted, because when reported honestly it helps separate an induced failure from an organic one.", + "description": "A decision failure is recorded as a span event named 'acs.decision.failure' on the step span, separate from acs.decision, or on the acs.session span for handshake_failure. It records a step whose decision did not arrive or was not usable, and the deployment's failure posture. The Observed Agent MUST record every decision failure, fail-open or fail-closed, in the deployment's own audit log, and SHOULD emit this event when it claims ACS-Trace. A Guardian claiming ACS-Trace SHOULD emit it for the failures it sees itself: an error it returned (guardian_error) and a decision it finished after the negotiated timeout (timeout). In v0.1 the Observed Agent reports this event about its own conduct, so a missing event does not show that no failure happened, and a count built from these events is a lower bound. acs.failure.reported_by, acs.failure.posture and acs.failure.payload_bytes are the emitter's own claims. Events from both parties for one request describe one failure, so a reader counts distinct acs.session.id and acs.request_id pairs. That counts failed attempts, not steps: a step the Observed Agent recovered by retrying still shows its failed attempts. A pair with a Guardian event and no Observed Agent event is still counted. A Guardian MUST NOT emit this event or an acs.decision event under the pair of a request it rejects before trusting it: an envelope that fails validation, a signature that fails verification (SIGNATURE_INVALID), a replay (REPLAY_DETECTED), or a timestamp outside the window (TIMESTAMP_OUT_OF_WINDOW). Such a request can carry any pair, including a genuine request's. A deployment that records rejected requests does so under attribute names other than acs.session.id and acs.request_id. A reader cross-checking the two sides takes them from separately produced streams. acs.failure.detail is required when acs.failure.class is other. It MUST be text the emitter composes itself, and MUST NOT copy bytes from the request, the response, or an error's data, including parser messages that quote their input. acs.failure.payload_bytes SHOULD be emitted, because when reported honestly it helps separate an induced failure from an organic one.", "properties": { "event_name": { "type": "string", "const": "acs.decision.failure" }, "required_attributes": { @@ -189,7 +189,7 @@ }, "attribute_values": { "type": "object", - "description": "The values each enumerated attribute takes. timeout: the Guardian was reached and no decision arrived in time. unreachable: the request never reached the Guardian. malformed_response: an answer arrived and was not usable, for any reason other than its signature. signature_invalid: an answer arrived whose signature failed verification (Specification §10), kept apart so a tampered response does not blend into organic failures. handshake_failure: the session started without a completed handshake (Specification §4.1), so no step in it was governed; it is recorded on the acs.session span and counted under the handshake's own request_id and session_id. guardian_error: an error came back instead of a decision. observed_agent_error: the Observed Agent's own side failed before a decision could arrive. other: a failure that fits none of these. acs.failure.posture is fail_open for on_decision_failure proceed and fail_closed for deny. A response whose modifications break Specification §6.3, whether or not schema validation also rejects it, is not a decision failure, because §6.3 requires the Observed Agent to apply DENY. A MODIFY the client cannot apply is not a decision failure either, because §6.5 defines its outcome: DENY, or at postCompact the original summary. malformed_response covers a response that does not parse, or that fails schema validation for any other reason.", + "description": "The values each enumerated attribute takes. timeout: the Guardian was reached and no decision arrived in time. unreachable: the request never reached the Guardian. malformed_response: an answer arrived and was not usable, for any reason other than its signature. signature_invalid: an answer arrived whose signature failed verification (Specification §10), kept apart so a tampered response does not blend into organic failures. handshake_failure: the handshake did not complete (Specification §4.1). Under the startup posture proceed the session ran with no step governed, and under refuse it did not start. It is recorded on the acs.session span and counted under the handshake's own request_id and session_id. guardian_error: an error came back instead of a decision. observed_agent_error: the Observed Agent's own side failed before a decision could arrive. other: a failure that fits none of these. acs.failure.posture is fail_open for on_decision_failure proceed and fail_closed for deny. A handshake_failure comes before on_decision_failure is negotiated, so its posture reflects the startup posture: fail_open for proceed and fail_closed for refuse. A response whose modifications break Specification §6.3, whether or not schema validation also rejects it, is not a decision failure, because §6.3 requires the Observed Agent to apply DENY. A MODIFY the client cannot apply is not a decision failure either, because §6.5 defines its outcome: DENY, or at postCompact the original summary. malformed_response covers a response that does not parse, or that fails schema validation for any other reason.", "additionalProperties": { "type": "array", "items": { "type": "string" } }, "default": { "acs.failure.class": ["timeout", "unreachable", "malformed_response", "guardian_error", "observed_agent_error", "signature_invalid", "handshake_failure", "other"], From 6e1ec9e4d943c9ba540c3c9c73f8c17a4605df31 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Thu, 22 Oct 2026 09:52:07 -0600 Subject: [PATCH 3/3] Say every decision failure is recorded in handshake.json and the identity overview Both still said only a fail-open proceed is recorded, which section 6.4 no longer says after this PR. They now match: every decision failure is recorded, fail-open or fail-closed. Signed-off-by: rocklambros --- docs/identity/overview.md | 2 +- specification/v0.1.0/handshake.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/identity/overview.md b/docs/identity/overview.md index a95ba772..38f6e864 100644 --- a/docs/identity/overview.md +++ b/docs/identity/overview.md @@ -121,7 +121,7 @@ A concrete example that illustrates several of these challenges is Palo Alto Net | 1 | **Chain Integrity**
*Can the full delegation chain be named and verified end-to-end?* | A single agent task can chain through five to ten identities: user → orchestrator → sub-agent → tool → downstream API. Two failures compound: the chain is not *named* (audit collapses into "the agent did it"), and the chain is not *verifiable* (downstream resources cannot confirm the chain without trusting every intermediary). In practice the full chain is often not reconstructable end-to-end, and there is a known class of attacks where the chain itself can be spliced. | **[OAuth 2.0](https://www.rfc-editor.org/rfc/rfc6749) access tokens** identify the bearer, not the chain.
**[OIDC Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) ID tokens** name the human at the start but lose granularity at each hop.
**[SPIFFE SVIDs](https://spiffe.io/docs/latest/spiffe-about/spiffe-concepts/)** identify the workload but not which call within it.
**[OAuth Token Exchange (RFC 8693)](https://www.rfc-editor.org/rfc/rfc8693)** defines nested `act` claims that can express a chain, but per [§4.1](https://www.rfc-editor.org/rfc/rfc8693#section-4.1) *"prior actors identified by any nested `act` claims are informational only and are not to be considered in access control decisions."* The chain is recorded, not normatively enforceable.
**[OAuth Identity Chaining draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)** and **[Transaction Tokens draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-transaction-tokens/)** address parts but specify neither chain verification rules nor revocation propagation as required runtime behavior.
**[W3C Trace Context](https://www.w3.org/TR/trace-context/)** correlates but [does not authenticate](https://www.w3.org/TR/trace-context/#privacy-of-traceparent-field). | Every action must produce, at the moment of execution, a signed record that names the specific link in the chain and lets any participant (including the downstream resource) verify the entire chain end-to-end without trusting any intermediary. Cryptographic chain structure, mandatory scope attenuation at every hop, revocation propagation. | Pending | | 2 | **Over-Privilege**
*Was this action within the agent's purpose?* | Agents inherit broad standing privileges. Service accounts. Long-lived API keys. OAuth scopes designed for human applications. One compromised agent has the union of every permission anyone has ever granted to it. The fix is not bigger scopes; the fix is no standing privilege. | **[OAuth scopes (RFC 6749 §3.3)](https://www.rfc-editor.org/rfc/rfc6749#section-3.3)** are coarse, deployment-defined strings (e.g. `read`, `write`, `repo`). Per **[RFC 9396 §1](https://www.rfc-editor.org/rfc/rfc9396#section-1)**, scope is "sufficient... for coarse-grained authorization requests" but "not sufficient to specify fine-grained authorization requirements" — it does not express per-action, per-resource, per-data-cell intent.
**RBAC and ABAC** scope by role or attribute, granted once, evaluated coarsely.
**AIMS** does not require **[RAR (RFC 9396)](https://www.rfc-editor.org/rfc/rfc9396)** and treats scope semantics as deployment-specific. | Credentials minted just before the call, scoped to one downstream callee, structured (per-action, per-resource), expired in minutes. Authorization evaluated per-call against the immutable intent for the run, not per-session. | Pending | | 3 | **Token Theft Resistance**
*If a token leaks from agent memory, is it useless to the attacker?* | The agent threat model breaks OAuth's bearer-token assumption entirely. Prompt injection can exfiltrate tokens from memory. Tool outputs can leak tokens. Sub-agents can replay tokens. Logs and crash dumps capture tokens. Long-lived refresh tokens in agent memory are the worst case: a durable credential held in storage that any indirect prompt injection can read. | **[OAuth 2.0 Bearer Tokens (RFC 6750 §1.2)](https://www.rfc-editor.org/rfc/rfc6750#section-1.2)** explicitly treat possession as sufficient: *"Using a bearer token does not require a bearer to prove possession of cryptographic key material."*
**API keys** are bearer secrets by definition.
**[OAuth refresh tokens (RFC 6749 §1.5)](https://www.rfc-editor.org/rfc/rfc6749#section-1.5)** were designed for the same user returning later to resume a session, which does not describe how agents work.
**[DPoP (RFC 9449)](https://www.rfc-editor.org/rfc/rfc9449)** and **[OAuth 2.0 Mutual-TLS Certificate-Bound Access Tokens (RFC 8705)](https://www.rfc-editor.org/rfc/rfc8705)** solve sender-constraint but are optional extensions that most deployments do not enforce.
None of these are required by the OAuth core. | All non-trivial tokens sender-constrained (DPoP or mTLS) so a leaked token is useless without the corresponding private key. Aggressive lifetime ceilings measured in minutes or seconds, not hours. No long-lived secrets in model-visible context. Credentials fetched at task start, used within a tight window, discarded at task end. | Pending | -| 4 | **Last-Mile Enforcement**
*Did the resource actually enforce the decision, on every call, inside the runtime?* | The moment the action reaches a production system. All upstream identity work either holds here or fails here. Most systems today cannot distinguish an authorized action by an agent acting in good faith from the same call by an agent hijacked by indirect prompt injection. Gateway-only enforcement misses tool calls and sub-agent invocations that never cross a gateway. Cached decisions miss revocation, scope change, and freshly-discovered taint. | **API gateways and service meshes** enforce at the edge; agent tool calls never reach them.
**[OAuth 2.0 resource servers (RFC 6749 §1.1)](https://www.rfc-editor.org/rfc/rfc6749#section-1.1)** validate the token but not the action's relationship to intent.
**[mTLS (RFC 8705)](https://www.rfc-editor.org/rfc/rfc8705)** and **[SPIFFE](https://spiffe.io/docs/latest/spiffe-about/spiffe-concepts/)** establish workload identity but do not enforce per-action policy.
None fire inside the agent runtime where the decision is made. | Enforcement fires at every tool call, inside the agent runtime, before the action executes. The runtime verifies identity, scope, taint, freshness, and intent on every call. The LLM call cost dwarfs the re-authorization cost, so caching decisions is not required for performance. Default-deny verified by a negative test in CI. | **Partially specified.** The ACS Instrument spec already defines the honoring-decisions contract in [§6.4 (Honoring Decisions, Normative)](../spec/instrument/specification.md#64-honoring-decisions-normative): the Observed Agent MUST wait for the Guardian's decision up to the negotiated timeout, MUST apply it, and MUST record every fail-open proceed as an audit event. Note the spec's current default on timeout is **fail-open with audit**, which is in tension with this row's default-deny / no-cache goal; whether identity-tier actions should tighten that to fail-closed is an open work item for this workstream. | +| 4 | **Last-Mile Enforcement**
*Did the resource actually enforce the decision, on every call, inside the runtime?* | The moment the action reaches a production system. All upstream identity work either holds here or fails here. Most systems today cannot distinguish an authorized action by an agent acting in good faith from the same call by an agent hijacked by indirect prompt injection. Gateway-only enforcement misses tool calls and sub-agent invocations that never cross a gateway. Cached decisions miss revocation, scope change, and freshly-discovered taint. | **API gateways and service meshes** enforce at the edge; agent tool calls never reach them.
**[OAuth 2.0 resource servers (RFC 6749 §1.1)](https://www.rfc-editor.org/rfc/rfc6749#section-1.1)** validate the token but not the action's relationship to intent.
**[mTLS (RFC 8705)](https://www.rfc-editor.org/rfc/rfc8705)** and **[SPIFFE](https://spiffe.io/docs/latest/spiffe-about/spiffe-concepts/)** establish workload identity but do not enforce per-action policy.
None fire inside the agent runtime where the decision is made. | Enforcement fires at every tool call, inside the agent runtime, before the action executes. The runtime verifies identity, scope, taint, freshness, and intent on every call. The LLM call cost dwarfs the re-authorization cost, so caching decisions is not required for performance. Default-deny verified by a negative test in CI. | **Partially specified.** The ACS Instrument spec already defines the honoring-decisions contract in [§6.4 (Honoring Decisions, Normative)](../spec/instrument/specification.md#64-honoring-decisions-normative): the Observed Agent MUST wait for the Guardian's decision up to the negotiated timeout, MUST apply it, and MUST record every decision failure as an audit event. Note the spec's current default on timeout is **fail-open with audit**, which is in tension with this row's default-deny / no-cache goal; whether identity-tier actions should tighten that to fail-closed is an open work item for this workstream. | | 5 | **Proof of Intent**
*Was the action bound to an immutable, declared purpose that survives mid-task reframing?* | OAuth scopes say what the user *can* do. They do not say what the user *is doing right now*. Without intent binding at runtime, every agent action is a guess about user authorization. The agent's reasoning can be reframed mid-task by an instruction hidden in a document or a tool output. The original intent has to survive that. This is the hardest of the five, because it is the only one OAuth cannot patch with an extension: the underlying problem is non-deterministic client behavior under adversarial inputs. | **None at the identity layer.** Intent is implicit in the OAuth consent screen and discarded after token issuance.
**[RFC 9396 RAR](https://www.rfc-editor.org/rfc/rfc9396)** can carry purpose claims as `authorization_details` data but does not bind them as runtime invariants against the LLM client.
**IBAC (Intent-Based Access Control)** is research-stage with no IETF/W3C standard track.
No production standard treats intent as a first-class, immutable, normatively-enforced runtime invariant. | Intent treated as a first-class governance concept with a normative immutability invariant. Intent must flow across all three ACS pillars: Instrument fires decisions against it, Trace records it as a span attribute, Inspect catalogs what it authorized. Intent extension permitted only through an explicit, human-approved path. | Pending | --- diff --git a/specification/v0.1.0/handshake.json b/specification/v0.1.0/handshake.json index 819a1e53..c901f94a 100644 --- a/specification/v0.1.0/handshake.json +++ b/specification/v0.1.0/handshake.json @@ -128,7 +128,7 @@ "type": "string", "enum": ["proceed", "deny"], "default": "proceed", - "description": "Posture the Observed Agent applies on a decision failure: no usable decision within the negotiated timeout, whether the Guardian is silent, the transport fails, or an error response carries no decision (Specification 6.4). 'proceed' (default) is fail-open, so a slow, erroring, or unreachable Guardian does not halt production; 'deny' is fail-closed. Every fail-open proceed MUST be recorded as an audit event (Specification 6.4). A decision that arrives within the timeout MUST be honored regardless of this posture." + "description": "Posture the Observed Agent applies on a decision failure: no usable decision within the negotiated timeout, whether the Guardian is silent, the transport fails, or an error response carries no decision (Specification 6.4). 'proceed' (default) is fail-open, so a slow, erroring, or unreachable Guardian does not halt production; 'deny' is fail-closed. Every decision failure MUST be recorded as an audit event, fail-open or fail-closed (Specification 6.4). A decision that arrives within the timeout MUST be honored regardless of this posture." }, "approver_types_supported": { "type": "array",