Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
70707c3
Pin the OpenTelemetry packages the Guardian exports traces with
sabasiddique1 Oct 9, 2026
e4c32a8
Export Guardian spans as OTLP
sabasiddique1 Oct 9, 2026
e8cb4e5
Document trace export and add a collector config
sabasiddique1 Oct 9, 2026
19137fa
Pair a span with its response by handle, not by JSON-RPC id
sabasiddique1 Oct 9, 2026
34e1ee7
Carry provenance facts on the step span
sabasiddique1 Oct 9, 2026
d45e320
Keep each trace output running when the other fails, and write the fi…
sabasiddique1 Oct 9, 2026
30f90ba
Merge remote-tracking branch 'upstream/integration' into refimpl/otel…
sabasiddique1 Oct 13, 2026
b0ff0ac
Pad policy and provenance columns with "" instead of dropping them
sabasiddique1 Oct 13, 2026
f61d0d9
Bound how long a dead collector holds the exporter
sabasiddique1 Oct 13, 2026
82aa385
Create the trace file readable by its owner only
sabasiddique1 Oct 13, 2026
deb9e82
Build the trace exporter from the ServerHello and leave handshake.ts …
sabasiddique1 Oct 13, 2026
2875e38
Record decision failures the Guardian can see as acs.decision.failure
sabasiddique1 Oct 13, 2026
5c241ab
Convert the host audit log into the same OTLP file
sabasiddique1 Oct 13, 2026
34f216c
Make a second shutdown wait for the first
sabasiddique1 Oct 13, 2026
5bace4d
Read conditional_attributes, and pin the exporter to the mapping
sabasiddique1 Oct 13, 2026
c076238
Stop filling in acs.evaluator the response did not send
sabasiddique1 Oct 13, 2026
f5201ff
Make the trace docs say what the exporter does
sabasiddique1 Oct 13, 2026
53fb051
Tidy the shutdown path after review
sabasiddique1 Oct 13, 2026
4688d66
Bound the exporter's memory and shutdown, and stop copying error text…
rocklambros Oct 13, 2026
7f40c0a
Make the audit converter join, count and classify host failures corre…
rocklambros Oct 13, 2026
9b9babd
Describe the exporter and converter changes, and let the collector cr…
rocklambros Oct 13, 2026
8058752
Use one status sentence for a Guardian error in both writers
rocklambros Oct 13, 2026
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: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,11 +290,17 @@ harness measures the wire, not the language behind it.
### Harden it for production

Two gaps are named and open, not vague. The Guardian writes its envelope and audit logs
as synchronous file appends on the decision path, with no batching, so trace spans need
a batched writer before a Trace-pillar claim would hold up under load. No OpenTelemetry
collector or exporter exists yet. The conformance harness only measures which
attributes a consumer *could* emit from the envelopes, not that anything ships them.
Start in [`reference-implementations/agt/packages/`](reference-implementations/agt/packages/).
as synchronous file appends on the decision path, with no batching. Trace export is
partial: set `ACS_OTEL_FILE` or `ACS_OTEL_ENDPOINT` and the Guardian exports OTLP spans
for the two live methods, `steps/toolCallRequest` and `steps/toolCallResult`, as the
[Guardian README](reference-implementations/agt/packages/guardian/README.md#export-traces)
describes.

That exporter does not make the reference ACS-Trace conformant, and this tree claims no
Trace profile. It covers two methods, emits no OCSF, and leaves out a required attribute
when the request omits it. The conformance harness still measures which attributes a
consumer *could* emit from the wire, not what the exporter ships. Start in
[`reference-implementations/agt/packages/`](reference-implementations/agt/packages/).

### Go deeper

Expand Down
30 changes: 30 additions & 0 deletions reference-implementations/agt/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions reference-implementations/agt/otel-collector.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# An OpenTelemetry Collector that receives the Guardian's spans and writes
# them as OTLP/JSON, one export request per line -- the same shape
# ACS_OTEL_FILE writes directly. Run it with the contrib distribution, which
# is where the `file` exporter lives:
# otelcol-contrib --config otel-collector.yaml
# then start the Guardian with ACS_OTEL_ENDPOINT=http://127.0.0.1:4318/v1/traces
# Tested with otelcol-contrib 0.162.0. In a container, run it as your own
# user (--user "$(id -u):$(id -g)") so it can write into .acs/.
receivers:
otlp:
protocols:
http:
endpoint: 127.0.0.1:4318

exporters:
file:
path: .acs/otel-collector-traces.jsonl
# A fresh checkout has no .acs/ yet, and without this the exporter
# refuses to start.
create_directory: true
# basic, not detailed: detailed prints every attribute, reasoning text
# and provenance source ids included, to a stdout that often ends up in a
# log system nobody planned for.
debug:
verbosity: basic

service:
pipelines:
traces:
receivers: [otlp]
exporters: [file, debug]
55 changes: 53 additions & 2 deletions reference-implementations/agt/packages/guardian/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ It constructs the AGT runtime once, at startup, against `policy/manifest.yaml`,
| `ACS_MANIFEST_PATH` | `policy/manifest.yaml` | The AGT manifest. `policy/manifest.drift.yaml` is the second manifest, used to reach the `warn` verdict |
| `ACS_ENVELOPE_LOG` | `.acs/envelopes.jsonl` | Where every envelope is recorded |
| `ACS_SESSION_CONTEXT_LOG` | `.acs/session-context.jsonl` | Where each session's hash chain is written |
| `ACS_OTEL_FILE` | unset | Writes one OpenTelemetry span per decided step as OTLP/JSON, one export request per line. Unset means no trace export |
| `ACS_OTEL_ENDPOINT` | unset | Sends the same spans to an OTLP/HTTP collector, for example `http://127.0.0.1:4318/v1/traces` |

To see a decision without an agent client, pipe a hook payload into the Claude Code shim while the Guardian runs. The [tree README](../../README.md#drive-one-hook-by-hand) shows the command and its output.

Expand All @@ -46,6 +48,53 @@ To see a decision without an agent client, pipe a hook payload into the Claude C

Every throw on the request path is caught. Nothing escapes the fetch handler, because an unhandled rejection would produce an HTML error page, the host's client would fail to parse it, and Claude Code would read the failed hook as "never fired" and proceed ungoverned.

## Export traces

Set `ACS_OTEL_FILE`, `ACS_OTEL_ENDPOINT`, or both. With neither set, no exporter exists and the Guardian runs exactly as before. This is an exporter for the two live methods, not an ACS-Trace conformance claim.

```bash
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.

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 an `OTEL_TRACES_SAMPLER` or `OTEL_SPAN_*` variable in the environment does not change what is exported.

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 #229 is merged.

This Guardian does not send `chain_hash`, `cited_provenance_ids`, or `policy_version` on its responses yet, so those attributes are absent from its spans today. 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.

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.

The file holds one OTLP `ExportTraceServiceRequest` per line, the shape the Collector's file exporter writes, with `service.name` set to `acs-reference-guardian`. 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.

Export never touches a decision. Each output fails on its own: 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 response goes out unchanged.

Use synthetic sessions for lab runs. The envelope log holds raw envelopes before any redaction, a span's `acs.reasoning` repeats the Guardian's explanation of them, and `acs.provenance.source_id` goes out as the host sent it: by `provenance.json` it can be a URL or a file path, so a signed URL or a home-directory path would too ([#71](https://github.com/GenAI-Security-Project/agent-control-standard/issues/71)).

### Convert the host audit log

The failures only the host sees, a Guardian it could not reach or one that answered too late, are in the host adapter's audit log, not in the Guardian's file. `audit-to-otlp.ts` turns that log into the same OTLP/JSON shape: one span per posture entry, with an `acs.decision.failure` event whose `acs.failure.reported_by` is `observed_agent`. Each span carries the same `acs.session.id` and `acs.request_id` as the Guardian's span for that request, so the two files join. It does not carry `gen_ai.tool.name`, because the audit log does not record the tool.

```bash
bun run packages/guardian/src/audit-to-otlp.ts .acs/audit.jsonl .acs/otel-audit.jsonl
```

Run it after the Guardian has stopped. Two writers on one file can interleave lines, so give the converter a file of its own or wait. Running it twice over the same log writes every span twice.

`acs.failure.posture` is the deployment's posture, not what became of the step. A refused request shows `fail_open` under a `proceed` deployment even though the host blocked it. The detail sentence says so, and the error code is in `acs.failure.error_code`. The detail is always a sentence the converter writes, never the host's message, which quotes the Guardian's error text.

Entries with `outcome: "ungoverned"` are skipped: those are steps no gate asked about, which the failure classes do not cover. Also skipped, and counted on stdout:

- entries whose method has no `step_to_span` entry;
- entries with no method, by failure kind, because the host never built a request for them;
- replay rejections;
- lines it cannot read.

It prints how many spans reached the file and exits non-zero when that is fewer than it built.

## Use it in-process

The tests and the conformance harness start a Guardian in the same process.
Expand All @@ -58,7 +107,7 @@ const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.y
await guardian.close();
```

`StartGuardianOptions` also accepts `hostname`, `envelopeLogPath`, `sessionContextLog` and `onDecisionFailure`, which mirror the environment variables above, and four options for tests: `mappingPath`, `annotator`, `bridge` and `sessionContextStore`. Each is documented where it is declared, in `src/server.ts`.
`StartGuardianOptions` also accepts `hostname`, `envelopeLogPath`, `sessionContextLog`, `otelTraceFile`, `otelTraceEndpoint` and `onDecisionFailure`, which mirror the environment variables above, and five options for tests: `mappingPath`, `annotator`, `bridge`, `sessionContextStore` and `traceExporter`. Each is documented where it is declared, in `src/server.ts`.

The package has two entry points. `guardian` exports the governance verbs listed in `src/index.ts`. `guardian/deployment` exports `createDeploymentBridge`, the one function that builds the deployment's bridge with the egress annotator wired in. The conformance harness imports the second so it measures the bridge the Guardian ships, not a replica.

Expand All @@ -78,6 +127,8 @@ The package has two entry points. `guardian` exports the governance verbs listed
| `src/deny-on-invalid-envelope.ts` | Turns a schema failure on a `steps/*` method into an honoured `deny` |
| `src/session-context-store.ts`, `src/session-context.ts`, `src/ifc-labels.ts` | The per-session hash chain, provenance, and information-flow labels |
| `src/envelope-log-sink.ts` | The envelope log writer |
| `src/otel-trace-exporter.ts` | The OpenTelemetry span exporter and its OTLP/JSON file writer |
| `src/audit-to-otlp.ts` | Converts the host adapter's audit log into the same OTLP/JSON file |
| `src/acs-result.ts` | The decision result type |

## The wire is not secured
Expand All @@ -90,4 +141,4 @@ The endpoint has no authentication, no origin check and no request signing. It b
bun test packages/guardian
```

Twelve files. Most start a real Guardian on port 0 and drive real envelopes through the pinned policy bundle. One test copies `src/` one directory deeper into a fixed scratch directory, so the relative schema path resolves to nothing. It proves that a missing schema directory becomes a recorded JSON-RPC error and never an HTML page. The scratch directories are gitignored and removed in a `finally`.
Thirteen files. Most start a real Guardian on port 0 and drive real envelopes through the pinned policy bundle. One test copies `src/` one directory deeper into a fixed scratch directory, so the relative schema path resolves to nothing. It proves that a missing schema directory becomes a recorded JSON-RPC error and never an HTML page. The scratch directories are gitignored and removed in a `finally`.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
"./package.json": "./package.json"
},
"dependencies": {
"@opentelemetry/api": "1.9.1",
"@opentelemetry/core": "2.12.0",
"@opentelemetry/exporter-trace-otlp-http": "0.223.0",
"@opentelemetry/otlp-transformer": "0.223.0",
"@opentelemetry/resources": "2.12.0",
"@opentelemetry/sdk-trace-base": "2.12.0",
"agt-bridge": "workspace:*",
"ajv": "8.20.0",
"ajv-formats": "3.0.1"
Expand Down
Loading
Loading