Skip to content
Open
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
16 changes: 8 additions & 8 deletions docs/spec/trace/extend_opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool

```json
{
"traceId": "85c4878c9bda9e5b4f2fc6aae242d31f",
"spanId": "dea6530ad1681c0e",
"traceId": "13717f291e2368066d2fa3f2434ed2f6",
"spanId": "2a7efedbb7ee3881",
"name": "gen_ai.tool.call",
"kind": 1,
"startTimeUnixNano": "1791928279698000000",
"endTimeUnixNano": "1791928279749301083",
"startTimeUnixNano": "1792534253716796645",
"endTimeUnixNano": "1792534253780000000",
"attributes": [
{
"key": "acs.session.id",
Expand Down Expand Up @@ -146,7 +146,7 @@ The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool
}
],
"name": "acs.decision",
"timeUnixNano": "1791928279749295500",
"timeUnixNano": "1792534253780000000",
"droppedAttributesCount": 0
}
],
Expand All @@ -168,7 +168,7 @@ The span above is real output from the reference Guardian. This section explains

### Two streams, joined by a reader

No single process sees every outcome of a step. The Guardian sees each request it receives and the answer it gives. It exports one span per decided step, with the `acs.decision` event on it. It also reports the failures it can see itself: an error it returned instead of a decision, a decision it finished after the negotiated timeout, and a step still open when it shut down.
No single process sees every outcome of a step. The Guardian sees each request it receives and the answer it gives. It exports one span per step it trusted, with the `acs.decision` event on it, and records a request it rejected before trusting it apart, under names other than `acs.session.id` and `acs.request_id`. It also reports the failures it can see itself: an error it returned instead of a decision because it could not handle the step, a decision it finished after the negotiated timeout, and a step still open when it shut down.

The host sees what the Guardian can't. An unreachable Guardian has nothing to record. A Guardian whose answer arrives after the host stopped waiting can't see what the host did in the meantime. The host adapter records both cases in its own audit log. A converter, `audit-to-otlp.ts`, turns that log into spans of the same OTLP/JSON shape, each with an `acs.decision.failure` event whose `acs.failure.reported_by` is `observed_agent`.

Expand Down Expand Up @@ -224,7 +224,7 @@ sequenceDiagram

A late decision gets both events on one span: the `acs.decision` with the verdict, then an `acs.decision.failure` with class `timeout`. When either party's stream holds a failure event for a pair, a reader doesn't count that pair's `acs.decision` as enforced. [Trace Events](./events.md) states that rule.

The Guardian never copies its error text into the trace. A failure event carries the error code, the timeout, the elapsed time and the request size when they're known, because error text can quote the request. A replay rejection gets no failure event, since its pair belongs to the earlier request.
The Guardian never copies its error text into the trace. A failure event carries the error code, the timeout, the elapsed time and the request size when they're known, because error text can quote the request. A request the Guardian rejected before trusting it, by a failed validation, signature, replay or timestamp check, gets no event under the pair it carried, since that pair can be a genuine request's. The reference Guardian records it as a separate span under its own names instead.

If you only need to read a trace, you can stop here. The rest of this section is for running the pipeline or building your own.

Expand All @@ -238,7 +238,7 @@ The collector path, set with `ACS_OTEL_ENDPOINT`, sends the same spans over OTLP

The two Guardian outputs fail independently. The first failed write or send disables that output for the rest of the process and prints one line on stderr. The other output keeps running, and the decision goes out unchanged, as the [failure isolation](#failure-isolation) rule below requires. The [Guardian README](https://github.com/GenAI-Security-Project/agent-control-standard/blob/main/reference-implementations/agt/packages/guardian/README.md#troubleshooting) lists the messages you may see.

The committed [sample trace](https://github.com/GenAI-Security-Project/agent-control-standard/blob/main/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl) holds both streams in one file: five spans from the Guardian and one from the converter. Every value in it is synthetic.
The committed [sample trace](https://github.com/GenAI-Security-Project/agent-control-standard/blob/main/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl) holds both streams in one file: five spans from the Guardian, one of them a rejected request, and one from the converter. Every value in it is synthetic.

### What the labels don't prove

Expand Down
1 change: 1 addition & 0 deletions reference-implementations/agt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,7 @@ These are known limits of the tree as it stands. None is fixed.
- **Memory and disk grow without bound.** The Guardian's session store never evicts a session. The envelope log, the audit log and the session-context log have no rotation and no size cap. A long-lived Guardian grows until something else stops it.
- **Log writes sit on the decision path.** The Guardian writes two synchronous file appends per request in a single-threaded server. A slow disk blocks every in-flight decision.
- **The wire is unauthenticated.** See [What this project is, and is not](#what-this-project-is-and-is-not). Loopback by default is the only protection.
- **A well-formed forgery is trusted.** The Guardian checks a request against the schema and nothing else, so a forged or replayed request that passes the schema is decided, recorded on the trace under the ids it carries, and appended to that session's hash chain. Signature, replay and timestamp checks arrive with PR #229. Until then, a trace's request pairs, and the chain, are only as trustworthy as access to the Guardian's port.
- **The egress gate has three measured soft spots.** A shell command whose destination the extractor cannot parse falls through to `allow`, not `deny`. With the shipped allowlist, any command whose text contains an off-list URL is denied, whether or not it reaches that URL. A `WebFetch` URL reaches AGT's gate with no parsing, so four userinfo-style URL shapes pass on the fetch route and fail on the shell route. Closing the last one means editing a `.rego` file, which this project does not do.
- **An `ask` never carries `ask_details`.** The Guardian's decision type has no such field. Every `ask` fails the response schema. A `defer` would fail the same way if any AGT verdict mapped to it.
- **The handshake is not negotiated.** The Guardian returns constants and never reads the ClientHello. Neither side declares ACS profiles.
Expand Down
12 changes: 8 additions & 4 deletions reference-implementations/agt/packages/guardian/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,21 @@ Set `ACS_OTEL_FILE`, `ACS_OTEL_ENDPOINT`, or both. With neither set, no exporter
ACS_OTEL_FILE=.acs/otel-traces.jsonl bun run guardian
```

Each decided step becomes one span, named by [`trace/otel-mapping.json`](../../../../specification/v0.1.0/trace/otel-mapping.json): `gen_ai.tool.call` for `steps/toolCallRequest` and `gen_ai.tool.result` for `steps/toolCallResult`. The span opens when the request is recorded and closes when the response is. It carries each attribute the mapping lists for it when the envelope supplies a value. An attribute the envelope omits, such as `acs.capability`, is absent from the span.
Each step the Guardian trusted becomes one span, named by [`trace/otel-mapping.json`](../../../../specification/v0.1.0/trace/otel-mapping.json): `gen_ai.tool.call` for `steps/toolCallRequest` and `gen_ai.tool.result` for `steps/toolCallResult`. The span opens when the request is recorded and closes when the response is. It carries each attribute the mapping lists for it when the envelope supplies a value. An attribute the envelope omits, such as `acs.capability`, is absent from the span.

Every span also carries `acs.session.id`, and `acs.request_id` whenever the envelope has one, so a session can be rebuilt from the trace alone. When an argument or output carries a Provenance object, the span carries `acs.provenance.origin` as an array, one element per Provenance object in wire order. `acs.provenance.source_id` is the matching array, with `""` where an object has no `source_id`, and it is left out when no object has one. Both stop at 64 objects. `acs.provenance.lineage_depth` is not emitted: `provenance.json` defines no such field.

Every string attribute is cut at 4096 characters, a long `acs.reasoning` included, so one large request cannot grow the export queue without bound. The sampler and span limits are set in code, so `OTEL_TRACES_*`, `OTEL_SPAN_*`, `OTEL_ATTRIBUTE_*` and `OTEL_BSP_*` variables in the environment do not change what is exported, and the endpoint output is never compressed. `OTEL_EXPORTER_OTLP_*HEADERS` still add headers to the endpoint's requests, and a loopback endpoint is added to `NO_PROXY` so an `HTTP_PROXY` cannot route it.

The decision rides on the span as an `acs.decision` event. The event always carries `acs.decision`, carries `acs.reasoning` when the response has one, and carries `acs.reason_codes`, `acs.policy.ids`, `acs.policy.versions`, `acs.policy.rule_ids`, `acs.cited_provenance_ids` and `acs.chain_hash` only when the response carries them. `acs.evaluator` appears when the Guardian's response carries `metadata.evaluator`. This Guardian sends it once PR #229 is merged.

This Guardian does not send `chain_hash`, `cited_provenance_ids`, or `policy_version` on its responses yet. `acs.chain_hash` and `acs.cited_provenance_ids` are absent from its spans, and `acs.policy.versions` carries `""` for each policy reference. The three policy arrays always have the same length as `policy_references`, with `""` where a reference has no value. `handshake/hello`, methods this Guardian does not dispatch, and unparseable bodies produce no span.
This Guardian does not send `chain_hash`, `cited_provenance_ids`, or `policy_version` on its responses yet. `acs.chain_hash` and `acs.cited_provenance_ids` are absent from its spans, and `acs.policy.versions` carries `""` for each policy reference. The three policy arrays always have the same length as `policy_references`, with `""` where a reference has no value. A valid `handshake/hello`, a valid request for a method this Guardian does not dispatch, and an unparseable body produce no span.

A step that got no decision is recorded as an `acs.decision.failure` event on the same span, with `acs.failure.class`, `acs.failure.posture` and `acs.failure.reported_by`, plus the error code, the timeout, the elapsed time and the request size when known. The Guardian emits it for an error it answered instead of a decision, for a decision it finished after the negotiated timeout, and for a step still open when it shut down. It carries the error code but never the error text, because that text can quote the request. A replay rejection (`-32005`) gets no event, because its pair belongs to the earlier request. The converter below produces the same event, reported by the observed agent, from the host adapter's audit log.
A step that got no decision is recorded as an `acs.decision.failure` event on the same span, with `acs.failure.class`, `acs.failure.posture` and `acs.failure.reported_by`, plus the error code, the timeout, the elapsed time and the request size when known. The Guardian emits it for an error it answered instead of a decision, for a decision it finished after the negotiated timeout, and for a step still open when it shut down. It carries the error code but never the error text, because that text can quote the request. The converter below produces the same event, reported by the observed agent, from the host adapter's audit log.

A request the Guardian cannot vouch for is recorded apart from the steps it governed, as a `guardian.rejected_request` span. Such a request can carry any session and request id, a genuine request's included. So the span carries no `acs.session.id`, `acs.request_id`, step attribute or `acs.decision` event. It records the ids the request claimed as `guardian.rejected.claimed_session_id` and `guardian.rejected.claimed_request_id`, with `guardian.rejected.reason` and, for an error answer, `guardian.rejected.error_code`. These names belong to this Guardian, not to the ACS mapping.

The reasons are `envelope_invalid`, for an envelope that fails validation, and `signature_invalid`, `replay_detected` or `timestamp_out_of_window`, for a step answered with that error by whichever check produced it. The last is `guardian_fault`, for a request the Guardian could not check at all, a missing schema directory for example. The host got an error for that one, so its span also carries an `acs.decision.failure` with class `guardian_error`. This Guardian verifies no signature, replay or timestamp yet (PR #229), so a forged or replayed request that passes the schema is still recorded as a step.

The file holds one OTLP `ExportTraceServiceRequest` per line, the shape the Collector's file exporter writes, with `service.name` set to `acs-reference-guardian` (the converter below uses `acs-reference-audit-converter`, and both are labels, not attribution). Spans are batched and flushed when the Guardian closes on SIGINT or SIGTERM. A SIGKILL loses the last batch, and so does an output that has not finished three seconds into shutdown: the Guardian reports it on stderr and exits instead of waiting. [`otel-collector.yaml`](../../otel-collector.yaml) at the root of this tree receives the endpoint output and writes the same file shape.

Expand Down Expand Up @@ -116,7 +120,7 @@ It prints how many spans reached the file and exits non-zero when that is fewer

### Sample trace

[`samples/trace/acs-trace-sample.jsonl`](../../samples/trace/acs-trace-sample.jsonl) is what this export looks like, so you can read it without running the Guardian. It holds six steps: an allowed tool call and its result, linked by `acs.request_id_ref`, a denied call carrying reasoning, reason codes, policy references and provenance, a call the Guardian answered with an error, a decision that finished after the timeout, and a step the host could not deliver, converted from its audit log. The decisions come from the stock policy, and the one late decision is delayed on purpose. Every value in it is synthetic.
[`samples/trace/acs-trace-sample.jsonl`](../../samples/trace/acs-trace-sample.jsonl) is what this export looks like, so you can read it without running the Guardian. It holds five steps and one rejected request: an allowed tool call and its result, linked by `acs.request_id_ref`, a denied call carrying reasoning, reason codes, policy references and provenance, a forged request that reuses the allowed call's ids and fails the schema, recorded as a `guardian.rejected_request` span so the allowed call keeps the only span under its pair, a decision that finished after the timeout, and a step the host could not deliver, converted from its audit log. The decisions come from the stock policy, and the one late decision is delayed on purpose. Every value in it is synthetic.

```bash
bun run sample:trace
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ const PROVENANCE_COLUMN_LIMIT = 64;
* earlier request, so #228 forbids a failure event for it: one would mark
* that earlier, legitimate decision as not enforced. */
export const REPLAY_DETECTED_CODE = -32005;
/** JSON-RPC SIGNATURE_INVALID and TIMESTAMP_OUT_OF_WINDOW (Specification
* §17.1). Defined here beside REPLAY_DETECTED_CODE so the Guardian's checks
* and the trace's rejection mapping read one number for each. */
export const SIGNATURE_INVALID_CODE = -32004;
export const TIMESTAMP_OUT_OF_WINDOW_CODE = -32006;

// The repository's own mapping, five directories up from this file, the
// same relationship SCHEMA_ROOT in validate-envelope.ts relies on.
Expand All @@ -48,6 +53,46 @@ export const DECISION_FAILURE_EVENT = "acs.decision.failure";
* so a query on status text finds both sides of the same failure. */
export const GUARDIAN_ERROR_STATUS = "The Guardian returned an error instead of a decision.";

/**
* A request this Guardian cannot vouch for, recorded apart from the steps it
* governed. Trace Events forbids an acs.decision or acs.decision.failure
* under the pair of a request rejected before it was trusted, because such a
* request can carry any pair, a genuine request's included. Issue #283 ruled
* that it is still recorded, under names outside `step_to_span`, so a forged
* or replayed request stays visible without being attributed to the request
* whose ids it carries. These names belong to this reference Guardian, not
* to the ACS mapping.
*/
export const REJECTED_SPAN_NAME = "guardian.rejected_request";
export const REJECTED_CLAIMED_SESSION_ID = "guardian.rejected.claimed_session_id";
export const REJECTED_CLAIMED_REQUEST_ID = "guardian.rejected.claimed_request_id";
export const REJECTED_REASON = "guardian.rejected.reason";
export const REJECTED_ERROR_CODE = "guardian.rejected.error_code";
export const REJECTED_STATUS = "The Guardian could not vouch for this request, so it carries no ACS ids or events.";

/** Why a request was rejected before it was trusted. `guardian_fault` is the
* Guardian failing to check the request at all (a missing schema directory,
* say): its ids are just as unverified, so it is recorded the same way. */
export type RejectionReason =
| "envelope_invalid"
| "guardian_fault"
| "signature_invalid"
| "replay_detected"
| "timestamp_out_of_window";

/**
* The §17.1 codes that mean the Guardian rejected a request before trusting
* it. dispatch reports its own rejections to the exporter directly. This is
* the backstop: a step answered with one of these codes is recorded as a
* rejected request even if the check that produced it forgot to report, so
* a rejected request's ids never land on a step span.
*/
export const PRE_TRUST_REJECTION_CODES: ReadonlyMap<number, RejectionReason> = new Map([
[SIGNATURE_INVALID_CODE, "signature_invalid"],
[REPLAY_DETECTED_CODE, "replay_detected"],
[TIMESTAMP_OUT_OF_WINDOW_CODE, "timestamp_out_of_window"],
]);

/** The event's attribute keys: the three required ones first, then the
* ones carried when known. */
export const FAILURE_ATTRIBUTES = {
Expand Down
Loading
Loading