diff --git a/.github/workflows/reference-implementation.yml b/.github/workflows/reference-implementation.yml index 424d2a97..6222485a 100644 --- a/.github/workflows/reference-implementation.yml +++ b/.github/workflows/reference-implementation.yml @@ -10,18 +10,25 @@ # concurrency group are an integer pull request number and a ref name GitHub validates, # and a concurrency group is not a shell context. # -# Scoped by path. A documentation or schema change should not pay for a Bun install. +# Scoped by path. A documentation change should not pay for a Bun install. A schema change +# does, because this tree reads the schemas at runtime. name: Reference implementation on: pull_request: paths: - "reference-implementations/**" + # The conformance checker and the trace exporter read the schemas and + # the Trace mappings at runtime, so a spec-only change can break them. + - "specification/**" - ".github/workflows/reference-implementation.yml" push: branches: ["main", "integration"] paths: - "reference-implementations/**" + # The conformance checker and the trace exporter read the schemas and + # the Trace mappings at runtime, so a spec-only change can break them. + - "specification/**" - ".github/workflows/reference-implementation.yml" workflow_dispatch: diff --git a/CHANGELOG.md b/CHANGELOG.md index ed40bdc0..d342a1bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,46 @@ # Changelog -All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once it reaches 1.0.0. Pre-1.0 releases MAY change the ACS-Core baseline in patch versions; the wire format version (`acs_version` in the handshake) evolves separately in the `specification/` directory. +All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once it reaches 1.0.0. Pre-1.0 releases MAY change the ACS-Core baseline and the Trace mappings in patch versions. The wire format version (`acs_version` in the handshake, and the `v0.1.0` in every schema `$id`) evolves separately in the `specification/` directory. -## [0.1.3] — 2026-09-09 +## [0.1.4] - 2026-10-16 + +This release changes ACS-Trace for producers and consumers. The specification version stays v0.1.0, so the files at the `/schema/v0.1.0/` URIs change in place, and the handshake cannot tell the old rules from the new. Name this release, or its commit, when you claim ACS-Trace conformance. + +### Changed: Trace (breaking) + +- Every step span now requires `acs.request_id` and `acs.session.id`. A reader joins the two parties' spans on that pair. An exporter that omits either no longer conforms ([PR #275](https://github.com/GenAI-Security-Project/agent-control-standard/pull/275), issue #228). +- The OCSF decision class now requires `finding_info`, `finding_info.title`, `action_id`, `action`, `disposition_id`, `disposition`, `evidences` and `metadata.correlation_uid`, and decision events list the `security_control` profile. `defer` maps to `disposition_id` 99. `is_alert` is true exactly when `severity_id` is 3 or higher, so deny, ask and defer alert, and allow and modify don't. A decision that arrives late is emitted and flagged in `finding_info.types`, where it used to be suppressed (PR #275). +- A decision event records the Guardian's verdict, not the enforced outcome. A matching `acs.decision.failure` in either party's stream means the decision MUST NOT be counted as enforced (PR #275). +- The four `policy_references` fields and the provenance `source_id` now require `minLength: 1` (PR #275, issue #263). +- The OCSF mapping is pinned to OCSF 1.9.0 and uses its class names. Three rows on class 5001 set `activity_id` 2, and `required_fields` includes `finding_info` ([PR #258](https://github.com/GenAI-Security-Project/agent-control-standard/pull/258)). + +### Added + +- The `acs.decision.failure` span event, and the extra `acs.decision` attributes a reader needs to replay a decision from the trace alone (PR #275). +- Per-hook `conditional_attributes` in `otel-mapping.json` ([PR #261](https://github.com/GenAI-Security-Project/agent-control-standard/pull/261)). +- Trace mappings for the skill lifecycle hooks, `steps/skillRegister`, `steps/skillLoad` and `steps/skillUnload`, in both OpenTelemetry and OCSF ([PR #63](https://github.com/GenAI-Security-Project/agent-control-standard/pull/63), issue #57, issue #58). +- The reference Guardian exports OTLP traces to a file and over HTTP, and a converter turns the host adapter's audit log into the same format. It covers the two hook methods the Guardian evaluates and is not an ACS-Trace conformance claim ([PR #244](https://github.com/GenAI-Security-Project/agent-control-standard/pull/244), issue #91). +- A committed sample trace, a test that holds the exporter to the mapping, and "Produce a trace", a walkthrough from a fresh clone to a trace file, tested natively and with Docker ([PR #277](https://github.com/GenAI-Security-Project/agent-control-standard/pull/277), issue #266). +- "How the reference Guardian produces a trace" on the Trace with OpenTelemetry page, with diagrams of both streams and of one tool call, and a Troubleshooting list in the Guardian README ([PR #280](https://github.com/GenAI-Security-Project/agent-control-standard/pull/280)). + +### Changed: reference implementation + +- The audit converter's spans carry `service.name` `acs-reference-audit-converter`, no longer the Guardian's `acs-reference-guardian`. A query that filtered on the old value for converter spans needs updating (PR #277, issue #274). +- The converter refuses to write to an output file that already exists and exits 1. It used to append, which could write OTLP lines into the audit log or double every span on a rerun (PR #277). +- `otel-collector.yaml` stays on loopback, keeps its file across restarts, writes owner-only, and pins its container image by digest (PR #277, issue #276). +- `OTEL_TRACES_*`, `OTEL_SPAN_*`, `OTEL_ATTRIBUTE_*`, `OTEL_BSP_*` and OTLP compression variables no longer change what the Guardian or the converter exports. `OTEL_EXPORTER_OTLP_*HEADERS` still apply (PR #277). +- A loopback trace endpoint is added to whichever of `NO_PROXY` and `no_proxy` is set, keeping the user's own entries, and the Guardian warns at startup when a proxy is set and loopback isn't exempt. The walkthrough says to stop the Guardian and the collector before deleting `.acs` ([PR #279](https://github.com/GenAI-Security-Project/agent-control-standard/pull/279), issue #274). +- The Guardian's `main.ts`, not the trace exporter, now reads the proxy environment and applies the loopback exemption, and `close()` applies the same bounded drain whether trace export is on or off (Ariel's review on PR #286). + +### Removed + +- `acs.provenance.lineage_depth` and `acs.provenance.origins_compacted` from the OTel mapping (PR #261). + +### Changed: dependencies + +- urllib3 2.7.0 to 2.8.0 ([PR #202](https://github.com/GenAI-Security-Project/agent-control-standard/pull/202)). + +## [0.1.3] — 2026-10-05 ### Changed — ACS-Core baseline diff --git a/GOVERNANCE.md b/GOVERNANCE.md index c77bece4..c2c2b389 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -6,7 +6,7 @@ ACS is an OWASP project. This file records who leads the work, which workstream | Role | Name | | --- | --- | -| Project Lead | Rock Lambros ([@rocklambros](https://github.com/rocklambros)) | +| Project Lead | Kyriakos Lambros (Rock) ([@rocklambros](https://github.com/rocklambros)) | | Project Lead | Ariel Fogel ([@afogel](https://github.com/afogel)) | | Project Lead | Bar Kaduri ([@bar-capsule](https://github.com/bar-capsule)) | @@ -24,7 +24,7 @@ against the conformance requirements it touches. | Workstream | Leads | | --- | --- | | Coding Agents | Almog Langleben ([@almogbhl](https://github.com/almogbhl)), Stefano Amorelli ([@stefanoamorelli](https://github.com/stefanoamorelli)) | -| Development (SDK) | Rock Lambros ([@rocklambros](https://github.com/rocklambros)), Fred Wilmot ([@fewdisc](https://github.com/fewdisc)) | +| Development (SDK) | Kyriakos Lambros (Rock) ([@rocklambros](https://github.com/rocklambros)), Fred Wilmot ([@fewdisc](https://github.com/fewdisc)) | | Documentation | Lance Dye ([@Lrd0036](https://github.com/Lrd0036)) | | Identity | Eva Benn ([@evabenn](https://github.com/evabenn)), Richard Bird ([@RbBuiltWrong](https://github.com/RbBuiltWrong)) | | Outreach | Eva Benn ([@evabenn](https://github.com/evabenn)), Aruneesh Salhotra ([@aruneeshsalhotra](https://github.com/aruneeshsalhotra)) | @@ -75,7 +75,7 @@ they step back. | Volunteer | Assigned by | | --- | --- | -| Victor Hernandez ([@victorm-hernandez](https://github.com/victorm-hernandez)) | Rock Lambros | +| Victor Hernandez ([@victorm-hernandez](https://github.com/victorm-hernandez)) | Kyriakos Lambros (Rock) | ## Origins diff --git a/README.md b/README.md index e6a64cf9..71be2b4b 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ the one sending hook requests like the ones above. The **Guardian Agent** sits o other end of the wire and decides what happens next. A **hook** is a point in the Observed Agent's execution where it stops and asks before -acting. `toolCallRequest` is one of ACS's sixteen native lifecycle hooks. Others fire at +acting. `toolCallRequest` is one of ACS's nineteen native lifecycle hooks. Others fire at session start and end, before and after a turn, on knowledge retrieval, and around memory reads and writes. @@ -289,12 +289,24 @@ 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/). +Two gaps are named and open, not vague. The Guardian writes its envelope and +session-context logs, and the host adapter writes its audit log, 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. Failures only the host sees, such as an unreachable Guardian, stay in the host's +audit log until a converter turns them into spans of the same format. +[How the reference Guardian produces a trace](docs/spec/trace/extend_opentelemetry.md#how-the-reference-guardian-produces-a-trace) +explains how the two streams join. A [sample trace](reference-implementations/agt/samples/trace/acs-trace-sample.jsonl) +shows that output without running anything, and [Produce a trace](reference-implementations/agt/README.md#produce-a-trace) +walks you from a fresh clone to a trace file of your own. + +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 diff --git a/docs/concepts/skill.md b/docs/concepts/skill.md index 89bc81e1..98b180b7 100644 --- a/docs/concepts/skill.md +++ b/docs/concepts/skill.md @@ -30,4 +30,5 @@ Load is the runtime gate. It governs each activation in live context and carries - **Instrument**: the [`skillRegister`, `skillLoad`, and `skillUnload` hooks](../spec/instrument/hooks.md#skillregister). - **Inspect**: the `skill` [AgBOM component type](../spec/inspect/README.md). +- **Trace**: the [span names and OCSF classes](../spec/trace/events.md) the three hooks emit. - See also [Capability](./capability.md), [Provenance](./provenance.md), and [Trust basis](./trust.md). diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index 17c56335..dd937e95 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -48,7 +48,7 @@ Who verifies a conformance claim: nobody, in v0.1.0. `profiles_supported` and `p Adds deterministic Trace event emission per [Trace Events](./trace/events.md). A deployment claiming ACS-Trace MUST: -1. Emit at least one of {OTel, OCSF} for every supported ACS step, with the required attributes populated. +1. Emit at least one of {OTel, OCSF} for every supported ACS step, with the required attributes populated and each conditional attribute populated whenever its source field is present. 2. Record decisions as Trace events. 3. Carry provenance facts forward onto Trace events. diff --git a/docs/spec/trace/README.md b/docs/spec/trace/README.md index ef4bf50f..3d637235 100644 --- a/docs/spec/trace/README.md +++ b/docs/spec/trace/README.md @@ -7,6 +7,22 @@ AI agents make autonomous decisions that impact business outcomes. Without obser !!! info "ACS Extends Industry Standards" We already have great observability standards, so ACS doesn't introduce a new one. Instead, it extends existing industry-proven standards: OpenTelemetry and OCSF to support AI agent-specific components. +## Where Trace Sits + +Instrument decides in the moment. Trace records what happened, out of the request path, so someone can rebuild a session afterwards: what the agent tried, what the Guardian decided, and which steps got no usable decision at all. + +Both parties write to the trace, because each one sees something the other can't. In the diagram, notice that the Observed Agent and the Guardian each emit their own stream, and the two streams meet only at the reader. + +```mermaid +flowchart LR + agent["Observed Agent"] -->|"hook request"| guardian["Guardian"] + agent -->|"spans and events"| formats["OpenTelemetry or OCSF"] + guardian -->|"spans and events"| formats + formats --> reader["Reader joins on
session id + request id"] +``` + +The reader matches records from the two parties by each step's session id and request id. [Trace Events](./events.md) defines that join and the rule for counting a decision as enforced. To see it as real files, read [How the reference Guardian produces a trace](./extend_opentelemetry.md#how-the-reference-guardian-produces-a-trace), or open the committed [sample trace](https://github.com/GenAI-Security-Project/agent-control-standard/blob/main/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl). + ## Why Agent Observability Matters Modern agents orchestrate complex workflows: reasoning chains, tool execution, knowledge retrieval, multi-agent collaboration. When things go wrong, or right, you need to understand exactly what happened. diff --git a/docs/spec/trace/events.md b/docs/spec/trace/events.md index 821b3b1c..25d271c4 100644 --- a/docs/spec/trace/events.md +++ b/docs/spec/trace/events.md @@ -12,10 +12,14 @@ flowchart LR STEP --> OCSFEVENT["OCSF event class"] ``` +Every attribute value comes from the Observed Agent, the Guardian, or content they processed, and any of it can carry text an attacker influenced. A consumer that renders trace data or feeds it to a model SHOULD treat every trace value, in OTel attributes or OCSF fields, as untrusted data, never as instructions or markup. + ## OpenTelemetry semantic conventions Each ACS step produces a span whose `name` and required attributes are fixed by the table below. Decisions are recorded as span events on the parent step span, not as separate spans, so the enforcement verdict and the action it gates share a parent. +An attribute marked *conditional* comes from a field the hook payload or response may omit. An exporter MUST emit it whenever the source field is present and MUST omit it when the field is absent. An empty source array counts as absent. + The full mapping lives in [`trace/otel-mapping.json`](https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/trace/otel-mapping.json). See [Extending OpenTelemetry](./extend_opentelemetry.md) for a deeper integration guide. | ACS step | OTel span name | Required attributes | @@ -24,39 +28,59 @@ The full mapping lives in [`trace/otel-mapping.json`](https://genai-security-pro | `steps/agentTrigger` | `acs.agent.trigger` | `acs.agent.id`, `acs.trigger.type` | | `steps/userMessage` | `acs.message.user` | `acs.session.id`, `acs.content.types[]` | | `steps/agentResponse` | `acs.message.agent` | `acs.session.id`, `acs.agent.id` | -| `steps/toolCallRequest` | `gen_ai.tool.call` | `gen_ai.tool.name`, `acs.capability`; `acs.tool.provider` (optional) | -| `steps/toolCallResult` | `gen_ai.tool.result` | `gen_ai.tool.name`, `acs.exit_status`; `acs.duration_ms` (optional) | -| `steps/knowledgeRetrieval` | `acs.knowledge.retrieval` | `acs.source.type`, `acs.results.count` | -| `steps/memoryStore` | `acs.memory.store` | `acs.memory_store.name`, `acs.operation` | -| `steps/memoryContextRetrieval` | `acs.memory.retrieval` | `acs.memory_store.name`, `acs.results.count` | +| `steps/toolCallRequest` | `gen_ai.tool.call` | `gen_ai.tool.name`; `acs.capability` (conditional); `acs.tool.provider` (optional) | +| `steps/toolCallResult` | `gen_ai.tool.result` | `gen_ai.tool.name`, `acs.exit_status`; `acs.request_id_ref` (conditional); `acs.duration_ms` (optional) | +| `steps/knowledgeRetrieval` | `acs.knowledge.retrieval` | `acs.results.count`; `acs.source.type` (conditional) | +| `steps/memoryStore` | `acs.memory.store` | `acs.memory_store.name`, `acs.operation` (both conditional) | +| `steps/memoryContextRetrieval` | `acs.memory.retrieval` | `acs.results.count`; `acs.memory_store.name` (conditional) | | `steps/sessionEnd` | `acs.session.end` | `acs.session.reason` | | `steps/turnStart`, `steps/turnEnd` | `acs.turn`, `acs.turn.end` | `acs.turn.id`, `acs.turn.triggered_by` / `acs.turn.outcome` | | `steps/preCompact` | `acs.compact` | `acs.compact.entry_count`, `acs.compact.triggered_by` | | `steps/postCompact` | `acs.compact.complete` | `acs.compact.entry_count`; `acs.compact.lineage_depth_after` (optional) | -| `steps/subagentStart`, `steps/subagentStop` | `acs.subagent`, `acs.subagent.end` | `acs.subagent.session_id`, `acs.subagent.parent_session_id`, `acs.subagent.intent_derivation` / `acs.subagent.outcome`, `acs.subagent.final_chain_hash` | -| Decision (allow/deny/modify/ask/defer) | `acs.decision` (span event) | `acs.decision`, `acs.evaluator`, `acs.reasoning` (when present), `acs.confidence` (when present) | -| `agbom/snapshot` | `acs.agbom` | `acs.agbom.format` (`canonical`/`cyclonedx`/`spdx`/`swid`), `acs.agbom.component_count` | -| `agbom/changed` | `acs.agbom` | `acs.agbom.format` (`canonical`/`cyclonedx`/`spdx`/`swid`), `acs.agbom.change_reason` | +| `steps/subagentStart`, `steps/subagentStop` | `acs.subagent`, `acs.subagent.end` | `acs.subagent.session_id`, `acs.subagent.parent_session_id`, `acs.subagent.intent_derivation` / `acs.subagent.outcome`; `acs.subagent.final_chain_hash` (conditional) | +| `steps/skillRegister` | `acs.skill.register` | `acs.skill.id`, `acs.skill.digest.algorithm`, `acs.skill.digest.value`, `acs.skill.declared_capabilities` | +| `steps/skillLoad` | `acs.skill.load` | `acs.skill.id`, `acs.skill.digest.algorithm`, `acs.skill.digest.value`, `acs.skill.load_trigger`, `acs.skill.load_path` (skill ids, root first); `acs.skill.digest_verified`, `acs.skill.registration_ref` (optional) | +| `steps/skillUnload` | `acs.skill.unload` | `acs.skill.id`, `acs.skill.reason`; `acs.skill.replaced_by` (optional) | +| Decision (allow/deny/modify/ask/defer) | `acs.decision` (span event) | `acs.decision`; `acs.evaluator`, `acs.evaluator_version`, `acs.model_id`, `acs.reasoning`, `acs.confidence`, `acs.reason_codes`, `acs.policy.ids`, `acs.policy.versions`, `acs.policy.rule_ids`, `acs.cited_provenance_ids`, `acs.chain_hash` (conditional) | +| Decision failure | `acs.decision.failure` (span event; on the `acs.session` span for `handshake_failure`) | `acs.failure.class`, `acs.failure.posture`, `acs.failure.reported_by`; `acs.failure.detail` (required for class `other`), `acs.failure.error_code`, `acs.failure.timeout_ms`, `acs.failure.elapsed_ms`, `acs.failure.payload_bytes` (conditional) | +| `agbom/snapshot` | `acs.agbom` | `acs.agbom.format`, `acs.agbom.component_count` | +| `agbom/changed` | `acs.agbom` | `acs.agbom.format`; `acs.agbom.change_reason` (conditional) | + +Every step span also carries `acs.request_id` and `acs.session.id`, the step's own `request_id` and `session_id`. A `steps/toolCallResult` span's `acs.request_id_ref` joins to its request. The Observed Agent chooses `request_id`, and it is unique only within a session ([Specification §10.3](../instrument/specification.md#103-replay-protection)), so a join keys on the two together. The Observed Agent and the Guardian may each emit a span for one request, and a reader joins the two. When the Observed Agent retries a step with a new `request_id`, each attempt is its own request. The Observed Agent emits a step span for each attempt that reached the Guardian, carrying that attempt's `request_id`. A Guardian event whose pair matches no Observed Agent step span is a discrepancy worth investigating. + +The three `acs.policy.*` attributes are parallel arrays built from `policy_references`. Their source field is `policy_references` itself: all three are emitted whenever the response carries a non-empty `policy_references`, and none otherwise. Each has the same length and order as `policy_references`, with `""` where an entry lacks the field. A reader can then line up a policy, its version, and its rule by position. A `""` on the wire could not be told from an absent field, so the response envelope rejects one in a `policy_references` entry, and the Provenance schema rejects an empty `source_id`. A producer SHOULD omit a member it has no value for rather than send `""`, because a peer validating against these schemas now rejects the empty string. `acs.reason_codes` is a set. Its order carries no meaning, and it does not line up with the policy arrays. + +An `acs.decision` event records the Guardian's verdict. Only the Observed Agent knows the applied outcome. When any `acs.decision.failure` event in either party's stream carries the same `acs.session.id` and `acs.request_id`, a reader MUST NOT count the `acs.decision` event as an enforced outcome. The rule works in one direction only. A missing failure event does not prove the decision was enforced, because in v0.1 the Observed Agent can under-report, so no decision event proves enforcement on its own. `acs.chain_hash` is a copy of the response field and proves nothing on its own. The trace carries neither the response signature nor the ContextEntries a verifier needs ([Specification §8.6](../instrument/specification.md#86-chain-head-publication-normative)). One check does work from the trace alone: when the Guardian's and the Observed Agent's streams show different `acs.chain_hash` values for the same `acs.session.id` and `acs.request_id`, that is an integrity event under §8.6. + +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`. + +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. + +No AgBOM payload carries a format field, because the Observed Agent always sends the canonical form. An exporter recording the payload sets `acs.agbom.format` to `canonical`. A Guardian recording a serialization it rendered sets the value it advertises in the handshake's `agbom_serializations_supported` (`cyclonedx-1.6`, `spdx-3.0`, or `swid`). -When Provenance is attached to a hook payload, the resulting span MUST carry `acs.provenance.origin` as an attribute, and SHOULD carry `acs.provenance.source_id` and `acs.provenance.lineage_depth` when populated. v0.1 emits factual provenance attributes; trust classification is computed by the Guardian against local policy and is not a v0.1 span attribute (see [Specification §7](../instrument/specification.md#7-provenance)). Provenance lineage edges MAY be linked via OTel span links keyed by `provenance_id`. +When Provenance is attached to a hook payload, the resulting span MUST carry `acs.provenance.origin` as an attribute, and SHOULD carry `acs.provenance.source_id` when populated. Both are string arrays, also for a single Provenance object. `acs.provenance.source_id` is emitted whenever at least one attached object has a `source_id`. It then has the same length and order as `acs.provenance.origin`, with `""` where an object has none. v0.1 emits factual provenance attributes. Trust classification is computed by the Guardian against local policy and is not a v0.1 span attribute (see [Specification §7](../instrument/specification.md#7-provenance)). Provenance lineage edges MAY be linked via OTel span links keyed by `provenance_id`. ## OCSF event classes -Each ACS step is representable as an OCSF event in the class shown below (OCSF 1.5+). Required class-specific attributes are populated from the ACS payload. +Each ACS step is representable as an OCSF event in the class shown below (OCSF 1.9.0). Required class-specific attributes are populated from the ACS payload. The full mapping lives in [`trace/ocsf-mapping.json`](https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/trace/ocsf-mapping.json). See [Extending OCSF](./extend_ocsf.md) for the deeper integration guide. | ACS step | OCSF class | Class UID | |---|---|---| | `steps/sessionStart` | Authentication | 3002 | -| `steps/agentTrigger`, `steps/userMessage`, `steps/agentResponse` | Application Activity | 6002 | +| `steps/agentTrigger`, `steps/userMessage`, `steps/agentResponse` | Application Lifecycle | 6002 | | `steps/toolCallRequest`, `steps/toolCallResult` | Process Activity | 1007 | +| `steps/skillLoad`, `steps/skillUnload` | Module Activity | 1005 (`activity_id` 1 load, 2 unload) | | `steps/knowledgeRetrieval`, `steps/memoryStore`, `steps/memoryContextRetrieval` | Datastore Activity | 6005 | | `steps/preCompact`, `steps/postCompact` | Datastore Activity | 6005 (compaction subtype) | | `steps/subagentStart`, `steps/subagentStop` | Authentication | 3002 (logon/logoff for the subagent's session) | -| `steps/turnStart`, `steps/turnEnd` | Application Activity | 6002 | +| `steps/turnStart`, `steps/turnEnd` | Application Lifecycle | 6002 | | Decision (deny/modify/ask/defer) | Detection Finding | 2004 | -| `agbom/snapshot`, `agbom/changed` | Inventory Info | 5001 | +| `agbom/snapshot`, `agbom/changed` | Device Inventory Info | 5001 (`activity_id` 2, Collect) | +| `steps/skillRegister` | Device Inventory Info | 5001 (`activity_id` 2, Collect: skill enters the component set) | | `steps/sessionEnd` | Authentication | 3002 (logoff) | OCSF `severity_id` for decision events is set from the disposition: @@ -69,12 +93,37 @@ OCSF `severity_id` for decision events is set from the disposition: | `defer` | 3 (Medium) | | `deny` | 4 (High) | +OCSF `action_id` and `disposition_id` for decision events are also set from the disposition. They come from OCSF's Security Control profile, so a decision event lists `security_control` in `metadata.profiles`. Both record the Guardian's verdict, so an `action_id` of 2 (Denied) or a `disposition_id` of 2 (Blocked) never proves the action was blocked, and the enforcement rule above applies to OCSF decision events as well. OCSF has no `disposition_id` for `modify` or `ask`. `defer` takes 99 (Other) as well, not 14 (Delayed), because an ACS defer holds the step and denies by default when it times out. `action_id` gives `modify` its own code, 4 (Modified). `ask` and `defer` share 99 in both, so the `action` and `disposition` text fields are required too. They carry the OCSF caption for 1, 2 and 4, and the ACS name for 99. The `allow` row applies when the deployment records allows as Detection Findings. + +| Disposition | `action_id` | `disposition_id` | `is_alert` | +|---|---|---|---| +| `allow` | 1 (Allowed) | 1 (Allowed) | false | +| `deny` | 2 (Denied) | 2 (Blocked) | true | +| `modify` | 4 (Modified) | 99 (Other) | false | +| `ask` | 99 (Other) | 99 (Other) | true | +| `defer` | 99 (Other) | 99 (Other) | true | + +On an on-time decision event, `is_alert` is true exactly when `severity_id` is 3 or higher, so the two fields never disagree. `ask` and `defer` alert because an ASK can time out to `allow`, a DEFER can fall back to that ASK, and v0.1.0 has no Trace event for how either one ends, so the decision event is the only signal a SOC gets. `modify` does not alert, because the action ran in the form the policy required, and a MODIFY the client cannot apply becomes a DENY (§6.5), which alerts. + +A Guardian SHOULD NOT set `verdict_id` or the finding's `status_id`, because both belong to whoever triages the finding. + +SIEM content reads native fields, not `unmapped`, so each decision event also carries these: + +- `metadata.correlation_uid` is the gated step's pair, formatted as `:`. Every OCSF step event sets it from its own pair too, so the join works on native fields alone. +- `evidences` holds the agent and the action, because a Detection Finding has no other native place for them: `ai_agent.uid` from `metadata.agent_id`, `actor.session.uid` from `metadata.session_id`, and, for `steps/toolCallRequest` and `steps/toolCallResult`, the action the decision gates as `api.operation`, set to the tool name. For every other step, `evidences` carries `ai_agent` and `actor.session` only. +- `authorizations` has one entry per `policy_references` entry, in the same order, whenever `policy_references` is non-empty. Each entry's `policy` carries `uid`, `version` and `name` from `policy_id`, `policy_version` and `policy_name`. An entry with neither `policy_id` nor `policy_name` MUST be an `authorizations` entry with no `policy` object, so positions still line up with `policy_references`: OCSF requires a `policy` to carry `uid` or `name`. +- `finding_info.desc` SHOULD carry `reasoning`. + +The decision's `request_id`, `decision`, `reasoning`, `reason_codes`, `policy_references`, `cited_provenance_ids`, `chain_hash`, and `metadata` also ride under `unmapped.acs` with the response's field names, as the exact copy for replay. Every OCSF step event carries `unmapped.acs.request_id` and `unmapped.acs.session_id`. The decision event carries the gated step's pair (`join_fields` in the mapping). A `steps/toolCallResult` event also carries `unmapped.acs.request_id_ref` when the payload has it. OCSF does not validate `unmapped`, so each field takes the type of its definition in the response envelope. + +A Guardian that finishes a decision after the negotiated timeout still emits the decision event. A late deny under fail-open means the policy said no and the action ran, so suppressing it would leave OCSF silent exactly when someone slows the Guardian down. `action_id` and `disposition_id` stay the verdict. `finding_info.types` includes `late_decision_fail_open` or `late_decision_fail_closed`, from the `on_decision_failure` posture the Guardian sent in its ServerHello. `unmapped.acs.failure` carries `class`, `posture`, `timeout_ms`, `elapsed_ms` and `payload_bytes`, with the values of the matching `acs.failure.*` attributes. `is_alert` follows the table above, and is also true for any verdict other than `allow` under fail-open, because the action ran without what the policy required. `severity_id` stays the value the table gives the verdict, so a late decision is the one case where `is_alert` can be true below severity 3. A reader MUST NOT count a decision event whose `finding_info.types` includes `late_decision_fail_open` or `late_decision_fail_closed` as an enforced outcome. The Guardian measures its own processing time, not network time, so a decision can still arrive late and look on time. This is the first piece of an OCSF form for decision failures. v0.1.0 defines none for the other failure classes. + ## ACS-Trace conformance bar A deployment claiming **ACS-Trace** MUST: -1. Emit at least one of {OTel, OCSF} for every ACS step the deployment supports, with the required attributes populated. -2. Record every decision as a Trace event carrying the disposition, evaluator identity, and reasoning when present. -3. Carry provenance facts forward from the hook payload onto the Trace event so audit replay reconstructs the policy decision without consulting Guardian state separately. +1. Emit at least one of {OTel, OCSF} for every ACS step the deployment supports, with the required attributes populated and each conditional attribute populated whenever its source field is present. +2. Record every decision as a Trace event carrying the disposition, plus the evaluator identity and reasoning when the response carries them. +3. Carry provenance facts forward from the hook payload onto the Trace event. Carry the decision's reason codes, policy references, cited provenance ids, and chain hash onto the decision event when the response carries them. Arrays follow the padding rules above. A reader can then tell which policies, rules, and reason codes produced a decision without consulting Guardian state. Joining a cited provenance id to its provenance facts, and verifying the chain head, still need the Guardian's ContextEntries and the signed response. Trace events MUST NOT block enforcement — failure of the Trace sink MUST NOT change the disposition returned to the Observed Agent. Deployments that do not claim ACS-Trace SHOULD still emit Trace events where feasible; the vocabulary is normative regardless of profile claim. diff --git a/docs/spec/trace/extend_ocsf.md b/docs/spec/trace/extend_ocsf.md index b3921599..b8efdfe1 100644 --- a/docs/spec/trace/extend_ocsf.md +++ b/docs/spec/trace/extend_ocsf.md @@ -2,18 +2,20 @@ The Open Cybersecurity Schema Framework (OCSF) integration enables standardized security-event logging for AI agent activity. ACS-shaped events drop directly into existing SIEM pipelines without bespoke parsers. -ACS events map to OCSF 1.5+ event classes. The normative class assignments and the disposition → `severity_id` mapping live on the [Trace Events](./events.md) page; the machine-readable mapping is at [`trace/ocsf-mapping.json`](https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/trace/ocsf-mapping.json). This page describes how to assemble the events themselves and provides representative wire examples. +ACS events map to OCSF 1.9.0 event classes. The normative class assignments and the disposition → `severity_id` mapping live on the [Trace Events](./events.md) page; the machine-readable mapping is at [`trace/ocsf-mapping.json`](https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/trace/ocsf-mapping.json). This page describes how to assemble the events themselves and provides representative wire examples. ## Class assignments at a glance | ACS step | OCSF class | Class UID | |---|---|---| | `steps/sessionStart`, `steps/sessionEnd`, `steps/subagentStart`, `steps/subagentStop` | Authentication | 3002 | -| `steps/userMessage`, `steps/agentResponse`, `steps/turnStart`, `steps/turnEnd` | Application Activity | 6002 | +| `steps/agentTrigger`, `steps/userMessage`, `steps/agentResponse`, `steps/turnStart`, `steps/turnEnd` | Application Lifecycle | 6002 | | `steps/toolCallRequest`, `steps/toolCallResult` | Process Activity | 1007 | +| `steps/skillLoad`, `steps/skillUnload` | Module Activity | 1005 (`activity_id` 1 load, 2 unload) | | `steps/knowledgeRetrieval`, `steps/memoryStore`, `steps/memoryContextRetrieval`, `steps/preCompact`, `steps/postCompact` | Datastore Activity | 6005 | | Decision (deny/modify/ask/defer) | Detection Finding | 2004 | -| `agbom/snapshot`, `agbom/changed` | Inventory Info | 5001 | +| `agbom/snapshot`, `agbom/changed` | Device Inventory Info | 5001 (`activity_id` 2, Collect) | +| `steps/skillRegister` | Device Inventory Info | 5001 (`activity_id` 2, Collect) | ## Severity mapping @@ -39,7 +41,8 @@ ACS events map to OCSF 1.5+ event classes. The normative class assignments and t "type_uid": 100701, "severity_id": 1, "metadata": { - "version": "1.5.0", + "version": "1.9.0", + "correlation_uid": "6b1f2c9e-4d7a-4e3b-8a51-9c0d2e7f1a36:3f6c1e2a-8b4d-4c1e-9a7f-2d5b6e8c9a01", "product": { "name": "ACS Guardian", "vendor_name": "ACS" } }, "actor": { @@ -49,7 +52,7 @@ ACS events map to OCSF 1.5+ event classes. The normative class assignments and t "type_id": 99, "type": "AI Agent" }, - "session": { "uid": "session-789" } + "session": { "uid": "6b1f2c9e-4d7a-4e3b-8a51-9c0d2e7f1a36" } }, "process": { "name": "database_query", @@ -58,6 +61,8 @@ ACS events map to OCSF 1.5+ event classes. The normative class assignments and t }, "unmapped": { "acs": { + "request_id": "3f6c1e2a-8b4d-4c1e-9a7f-2d5b6e8c9a01", + "session_id": "6b1f2c9e-4d7a-4e3b-8a51-9c0d2e7f1a36", "step": { "id": "step-abc", "type": "toolCallRequest", "turn_id": "turn-456" }, "tool": { "id": "database_query", "execution_id": "exec-123", "capability": "datastore.read" }, "provenance": [ @@ -79,50 +84,80 @@ ACS events map to OCSF 1.5+ event classes. The normative class assignments and t "activity_id": 1, "activity_name": "Create", "time": 1706550000050, + "type_uid": 200401, "severity_id": 4, - "status_id": 1, - "metadata": { "version": "1.5.0", "product": { "name": "ACS Guardian", "vendor_name": "ACS" } }, + "action_id": 2, + "action": "Denied", + "disposition_id": 2, + "disposition": "Blocked", + "is_alert": true, + "metadata": { + "version": "1.9.0", + "profiles": ["security_control"], + "correlation_uid": "6b1f2c9e-4d7a-4e3b-8a51-9c0d2e7f1a36:7c4e9a1d-2f6b-4d8e-a3c5-1b9f0e7d6a52", + "product": { "name": "ACS Guardian", "vendor_name": "ACS" } + }, "finding_info": { "uid": "decision-001", "title": "Tool call denied: outside committed Intent", "desc": "email.send to recipient outside Intent.parsed; IBAC P-I check failed." }, + "evidences": [ + { + "ai_agent": { "uid": "agent-123" }, + "actor": { "session": { "uid": "6b1f2c9e-4d7a-4e3b-8a51-9c0d2e7f1a36" } }, + "api": { "operation": "email.send" } + } + ], + "authorizations": [ + { "policy": { "uid": "acme-ibac", "version": "v1" } } + ], "unmapped": { "acs": { "decision": "deny", - "evaluator": "deterministic", + "reasoning": "email.send to recipient outside Intent.parsed; IBAC P-I check failed.", + "metadata": { "evaluator": "deterministic" }, "policy_references": [ { "policy_id": "acme-ibac", "policy_version": "v1", "rule_id": "email-send-recipient-not-in-intent" } ], "reason_codes": ["ibac_capability_mismatch"], "cited_provenance_ids": ["p1"], - "session_id": "session-789", - "step_id": "step-abc" + "chain_hash": "9f2c4e7a1b3d5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8", + "request_id": "7c4e9a1d-2f6b-4d8e-a3c5-1b9f0e7d6a52", + "session_id": "6b1f2c9e-4d7a-4e3b-8a51-9c0d2e7f1a36" } } } ``` -## Example: AgBOM snapshot as Inventory Info (5001) +## Example: AgBOM snapshot as Device Inventory Info (5001) ```json { "category_uid": 5, "category_name": "Discovery", "class_uid": 5001, - "class_name": "Inventory Info", - "activity_id": 1, - "activity_name": "Log", + "class_name": "Device Inventory Info", + "activity_id": 2, + "activity_name": "Collect", "time": 1706549900000, + "type_uid": 500102, "severity_id": 1, - "metadata": { "version": "1.5.0" }, + "metadata": { + "version": "1.9.0", + "correlation_uid": "6b1f2c9e-4d7a-4e3b-8a51-9c0d2e7f1a36:8d2b7f40-1c3e-4a59-b6d8-0e9f1a2b3c4d", + "product": { "name": "ACS Guardian", "vendor_name": "ACS" } + }, "device": { "uid": "agent-123", "name": "CustomerServiceAgent", + "type_id": 99, "type": "AI Agent" }, "unmapped": { "acs": { + "request_id": "8d2b7f40-1c3e-4a59-b6d8-0e9f1a2b3c4d", + "session_id": "6b1f2c9e-4d7a-4e3b-8a51-9c0d2e7f1a36", "agbom": { "format": "canonical", "component_count": 7, @@ -140,7 +175,8 @@ ACS events map to OCSF 1.5+ event classes. The normative class assignments and t ## Implementation notes - **`actor.user.type_id: 99`** is the OCSF "Other" sentinel; pair with `actor.user.type: "AI Agent"` so SIEM filters can distinguish agent identities from human ones. -- **`unmapped.acs`** carries ACS-specific facts that don't have direct OCSF equivalents in v1.5. The structure mirrors the request envelope's `payload` plus the decision envelope's `policy_data` / `reason_codes`. Backends that index `unmapped` keep this fully searchable. +- **Native fields first.** SIEM content reads native fields, not `unmapped`. A decision event puts the join pair in `metadata.correlation_uid`, the agent and the gated action in `evidences`, and the policies in `authorizations`. The example above shows each, and the [Trace Events](./events.md#ocsf-event-classes) page has the rules, including what a late decision carries. +- **`unmapped.acs`** carries ACS-specific facts that don't have direct OCSF equivalents in OCSF 1.9.0. On a step event it carries `request_id`, `session_id`, and facts from the request that have no OCSF attribute of their own. On a decision event it carries the fields that `join_fields` and `acs_fields` in `ocsf-mapping.json` list. Backends that index `unmapped` keep this fully searchable. - **Provenance** flows onto OCSF events under `unmapped.acs.provenance`. Trust classification is omitted on the wire for v0.1; backends that compute trust apply it as an enrichment step. For more worked examples — including A2A and MCP wrapped events — see [Implementation examples](./OCSF/implementation_examples.md). diff --git a/docs/spec/trace/extend_opentelemetry.md b/docs/spec/trace/extend_opentelemetry.md index 90c14463..dfb27c04 100644 --- a/docs/spec/trace/extend_opentelemetry.md +++ b/docs/spec/trace/extend_opentelemetry.md @@ -10,20 +10,251 @@ A typical agent session produces a tree of spans: - A root **session** span (`acs.session`) opened by `steps/sessionStart` and closed by `steps/sessionEnd`. Carries `acs.session.id` and (when set) `acs.tenant_id`. - One **turn** span (`acs.turn`) per agent turn, opened by `steps/turnStart` and closed by `steps/turnEnd`. Carries `acs.turn.id`. Nested turns (e.g. subagent turns inside a parent turn awaiting `subagentStop`) record `acs.turn.parent_id`. -- One **step** span per `steps/*` hook, parented to the enclosing turn (or session when the step is session-level: `agentTrigger`, `agbom/*` outside a turn). -- Each **decision** is a span event on the step span carrying `acs.decision`, `acs.evaluator` (`deterministic` / `agent` / `composite`), `acs.reasoning` (when present), and `acs.confidence` (when present). Decisions are not separate spans because the verdict and the action it gates share a parent. +- One **step** span per `steps/*` hook, parented to the enclosing turn (or session when the step is session-level: `agentTrigger`, `agbom/*` outside a turn). Every step span carries `acs.request_id` and `acs.session.id`, the pair a reader joins on. +- Each **decision** is a span event on the step span carrying `acs.decision`, plus `acs.evaluator` (`deterministic` / `agent` / `composite`), `acs.reasoning`, and `acs.confidence` when the response carries them. The replay attributes on [Trace Events](./events.md) ride the same event. Decisions are not separate spans because the verdict and the action it gates share a parent. +- A **decision failure** is a separate span event, `acs.decision.failure`, on the same step span. It records that no usable decision arrived and the deployment's failure posture. This hierarchy is deterministic — implementations that follow the mapping produce comparable traces across vendors and Guardians. +## Example: a denied tool call + +This is one span the reference Guardian exported, a `steps/toolCallRequest` the stock policy denied, copied from the committed [sample trace](https://github.com/GenAI-Security-Project/agent-control-standard/blob/main/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl). Every value in it is synthetic. It's the OTLP/JSON shape the exporter writes, so attribute values are typed (`stringValue`, `arrayValue`). + +The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool name, and the two Provenance columns. `acs.provenance.source_id` has `""` where the second object has no `source_id`. The decision rides on the span as the `acs.decision` event, with the verdict, the reasoning, the reason codes and the three policy arrays. `acs.policy.versions` is `[""]`, because this Guardian doesn't send a policy version. + +```json +{ + "traceId": "85c4878c9bda9e5b4f2fc6aae242d31f", + "spanId": "dea6530ad1681c0e", + "name": "gen_ai.tool.call", + "kind": 1, + "startTimeUnixNano": "1791928279698000000", + "endTimeUnixNano": "1791928279749301083", + "attributes": [ + { + "key": "acs.session.id", + "value": { + "stringValue": "5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01" + } + }, + { + "key": "acs.request_id", + "value": { + "stringValue": "1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b03" + } + }, + { + "key": "gen_ai.tool.name", + "value": { + "stringValue": "Bash" + } + }, + { + "key": "acs.provenance.origin", + "value": { + "arrayValue": { + "values": [ + { + "stringValue": "user_input" + }, + { + "stringValue": "agent_generated" + } + ] + } + } + }, + { + "key": "acs.provenance.source_id", + "value": { + "arrayValue": { + "values": [ + { + "stringValue": "sample-user-1" + }, + { + "stringValue": "" + } + ] + } + } + } + ], + "droppedAttributesCount": 0, + "events": [ + { + "attributes": [ + { + "key": "acs.decision", + "value": { + "stringValue": "deny" + } + }, + { + "key": "acs.reasoning", + "value": { + "stringValue": "This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0." + } + }, + { + "key": "acs.reason_codes", + "value": { + "arrayValue": { + "values": [ + { + "stringValue": "destructive_shell_command_blocked" + } + ] + } + } + }, + { + "key": "acs.policy.ids", + "value": { + "arrayValue": { + "values": [ + { + "stringValue": "agt_stock" + } + ] + } + } + }, + { + "key": "acs.policy.versions", + "value": { + "arrayValue": { + "values": [ + { + "stringValue": "" + } + ] + } + } + }, + { + "key": "acs.policy.rule_ids", + "value": { + "arrayValue": { + "values": [ + { + "stringValue": "destructive_shell_command_blocked" + } + ] + } + } + } + ], + "name": "acs.decision", + "timeUnixNano": "1791928279749295500", + "droppedAttributesCount": 0 + } + ], + "droppedEventsCount": 0, + "status": { + "code": 0 + }, + "links": [], + "droppedLinksCount": 0, + "flags": 257 +} +``` + +To produce a trace like this yourself, follow [Produce a trace](https://github.com/GenAI-Security-Project/agent-control-standard/blob/main/reference-implementations/agt/README.md#produce-a-trace) in the reference implementation's README. + +## How the reference Guardian produces a trace + +The span above is real output from the reference Guardian. This section explains the pipeline behind it, so the span hierarchy and the decision events on this page map onto processes and files you can inspect. The [reference Guardian](https://github.com/GenAI-Security-Project/agent-control-standard/blob/main/reference-implementations/agt/packages/guardian/README.md#export-traces) exports spans for the two hook methods it evaluates, `steps/toolCallRequest` and `steps/toolCallResult`. It makes no ACS-Trace conformance claim. The commands to run it live in [Produce a trace](https://github.com/GenAI-Security-Project/agent-control-standard/blob/main/reference-implementations/agt/README.md#produce-a-trace), and this section doesn't repeat them. + +### 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. + +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`. + +A reader joins the two streams on `acs.session.id` plus `acs.request_id`, the pair every step span carries. The reference exporter makes each step span a root span, with no `acs.session` or `acs.turn` parent, so the join runs on those attributes, not on `traceId`. + +In the diagram, look for the two separate paths out of the host shim. They meet only at the reader. + +```mermaid +flowchart LR + shim["Host shim"] -->|"JSON-RPC request"| guardian["Guardian"] + guardian --> exporter["Trace exporter"] + exporter -->|"ACS_OTEL_FILE"| gfile[".acs/otel-traces.jsonl"] + exporter -->|"ACS_OTEL_ENDPOINT"| collector["OpenTelemetry Collector"] + collector --> cfile[".acs/otel-collector-traces.jsonl"] + shim -->|"audit entries"| audit[".acs/audit.jsonl"] + audit --> converter["audit-to-otlp.ts"] + converter --> afile[".acs/otel-audit.jsonl"] + gfile --> reader["Reader joins on
acs.session.id + acs.request_id"] + cfile --> reader + afile --> reader +``` + +### Follow one tool call + +The span's lifetime follows the Guardian's envelope log. It opens when the Guardian records the request and ends when the Guardian records its response, before the response goes out. Export happens later, on a timer, so serializing and writing spans stay off the decision path. + +The sequence below follows one `steps/toolCallRequest`. Watch the `alt` block: the top branch is the working path, and the two lower branches show who reports the failure when no usable decision arrives. + +```mermaid +sequenceDiagram + participant Shim as Host shim + participant G as Guardian + participant X as Trace exporter + participant B as Batch processor + participant Out as File or collector + Shim->>G: steps/toolCallRequest + G->>X: request recorded, span starts + alt decision returned in time + G->>X: response recorded + X->>X: add acs.decision event, end span + G-->>Shim: decision + else Guardian returns an error or decides late + G->>X: response recorded + X->>X: add acs.decision.failure, reported_by guardian, end span + G-->>Shim: error or late decision + else no decision reaches the host + Shim->>Shim: apply posture, write audit.jsonl entry + Note over Shim: the converter later emits acs.decision.failure, reported_by observed_agent + end + X->>B: queue ended span + B->>Out: export batch about every 5 s, and at shutdown +``` + +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. + +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. + +### What each output is for + +`.acs/otel-traces.jsonl` is the Guardian's own file, written when `ACS_OTEL_FILE` is set. It holds one OTLP `ExportTraceServiceRequest` per line, the shape the OpenTelemetry Collector's file exporter writes. It's the file to read offline or hand to a reviewer. + +The collector path, set with `ACS_OTEL_ENDPOINT`, sends the same spans over OTLP/HTTP. The committed [`otel-collector.yaml`](https://github.com/GenAI-Security-Project/agent-control-standard/blob/main/reference-implementations/agt/otel-collector.yaml) writes them to `.acs/otel-collector-traces.jsonl`. Use it as an independent check that the output is valid OTLP, and as the starting point for sending spans to a real backend. + +`.acs/otel-audit.jsonl` comes from the converter, not the Guardian. It holds only the host-side failures, and it matters most when the Guardian was down. The converter runs after the session ends and refuses to write to a file that already exists. Its spans carry `service.name` `acs-reference-audit-converter`, where the Guardian's carry `acs-reference-guardian`. + +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. + +### What the labels don't prove + +`service.name` tells you which writer claims a span. It isn't authentication. Any process running as the same user, the observed agent included, can write the trace files, the audit log, or the collector's loopback port. In a container, other containers on the same network can reach that port too. A loopback address isn't authentication either. + +`acs.failure.reported_by` is the emitter's own claim too. The trace is a record of what each party says happened, not tamper-evident evidence. Treat every value in it as untrusted data, as [Trace Events](./events.md) requires. + ## Provenance attributes -When a hook payload carries Provenance, the resulting span MUST carry `acs.provenance.origin` and SHOULD carry `acs.provenance.source_id` and `acs.provenance.lineage_depth`. The set of provenance ids cited by a Guardian decision (the response envelope's `cited_provenance_ids`) MAY be expressed as OTel span links keyed by `provenance_id`, letting tools that index span links reconstruct the lineage graph without parsing payloads. +When a hook payload carries Provenance, the resulting span MUST carry `acs.provenance.origin` and SHOULD carry `acs.provenance.source_id` when populated. The set of provenance ids cited by a Guardian decision (the response envelope's `cited_provenance_ids`) MAY be expressed as OTel span links keyed by `provenance_id`, letting tools that index span links reconstruct the lineage graph without parsing payloads. v0.1 emits factual provenance attributes only. Trust classification is computed by the Guardian against local policy and is not a v0.1 span attribute. ## Sensitive data -Attributes can carry user prompts, tool arguments, and retrieved knowledge. Implementations SHOULD apply the deployment's redaction or hashing policy at attribute-emit time rather than relying on backend-side scrubbing. The Guardian's `modifications.redactions` payload is the natural input to the policy: the same list that drives content rewriting drives attribute redaction. +Attributes can carry user prompts, tool arguments, and retrieved knowledge. Any of it can carry text an attacker influenced. [Trace Events](./events.md) says how consumers treat it. Implementations SHOULD apply the deployment's redaction or hashing policy at attribute-emit time rather than relying on backend-side scrubbing. The Guardian's `modifications.redactions` payload is the natural input to the policy: the same list that drives content rewriting drives attribute redaction. ## Transport diff --git a/pyproject.toml b/pyproject.toml index bed93b4b..52430de4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "acs" -version = "0.1.3" +version = "0.1.4" description = "ACS Documentation" license = "Apache-2.0" requires-python = ">=3.11" diff --git a/reference-implementations/agt/README.md b/reference-implementations/agt/README.md index bb69941b..cdc6a862 100644 --- a/reference-implementations/agt/README.md +++ b/reference-implementations/agt/README.md @@ -156,6 +156,8 @@ This registers `hosts/claude-code/acs-hook.ts` on two Claude Code hook events. ` claude ``` +Start it from a shell where `127.0.0.1` and `localhost` are in both `NO_PROXY` and `no_proxy`, as step 1 of [Produce a trace](#produce-a-trace) sets them. Otherwise an `HTTP_PROXY` in that shell sends every hook payload, commands and tool output included, through the proxy on its way to the Guardian. + 3. Ask Claude Code to run a destructive command, for example `echo rm -rf /`. The hook sends the call to the Guardian. AGT's stock pattern rule denies it. Claude Code shows the policy reason in the transcript. Ask for `ls -la` in the same session and it runs. Use `echo rm -rf /` rather than `rm -rf /` when you script this. The pattern matches at offset 5 and the command is inert if the hook ever fails. @@ -193,6 +195,127 @@ echo '{"session_id":"demo","hook_event_name":"PreToolUse","tool_name":"Bash","to The shim never runs the command. It only asks the Guardian. +## Produce a trace + +This walks you from a fresh clone to an OpenTelemetry trace file of your own, in about ten minutes. You don't need Claude Code or a collector for it. Every command runs from `reference-implementations/agt` unless the step says otherwise. + +The steps below write four files under `.acs/`. The diagram shows which process writes each one and in which step, so you know what to stop before you delete anything. + +```mermaid +flowchart LR + shim["Claude Code shim
(steps 3 and 6)"] -->|"hook payloads"| guardian["Guardian
(step 2)"] + guardian -->|"ACS_OTEL_FILE"| traces[".acs/otel-traces.jsonl"] + shim -->|"steps with no decision"| audit[".acs/audit.jsonl"] + audit --> converter["audit-to-otlp.ts
(step 6)"] + converter --> auditotlp[".acs/otel-audit.jsonl"] + guardian -->|"ACS_OTEL_ENDPOINT"| collector["otelcol-contrib
(step 7, optional)"] + collector --> coltraces[".acs/otel-collector-traces.jsonl"] +``` + +The Guardian's file and the converter's file share a format and a join key, so you can read them as one trace. The collector's file holds Guardian spans too, received over OTLP/HTTP instead of written directly. + +If you only want to see what the output looks like, open [`samples/trace/acs-trace-sample.jsonl`](samples/trace/acs-trace-sample.jsonl). It's a committed run of the same exporter. + +**1. Get the code.** You need `git` and [Bun](https://bun.sh) 1.3 (CI runs 1.3.11). If someone gave you a commit to use, check it out, so your results match theirs. + +```bash +git clone https://github.com/GenAI-Security-Project/agent-control-standard.git +cd agent-control-standard +git checkout # skip this line to use the latest integration +cd reference-implementations/agt +bun install --frozen-lockfile +mkdir -p .acs && chmod 700 .acs # owner-only, even if .acs already existed +export NO_PROXY="${NO_PROXY:+$NO_PROXY,}${no_proxy:+$no_proxy,}127.0.0.1,localhost" +export no_proxy="$NO_PROXY" +``` + +Run every later step in a terminal where you've run the two `export` lines. Without them, an `HTTP_PROXY` in your shell sends the shim's requests and the Guardian's spans to the proxy. The lines keep the exemptions you already had and add this machine to both variables, because Bun reads `no_proxy` in preference to `NO_PROXY` when both are set. They need bash or zsh. + +**2. Start the Guardian with trace export on.** Leave it running in its own terminal. + +```bash +ACS_OTEL_FILE=.acs/otel-traces.jsonl bun run guardian +``` + +**3. Send it some steps.** In a second terminal, pipe hook payloads through the Claude Code shim. The shim asks the Guardian and prints its answer, and it never runs the command. These three give you an allowed call, a denied call and a tool result. + +```bash +echo '{"session_id":"trace-demo","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls -la"}}' \ + | bun run hosts/claude-code/acs-hook.ts +echo '{"session_id":"trace-demo","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \ + | bun run hosts/claude-code/acs-hook.ts +echo '{"session_id":"trace-demo","hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"ls -la"},"tool_response":{"stdout":"README.md"}}' \ + | bun run hosts/claude-code/acs-hook.ts +``` + +Use made-up session ids and commands, because the trace records what you send. + +**4. Stop the Guardian** with Ctrl-C in its terminal. Spans are written in batches, and stopping the Guardian writes the last one. + +**5. Read the trace.** `.acs/otel-traces.jsonl` holds one OTLP export request per line. This prints one line per span: its name, then the decision, or the failure class when no decision arrived. + +```bash +bun -e 'const clean = (s) => String(s ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, "?"); let bad = 0; for (const l of (await Bun.file(process.argv[1]).text()).split("\n").filter(Boolean)) { try { const req = JSON.parse(l); for (const r of req.resourceSpans) for (const s of r.scopeSpans) for (const p of s.spans) { const v = (e, k) => e.attributes?.find((a) => a.key === k)?.value.stringValue; console.log(clean(p.name), (p.events ?? []).map((e) => clean(v(e, "acs.decision") ?? v(e, "acs.failure.class"))).join(" ")) } } catch { bad++ } } if (bad) console.log(`${bad} line(s) were not readable and were skipped`)' .acs/otel-traces.jsonl +``` + +To read the collector's file in step 7, run the same command with `.acs/otel-collector-traces.jsonl` at the end. + +You should see two `gen_ai.tool.call` spans, one `allow` and one `deny`, and one `gen_ai.tool.result` span with `allow`. The spans carry the attributes [`trace/otel-mapping.json`](../../specification/v0.1.0/trace/otel-mapping.json) lists, and the [Guardian README](packages/guardian/README.md#export-traces) says which ones appear when. + +**6. Capture the failures only the host sees.** With the Guardian stopped, send one more step. The shim can't reach the Guardian, so it proceeds under the posture the Guardian declared when the session started, which is `proceed` for the stock Guardian, and records that in `.acs/audit.jsonl`. The converter turns that record into a span in the same format. + +```bash +echo '{"session_id":"trace-demo","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}' \ + | bun run hosts/claude-code/acs-hook.ts +bun run packages/guardian/src/audit-to-otlp.ts .acs/audit.jsonl .acs/otel-audit.jsonl +``` + +The converter prints how many spans it wrote. Its spans carry the same session and request ids as the Guardian's, so you can read both files as one trace. + +Run the converter after the agent session has ended. It refuses to write to a file that already exists, so it can't append into the audit log or the Guardian's trace. Delete `.acs/otel-audit.jsonl` first if you're running it again. If its summary shows a non-zero replay count, those were steps that proceeded without a decision that this trace doesn't show yet ([issue #274](https://github.com/GenAI-Security-Project/agent-control-standard/issues/274)). + +**7. Check it with a real collector (optional).** If you want an independent check that the output is valid OTLP, install [otelcol-contrib](https://github.com/open-telemetry/opentelemetry-collector-releases/releases) 0.162.0, which the repository was tested with, and send the spans to it, not to a file. Check that nothing else is listening first: `lsof -nP -iTCP:4318 -sTCP:LISTEN` should print nothing. + +```bash +umask 077 +otelcol-contrib --config otel-collector.yaml # wait for "Everything is ready" +NO_PROXY=127.0.0.1,localhost no_proxy=127.0.0.1,localhost \ + ACS_OTEL_ENDPOINT=http://127.0.0.1:4318/v1/traces bun run guardian # in another terminal +``` + +To run the collector in Docker instead, from `reference-implementations/agt`: + +```bash +docker run --rm --name acs-otelcol --user "$(id -u):$(id -g)" -p 127.0.0.1:4318:4318 \ + -v "$PWD/otel-collector.yaml":/etc/otelcol-contrib/config.yaml:ro \ + -v "$PWD/.acs":/work/.acs -w /work \ + otel/opentelemetry-collector-contrib:0.162.0@sha256:39923a8e431bd1f57be82411999d389fcfe40857492e4365456d97a4c1f74be6 \ + --config /etc/otelcol-contrib/config.yaml \ + --set=receivers::otlp::protocols::http::endpoint=0.0.0.0:4318 +``` + +With Podman, use the same command with `podman` in place of `docker`, plus `--userns=keep-id --umask 077`, and `:z` on both mounts on an SELinux host. + +Start the collector before the Guardian. The Guardian stops sending to an endpoint after its first failed send, and prints `disabled after failure` once. If you see that, or you restart the collector, stop the Guardian, restart the collector, then start the Guardian again. + +Repeat step 3. The collector writes what it received to `.acs/otel-collector-traces.jsonl`. That file can still be empty right after you repeat step 3, because spans go out in batches about every five seconds. + +Stop the collector with Ctrl-C or `docker stop acs-otelcol`, never `kill -9` or `docker kill`. A hard kill can leave half a line, and the next batch the restarted collector writes is joined onto it. The step 5 reader skips that combined line and tells you, but the spans in it are lost. + +The collector appends across restarts. Natively under `umask 077` the file is `0600`. In a container it's `0644`, and the `0700` `.acs` directory is what keeps other users out ([issue #71](https://github.com/GenAI-Security-Project/agent-control-standard/issues/71) tracks file modes). + +The `export` lines from step 1 keep the spans off any `HTTP_PROXY`, and so does the inline prefix on the Guardian's line above, because some Bun versions read these variables only when a process starts. The Guardian also adds a loopback endpoint to whichever of `NO_PROXY` and `no_proxy` you set, which takes effect on Bun 1.3.14 and later. When a proxy is set and loopback isn't exempt, it prints a warning on stderr when it starts. `OTEL_EXPORTER_OTLP_*HEADERS` variables still add headers to every send, so unset them if you don't mean them. + +If `.acs/otel-collector-traces.jsonl` doesn't appear within about ten seconds of step 3, the collector isn't running this config. + +Verified on macOS (arm64): natively with otelcol-contrib 0.162.0, and with the Docker command above on Docker Desktop for Mac. Rootless Docker, Podman, SELinux hosts and musl-based Linux haven't been verified. + +**Run it again.** Every output here except the converter's appends, so a second run on the same `.acs` doubles every count, and the converter refuses to run at all. Stop the Guardian and the collector first, with Ctrl-C or `docker stop acs-otelcol`. A process that is still running keeps writing into the deleted file, and its spans are lost without an error. Then delete it: `rm -rf .acs && mkdir -p .acs && chmod 700 .acs`. + +**What this is, and isn't.** The exporter covers the two hook methods this Guardian evaluates, `steps/toolCallRequest` and `steps/toolCallResult`. It isn't an ACS-Trace conformance claim. Traces driven through the shim also lack `acs.capability` and `acs.request_id_ref`, because the shim doesn't send them yet ([issue #274](https://github.com/GenAI-Security-Project/agent-control-standard/issues/274)). The committed sample drives the Guardian directly, so it has both. Spans also lack `acs.evaluator` until the Guardian sends `metadata.evaluator` (PR #229). + +The trace files, the audit log and the collector's port can be written by any process running as you, the observed agent included, and in a container by other containers on the same Docker network, so the output isn't tamper-evident. `service.name` (`acs-reference-guardian`, `acs-reference-audit-converter`) is a label each writer sets for itself, not authentication, and loopback isn't authentication either. These labels belong to the reference implementation, not to the ACS specification. + ## View the contract The contract has four layers in this tree. Read them in this order. diff --git a/reference-implementations/agt/bun.lock b/reference-implementations/agt/bun.lock index cc2dc5a0..5058e1e1 100644 --- a/reference-implementations/agt/bun.lock +++ b/reference-implementations/agt/bun.lock @@ -57,6 +57,12 @@ "name": "guardian", "version": "0.1.0", "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", @@ -93,6 +99,30 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.15", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-8sfo9nGiVwesAZW9Wqkvynyn7w4wYaHx1O9qOHpYL65+Bs2XUpHP3kBbZe58gjQydFgk6I74kUF7sqCiPu2arQ=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.223.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Oepm6etVPFCn0VWB1t833iOTgz1eYBDUPxcrOR/XvwzD1+Ag9VzSPSrrKAFNJ/haatj+8CXbG47pZXvdNQKDJw=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.12.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ=="], + + "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.223.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.223.0", "@opentelemetry/otlp-transformer": "0.223.0", "@opentelemetry/sdk-trace": "2.12.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-N8NWZaoQuF9e307S6xoVoI1lvxOOFkmBnynD42r02CPCfxoC95cJExAKBFrTD4C5iuhqthbzOZJN/W29ez4o/A=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.223.0", "", { "dependencies": { "@opentelemetry/core": "2.12.0", "@opentelemetry/otlp-transformer": "0.223.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-HlJMrJXjOqdQeoYNDHEvFTpaY2p9uWDK1GPl5AZ4wI5easzfoppTduOv47M8eiF+4ew42vfI/Xvt7THYgESnRw=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.223.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.223.0", "@opentelemetry/core": "2.12.0", "@opentelemetry/resources": "2.12.0", "@opentelemetry/sdk-logs": "0.223.0", "@opentelemetry/sdk-metrics": "2.12.0", "@opentelemetry/sdk-trace": "2.12.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-oxmmPL5/MbSsO8K588V9n/XLIe7FZTK30Fo+/U/lAnt0ZIK/hoPM03n2WpSTZVbUBGTUE7aLyxnAzh7nE2PD4Q=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.12.0", "", { "dependencies": { "@opentelemetry/core": "2.12.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.223.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.223.0", "@opentelemetry/core": "2.12.0", "@opentelemetry/resources": "2.12.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-ApiAfUiUh6fmJt1GbsBuoeDn8D8IL42UvGyPUr8o1Z89ZeIiOCE1rZ78KkIUjEX3OV8Oorl9ez5Qjo43ZynDyw=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.12.0", "", { "dependencies": { "@opentelemetry/core": "2.12.0", "@opentelemetry/resources": "2.12.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-mVMaEHwCUJuY3S4Esn+nT0yQA3qCEGh+SOPJsXygU0Gl453WcsDNjtEa8EfdewMwoLyr1tlofB4fXytVpiXuow=="], + + "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.12.0", "", { "dependencies": { "@opentelemetry/core": "2.12.0", "@opentelemetry/resources": "2.12.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.12.0", "", { "dependencies": { "@opentelemetry/core": "2.12.0", "@opentelemetry/resources": "2.12.0", "@opentelemetry/sdk-trace": "2.12.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], diff --git a/reference-implementations/agt/otel-collector.yaml b/reference-implementations/agt/otel-collector.yaml new file mode 100644 index 00000000..29a3279c --- /dev/null +++ b/reference-implementations/agt/otel-collector.yaml @@ -0,0 +1,56 @@ +# 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. Tested with otelcol-contrib 0.162.0, which +# is where the `file` exporter lives. The walkthrough in README.md +# ("Produce a trace", step 7) has the full sequence. In short: +# +# Native: +# otelcol-contrib --config otel-collector.yaml +# +# Docker (run from reference-implementations/agt, after +# mkdir -p .acs && chmod 700 .acs): +# docker run --rm --name acs-otelcol --user "$(id -u):$(id -g)" -p 127.0.0.1:4318:4318 \ +# -v "$PWD/otel-collector.yaml":/etc/otelcol-contrib/config.yaml:ro \ +# -v "$PWD/.acs":/work/.acs -w /work \ +# otel/opentelemetry-collector-contrib:0.162.0@sha256:39923a8e431bd1f57be82411999d389fcfe40857492e4365456d97a4c1f74be6 \ +# --config /etc/otelcol-contrib/config.yaml \ +# --set=receivers::otlp::protocols::http::endpoint=0.0.0.0:4318 +# +# Podman: the same, plus --userns=keep-id --umask 077, and :z on both +# mounts on an SELinux host. +# +# The endpoint below is loopback and literal on purpose. Inside a container +# the receiver must listen on 0.0.0.0, and that override lives only on the +# container command line above, where -p 127.0.0.1:... keeps the host side +# on loopback. Never set it for a native run: it listens on every interface. +# Loopback is not authentication: any local process can send to this port, +# and in a container so can other containers on the same Docker network. +receivers: + otlp: + protocols: + http: + endpoint: 127.0.0.1:4318 + +exporters: + file: + path: .acs/otel-collector-traces.jsonl + # Keep what earlier runs wrote: without this a restart truncates the file. + append: true + # A fresh checkout has no .acs/ yet. 0700 applies only when the collector + # creates it. The walkthrough creates it first and sets 0700 itself. + create_directory: true + directory_permissions: "0700" + # 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: + telemetry: + metrics: + level: none + pipelines: + traces: + receivers: [otlp] + exporters: [file, debug] diff --git a/reference-implementations/agt/package.json b/reference-implementations/agt/package.json index 6b920dcb..2f5f915d 100644 --- a/reference-implementations/agt/package.json +++ b/reference-implementations/agt/package.json @@ -11,7 +11,8 @@ "conformance": "bash scripts/run-conformance.sh", "watch:upstream": "bash scripts/run-upstream-watch.sh", "guardian": "bun run packages/guardian/src/main.ts", - "inspector": "bun run packages/inspector/src/main.ts" + "inspector": "bun run packages/inspector/src/main.ts", + "sample:trace": "bun run scripts/make-sample-trace.ts" }, "dependencies": { "agent-control-specification": "0.3.1-beta.0" diff --git a/reference-implementations/agt/packages/conformance/README.md b/reference-implementations/agt/packages/conformance/README.md index 62087828..e6b93f3f 100644 --- a/reference-implementations/agt/packages/conformance/README.md +++ b/reference-implementations/agt/packages/conformance/README.md @@ -27,6 +27,8 @@ The runner starts its own Guardian on an OS-assigned port for the one check that **The trace-pillar rows** list, for each of the six methods in scope, which OpenTelemetry attributes a consumer could emit from the envelopes alone. This tree does not claim the Trace profile, and the rows measure that non-claim. +Each row comes from the mapping's required or conditional list for that span. ✔ means a consumer can always emit the attribute. ○ means the attribute is conditional, so the envelope may omit its source and the exporter emits it only when present. ✖ means the mapping requires an attribute the wire doesn't guarantee, which is a mapping defect. + ## The checks | Check | What it measures | diff --git a/reference-implementations/agt/packages/conformance/src/render.ts b/reference-implementations/agt/packages/conformance/src/render.ts index ccbeeeca..b8819b67 100644 --- a/reference-implementations/agt/packages/conformance/src/render.ts +++ b/reference-implementations/agt/packages/conformance/src/render.ts @@ -278,8 +278,8 @@ export function renderCoverageMatrix(cells: CoverageMatrix, options: RenderOptio } /** - * Renders `checkTracePillar`'s `TraceRow[]` -- one line per required OTel - * attribute, against its v0.1.0 wire source and whether a downstream + * Renders `checkTracePillar`'s `TraceRow[]` -- one line per required or + * conditional OTel attribute, against its v0.1.0 wire source and whether a downstream * consumer of the ACS wire (not this Guardian) could emit it from that * source alone. A * trace row pairs an attribute with a wire source; a coverage cell pairs an @@ -303,10 +303,10 @@ export function renderCoverageMatrix(cells: CoverageMatrix, options: RenderOptio * row (`acs.capability`) is a smaller gap than an attribute with no wire * source at all, and the plain/coloured split is what keeps that * distinction visible at a glance rather than collapsing both into one - * undifferentiated ✖. None of the checker's own 17 rows takes this branch + * undifferentiated ✖. None of the checker's own rows takes this branch * today (`trace-pillar.ts`'s own test suite asserts and explains why -- every - * attribute currently in scope turned out to be a declared, merely optional - * property, not an absent one), so this split is exercised by a synthetic + * attribute currently in scope is a declared property, required or + * optional, never an absent one), so this split is exercised by a synthetic * fixture rather than by `checkTracePillar()`'s real output; it stays * general-purpose code because `resolveField` can still resolve a future * attribute to genuinely absent, and this renderer should not stop telling @@ -323,7 +323,12 @@ export function renderTraceRows(rows: TraceRow[], options: RenderOptions = {}): const spanWidth = Math.max(0, ...rows.map((row) => row.span.length)); const lines = rows.map((row) => { - const symbol = row.emittableByWireConsumer ? "✔" : "✖"; + // A conditional row that is not emittable is the list working as + // defined, so it gets its own symbol. ✖ stays reserved for a required + // attribute the wire need not carry, which is a mapping defect. + // A conditional row with no wire source at all is a defect, not the + // list working as defined, so only a present-but-optional one gets ○. + const symbol = row.emittableByWireConsumer ? "✔" : row.obligation === "conditional" && row.wireSource !== null ? "○" : "✖"; const painted = paint(symbol, row.wireSource === null ? YELLOW : null, color); const source = row.wireSource ?? "(no wire source)"; const suffix = row.reason !== undefined ? ` ${row.reason}` : ""; @@ -331,13 +336,14 @@ export function renderTraceRows(rows: TraceRow[], options: RenderOptions = {}): }); return [ - "Trace pillar (trace/otel-mapping.json): each required OTel attribute against its v0.1.0 wire source, and " + + "Trace pillar (trace/otel-mapping.json): each required or conditional OTel attribute against its v0.1.0 wire source, and " + "whether a downstream consumer of the ACS wire -- not this Guardian -- could emit it from that source " + "alone. This implementation does NOT claim the Trace pillar; this table is what that declaration is " + "measured against.", "", ...lines, "", - "Legend: ✔ emittable by a wire consumer alone ✖ not emittable (reason inline)", + "Legend: ✔ emittable by a wire consumer alone ○ conditional: emitted when the envelope carries it " + + "✖ required but not emittable (reason inline)", ].join("\n"); } diff --git a/reference-implementations/agt/packages/conformance/src/trace-pillar.ts b/reference-implementations/agt/packages/conformance/src/trace-pillar.ts index 4b16deeb..76edee91 100644 --- a/reference-implementations/agt/packages/conformance/src/trace-pillar.ts +++ b/reference-implementations/agt/packages/conformance/src/trace-pillar.ts @@ -1,7 +1,7 @@ /** * Whether a downstream consumer of the ACS v0.1.0 wire -- reading nothing - * this Guardian keeps process-local -- could emit each required Trace-pillar - * attribute `trace/otel-mapping.json` names. This is a check, not an + * this Guardian keeps process-local -- could emit each required or + * conditional Trace-pillar attribute `trace/otel-mapping.json` names. This is a check, not an * exporter: nothing here is named `exportTrace`, `traceExporter` or * `emitSpan`. Its render half, `renderTraceRows`, lives in `render.ts` -- * that file's own module header is why: `render.ts`'s only imports are @@ -11,7 +11,10 @@ * `otel-mapping.json` is a JSON **Schema**, not a data file -- its data * lives inside `default` keys, three places: * - `properties.step_to_span.default` -- an object keyed by ACS method, - * 18 entries, each `{span_name, required_attributes, optional_attributes?}`. + * 21 entries, each `{span_name, required_attributes, + * conditional_attributes?, optional_attributes?}`. + * - `properties.step_span_attributes.properties.required_attributes.default` + * -- one string array, the attributes every step span carries. * - `properties.decision_event.properties.{required_attributes, * conditional_attributes}.default` -- two string arrays. * - `properties.provenance_attributes.properties.required.default` -- one @@ -26,11 +29,12 @@ * `steps/sessionStart`, `steps/sessionEnd`, `steps/userMessage`, * `steps/agentResponse`). This implementation can only claim or decline the * Trace pillar for the methods it evaluates. `otel-mapping.json` declares - * eighteen; the other twelve (`steps/agentTrigger`, + * twenty-one; the other fifteen (`steps/agentTrigger`, * `steps/knowledgeRetrieval`, `steps/memoryStore`, * `steps/memoryContextRetrieval`, `steps/turnStart`, `steps/turnEnd`, * `steps/preCompact`, `steps/postCompact`, `steps/subagentStart`, - * `steps/subagentStop`, `agbom/snapshot`, `agbom/changed`) are out of scope + * `steps/subagentStop`, `steps/skillRegister`, `steps/skillLoad`, + * `steps/skillUnload`, `agbom/snapshot`, `agbom/changed`) are out of scope * for the same reason `mapping.yaml` never reaches them. The six are frozen * here rather than re-read from `mapping.yaml`: this module's interface * consumes nothing else, so the scope is a stated design fact (which methods @@ -49,19 +53,44 @@ * `otel-mapping.json` names `acs.capability`, not * `hooks/tool-call-request.json`'s `capability` field. * - * Decision facts (`acs.decision`, `acs.evaluator`, and the four conditional - * attributes) are recorded as a span EVENT named `acs.decision` on the - * parent step span -- not a span of their own -- so every row built from + * A step span's attributes come from two lists, read together: + * `required_attributes` (source field required on the wire) and + * `conditional_attributes` (#227: source field optional, so an exporter + * emits the attribute exactly when the payload carries it). Both are rows + * here, because both are attributes the mapping obliges an exporter to emit + * from the wire. A conditional row is expected to read "exists but is + * optional" -- that is the definition of the list -- so for it the red + * verdict is the mapping and the wire agreeing. A REQUIRED row that reads + * red is the finding: the mapping demands what the wire need not carry, + * which is how `acs.capability` read before #227 moved it. Each row + * carries the list it came from as `obligation`, so the renderer and the + * tests can tell the two cases apart rather than trusting this paragraph: + * every `required` row must read emittable, and the suite asserts it. + * `optional_attributes` names no obligation and is not read. + * + * Decision facts (`acs.decision` and the eleven conditional attributes) are + * recorded as a span EVENT named `acs.decision` on the parent step span -- + * not a span of their own -- so every row built from * `decision_event` carries that literal string as its `span`, matching * `otel-mapping.json`'s own `event_name` const. Provenance attributes are * plainer still: `provenance_attributes`'s own description says they land * as ordinary attributes "on the resulting span" whichever step that is, so * that row's `span` is a parenthesised description rather than a literal - * OTel name -- there is no single step span to point at. This is why - * `TraceRow.span` (below) is typed as a bare `string`: a caller reading it - * off a `TraceRow` this module returns cannot assume it is always a literal - * OTel span or event name -- one of the seventeen rows' `span` value (the - * provenance row) is not. + * OTel name -- there is no single step span to point at. `acs.request_id` + * and `acs.session.id` (`step_span_attributes`, #228) are the same shape: + * one row each, on every step span. This is why `TraceRow.span` (below) is + * typed as a bare `string`: a caller reading it off a `TraceRow` this module + * returns cannot assume it is always a literal OTel span or event name -- + * three rows' `span` values (the provenance row and the two every-step-span + * rows) are not. + * + * NOT MEASURED: `decision_failure_event` (`acs.decision.failure`, #37 and + * #228). That event records a decision that never arrived, or arrived + * unusable, so its attributes describe what the recording party saw, not a + * field of either envelope. "Could a wire consumer emit this from the wire" + * has no answer for it, and a row here would read as a gap that is not one. + * The test suite pins both halves: the mapping defines the event, and no + * row names it, so including it later is a deliberate change. * * WHAT `resolveField` DOES NOT MODEL: JSON Schema's conditional-requirement * keywords (`allOf`/`if`/`then`), which `response-envelope.json`'s @@ -84,7 +113,7 @@ * event) is this field present and unconditionally required", walking the * FULL path including whatever optional container sits in between -- * `acs.evaluator`'s path walks through `AcsResult.metadata`, itself optional - * on `AcsResult`, and that optionality is exactly what makes the row red. + * on `AcsResult`, and that optionality is exactly what makes the row read not emittable. * Provenance has no such always-present parent in scope: nothing in the six * in-scope spans' `required_attributes` names a `provenance` field, and * whether a `Provenance` object is attached at all is a per-argument, @@ -104,7 +133,7 @@ import { fileURLToPath } from "node:url"; export type TraceRow = { attribute: string; /** The OTel span or span-event name this attribute is recorded on -- - * EXCEPT for the provenance row, whose `span` is a parenthesised + * EXCEPT for the provenance and every-step-span rows, whose `span` is a parenthesised * description rather than a literal OTel name (module header): nothing in * `otel-mapping.json` names a span or event for provenance facts the way * `decision_event`'s `event_name` const names one for decision facts. A @@ -113,9 +142,15 @@ export type TraceRow = { span: string; wireSource: string | null; emittableByWireConsumer: boolean; + /** Which mapping list named the attribute. A `required` row that is not + * emittable is a defect in the mapping. A `conditional` row that is not + * emittable is the list working as defined (module header). */ + obligation: Obligation; reason?: string; }; +export type Obligation = "required" | "conditional"; + // The repository's own schemas, five directories up from this file; see // packages/guardian/src/validate-envelope.ts for the same relationship. const SPEC_ROOT = fileURLToPath(new URL("../../../../../specification/v0.1.0/", import.meta.url)); @@ -163,11 +198,21 @@ type SchemaNode = { $defs?: Record; }; -type StepSpanEntry = { span_name: string; required_attributes: string[]; optional_attributes?: string[] }; +type StepSpanEntry = { + span_name: string; + required_attributes: string[]; + conditional_attributes?: string[]; + optional_attributes?: string[]; +}; type OtelMappingSchema = { properties: { step_to_span: { default: Record }; + step_span_attributes: { + properties: { + required_attributes: { default: string[] }; + }; + }; decision_event: { properties: { required_attributes: { default: string[] }; @@ -269,7 +314,7 @@ type FieldSite = { label: string; }; -function buildRow(attribute: string, span: string, site: FieldSite): TraceRow { +function buildRow(attribute: string, span: string, obligation: Obligation, site: FieldSite): TraceRow { const { present, required } = resolveField(site.fileRoot, site.startNode, site.path); if (!present) { @@ -278,6 +323,7 @@ function buildRow(attribute: string, span: string, site: FieldSite): TraceRow { span, wireSource: null, emittableByWireConsumer: false, + obligation, reason: `${site.file} has no "${site.label}" property -- no wire source for ${attribute}`, }; } @@ -289,13 +335,14 @@ function buildRow(attribute: string, span: string, site: FieldSite): TraceRow { span, wireSource, emittableByWireConsumer: false, + obligation, reason: `${site.file}'s "${site.label}" exists but is optional there, so a conformant envelope may omit it -- ` + `a wire consumer cannot rely on ${attribute} being emitted`, }; } - return { attribute, span, wireSource, emittableByWireConsumer: true }; + return { attribute, span, wireSource, emittableByWireConsumer: true, obligation }; } export function checkTracePillar(): TraceRow[] { @@ -321,7 +368,8 @@ export function checkTracePillar(): TraceRow[] { const acsResult = responseEnvelope.$defs?.AcsResult; /** Resolves the wire site for each of the ten (span, attribute) pairs the - * six in-scope spans' `required_attributes` name. Domain knowledge -- + * six in-scope spans' `required_attributes` and `conditional_attributes` + * name. Domain knowledge -- * which file and field an OTel attribute NAME resolves to -- not * something any schema states directly, so it is written here once, by * hand, and checked by `resolveField` above rather than trusted. */ @@ -333,6 +381,8 @@ export function checkTracePillar(): TraceRow[] { return { file: "hooks/tool-call-request.json", fileRoot: toolCallRequest, startNode: toolCallRequest, path: ["capability"], label: "capability" }; case "gen_ai.tool.result::gen_ai.tool.name": return { file: "hooks/tool-call-result.json", fileRoot: toolCallResult, startNode: toolCallResult, path: ["tool", "name"], label: "tool.name" }; + case "gen_ai.tool.result::acs.request_id_ref": + return { file: "hooks/tool-call-result.json", fileRoot: toolCallResult, startNode: toolCallResult, path: ["request_id_ref"], label: "request_id_ref" }; case "gen_ai.tool.result::acs.exit_status": return { file: "hooks/tool-call-result.json", fileRoot: toolCallResult, startNode: toolCallResult, path: ["exit_status"], label: "exit_status" }; case "acs.session::acs.session.id": @@ -379,26 +429,59 @@ export function checkTracePillar(): TraceRow[] { throw new Error(`checkTracePillar: trace/otel-mapping.json's step_to_span.default has no entry for "${method}"`); } for (const attribute of entry.required_attributes) { - rows.push(buildRow(attribute, entry.span_name, stepAttributeSite(entry.span_name, attribute))); + rows.push(buildRow(attribute, entry.span_name, "required", stepAttributeSite(entry.span_name, attribute))); + } + for (const attribute of entry.conditional_attributes ?? []) { + rows.push(buildRow(attribute, entry.span_name, "conditional", stepAttributeSite(entry.span_name, attribute))); } } + // Attributes every step span carries, whichever step it is (module + // header) -- one row each, like the provenance row below. + const EVERY_STEP_SPAN = "(every step span)"; + for (const attribute of otelMapping.properties.step_span_attributes.properties.required_attributes.default) { + // Both sources are required on every step request, and every step + // request has AcsParams, so neither row can be omitted by a hook payload. + const site: Record = { + "acs.request_id": { path: ["request_id"], label: "AcsParams.request_id" }, + "acs.session.id": { path: ["metadata", "session_id"], label: "AcsParams.metadata.session_id" }, + }; + const known = site[attribute]; + if (known === undefined) { + throw new Error(`checkTracePillar: no known wire site for step_span_attributes attribute "${attribute}" -- add one here`); + } + rows.push( + buildRow(attribute, EVERY_STEP_SPAN, "required", { + file: "request-envelope.json", + fileRoot: requestEnvelope, + startNode: acsParams, + path: known.path, + label: known.label, + }), + ); + } + // Decision facts ride the "acs.decision" span EVENT, not a step span of // their own (module header). const DECISION_SPAN = "acs.decision"; /** Where each `decision_event` attribute lives on `AcsResult`. Explicit, * not derived from the attribute name: a mechanical `"acs.".length` slice - * would be wrong for four of these six, because `acs.evaluator`, + * would be wrong for four of these twelve, because `acs.evaluator`, * `acs.confidence`, `acs.evaluator_version` and `acs.model_id` are not * siblings of `AcsResult.decision` -- they live one level deeper, at * `AcsResult.metadata.` (verified by reading * `response-envelope.json`: `AcsResult.properties.metadata.properties` * lists all four; `AcsResult.required` and `metadata`'s own `required` - * are both silent on them, which is exactly what makes the row red under + * are both silent on them, which is exactly what makes the row read not emittable under * this module's rule -- metadata itself is optional on `AcsResult`, and * none of its own fields is required either). `acs.decision` and - * `acs.reasoning` remain direct `AcsResult` siblings. A `decision_event` + * `acs.reasoning` remain direct `AcsResult` siblings, as are the replay + * fields #228 added: `reason_codes`, `cited_provenance_ids`, `chain_hash`, + * and `policy_references`. The three `acs.policy.*` columns resolve to + * `policy_references` itself, not to one field of its entries: the + * padding rule emits all three whenever the array is non-empty, filling + * `""` for a missing field, so presence follows the array. A `decision_event` * attribute this table does not name throws, rather than silently * degrading to "no wire source" the way the mechanical derivation did. */ function decisionAttributeSite(attribute: string): FieldSite { @@ -439,6 +522,28 @@ export function checkTracePillar(): TraceRow[] { path: ["metadata", "model_id"], label: "AcsResult.metadata.model_id", }; + case "acs.reason_codes": + return { file: "response-envelope.json", fileRoot: responseEnvelope, startNode: acsResult, path: ["reason_codes"], label: "AcsResult.reason_codes" }; + case "acs.policy.ids": + case "acs.policy.versions": + case "acs.policy.rule_ids": + return { + file: "response-envelope.json", + fileRoot: responseEnvelope, + startNode: acsResult, + path: ["policy_references"], + label: "AcsResult.policy_references", + }; + case "acs.cited_provenance_ids": + return { + file: "response-envelope.json", + fileRoot: responseEnvelope, + startNode: acsResult, + path: ["cited_provenance_ids"], + label: "AcsResult.cited_provenance_ids", + }; + case "acs.chain_hash": + return { file: "response-envelope.json", fileRoot: responseEnvelope, startNode: acsResult, path: ["chain_hash"], label: "AcsResult.chain_hash" }; default: throw new Error( `checkTracePillar: no known wire site for decision_event attribute "${attribute}" -- add one to decisionAttributeSite`, @@ -447,9 +552,11 @@ export function checkTracePillar(): TraceRow[] { } const decisionEvent = otelMapping.properties.decision_event.properties; - const decisionAttributes = [...decisionEvent.required_attributes.default, ...decisionEvent.conditional_attributes.default]; - for (const attribute of decisionAttributes) { - rows.push(buildRow(attribute, DECISION_SPAN, decisionAttributeSite(attribute))); + for (const attribute of decisionEvent.required_attributes.default) { + rows.push(buildRow(attribute, DECISION_SPAN, "required", decisionAttributeSite(attribute))); + } + for (const attribute of decisionEvent.conditional_attributes.default) { + rows.push(buildRow(attribute, DECISION_SPAN, "conditional", decisionAttributeSite(attribute))); } // Provenance attributes land as ordinary attributes on whichever step @@ -462,7 +569,7 @@ export function checkTracePillar(): TraceRow[] { for (const attribute of otelMapping.properties.provenance_attributes.properties.required.default) { const field = attribute.slice("acs.provenance.".length); rows.push( - buildRow(attribute, PROVENANCE_SPAN, { + buildRow(attribute, PROVENANCE_SPAN, "required", { file: "provenance.json", fileRoot: provenance, startNode: provenance, diff --git a/reference-implementations/agt/packages/conformance/test/trace-pillar.test.ts b/reference-implementations/agt/packages/conformance/test/trace-pillar.test.ts index c856d7b8..2f0cb139 100644 --- a/reference-implementations/agt/packages/conformance/test/trace-pillar.test.ts +++ b/reference-implementations/agt/packages/conformance/test/trace-pillar.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { loadMapping } from "guardian"; import { renderTraceRows } from "../src/render.ts"; import { checkTracePillar, METHODS_IN_SCOPE, type TraceRow } from "../src/trace-pillar.ts"; @@ -55,7 +57,7 @@ describe("checkTracePillar resolves every row against the v0.1.0 schema files at // "present but optional" case, the same as acs.capability above, not // "no such property at all". `metadata` itself is absent from // `AcsResult.required`, and `metadata`'s own schema declares no - // `required` list either, which is what makes the row red: a + // `required` list either, which is what makes the row read not emittable: a // conformant envelope may omit `metadata` entirely, or include it // without `evaluator`. const row = only("acs.evaluator"); @@ -88,6 +90,96 @@ describe("checkTracePillar resolves every row against the v0.1.0 schema files at expect(only("acs.model_id").wireSource).toContain("AcsResult.metadata.model_id"); }); + it("the six replay attributes #228 added are present-but-optional on AcsResult -- none is absent, so none throws or reads 'no wire source'", () => { + const sites: Record = { + "acs.reason_codes": "AcsResult.reason_codes", + // All three follow the array's presence (padding rule), so each + // resolves to policy_references itself. + "acs.policy.ids": "AcsResult.policy_references", + "acs.policy.versions": "AcsResult.policy_references", + "acs.policy.rule_ids": "AcsResult.policy_references", + "acs.cited_provenance_ids": "AcsResult.cited_provenance_ids", + "acs.chain_hash": "AcsResult.chain_hash", + }; + for (const [attribute, site] of Object.entries(sites)) { + const row = only(attribute); + expect(row.span).toBe("acs.decision"); + expect(row.emittableByWireConsumer).toBe(false); + expect(row.wireSource).toBe(`response-envelope.json#${site}`); + expect(row.reason).toMatch(/optional/i); + } + }); + + it("acs.request_id is emittable on every step span -- AcsParams.request_id is unconditionally required", () => { + const row = only("acs.request_id"); + expect(row.span).toBe("(every step span)"); + expect(row.emittableByWireConsumer).toBe(true); + expect(row.wireSource).toBe("request-envelope.json#AcsParams.request_id"); + expect(row.reason).toBeUndefined(); + }); + + it("acs.session.id rides every step span too -- AcsParams.metadata.session_id is unconditionally required, so a join can be scoped to its session", () => { + const row = rows.find((r) => r.attribute === "acs.session.id" && r.span === "(every step span)"); + expect(row?.emittableByWireConsumer).toBe(true); + expect(row?.wireSource).toBe("request-envelope.json#AcsParams.metadata.session_id"); + }); + + it("each acs.policy.* column names a declared policy_references entry field -- the rows resolve to the array, so this guards the member fields the padding rule reads", () => { + type Item = { properties: Record }; + const envelopePath = fileURLToPath(new URL("../../../../../specification/v0.1.0/response-envelope.json", import.meta.url)); + const envelope = JSON.parse(readFileSync(envelopePath, "utf8")) as { + $defs: { AcsResult: { properties: { policy_references: { items: Item } } } }; + }; + const item = envelope.$defs.AcsResult.properties.policy_references.items.properties; + for (const field of ["policy_id", "policy_version", "rule_id"]) { + expect(item[field]?.type).toBe("string"); + } + }); + + it("acs.decision.failure is defined in the mapping and deliberately not measured -- no row names the event or its attributes", () => { + // Module header, "NOT MEASURED". Both halves are pinned so that adding + // the event to the checker, or dropping it from the mapping, is a + // deliberate change rather than drift. + type FailureEvent = { + properties: { + event_name: { const: string }; + required_attributes: { default: string[] }; + conditional_attributes: { default: string[] }; + attribute_values: { default: Record }; + }; + }; + const mappingPath = fileURLToPath(new URL("../../../../../specification/v0.1.0/trace/otel-mapping.json", import.meta.url)); + const failure = (JSON.parse(readFileSync(mappingPath, "utf8")) as { properties: { decision_failure_event: FailureEvent } }) + .properties.decision_failure_event; + expect(failure.properties.event_name.const).toBe("acs.decision.failure"); + expect(failure.properties.required_attributes.default).toEqual(["acs.failure.class", "acs.failure.posture", "acs.failure.reported_by"]); + // The names and values Bar fixed on #37. An emitter hardcoding them + // (#244) breaks silently if these change, so a change must be deliberate. + expect(failure.properties.conditional_attributes.default).toEqual([ + "acs.failure.detail", + "acs.failure.error_code", + "acs.failure.timeout_ms", + "acs.failure.elapsed_ms", + "acs.failure.payload_bytes", + ]); + expect(failure.properties.attribute_values.default).toEqual({ + // signature_invalid and handshake_failure: Bar's answer on issue #37, 2026-10-13. + "acs.failure.class": [ + "timeout", + "unreachable", + "malformed_response", + "guardian_error", + "observed_agent_error", + "signature_invalid", + "handshake_failure", + "other", + ], + "acs.failure.posture": ["fail_open", "fail_closed"], + "acs.failure.reported_by": ["observed_agent", "guardian"], + }); + expect(rows.some((r) => r.span === "acs.decision.failure" || r.attribute.startsWith("acs.failure."))).toBe(false); + }); + it("acs.provenance.origin is emittable -- provenance.json's own `required` list includes `origin`", () => { const row = only("acs.provenance.origin"); expect(row.emittableByWireConsumer).toBe(true); @@ -95,11 +187,11 @@ describe("checkTracePillar resolves every row against the v0.1.0 schema files at }); it("emittableByWireConsumer is false wherever wireSource is null -- an invariant of buildRow, currently unexercised by real data", () => { - // Every one of these 17 rows resolves against a declared + // Every one of these rows resolves against a declared // response-envelope.json or provenance.json property -- v0.1.0 declares // every field otel-mapping.json names in scope here, it just does not // make most of the decision-event ones required. So `wireSource === null` - // currently holds for zero of the 17 rows -- asserted explicitly (not + // currently holds for zero of the rows -- asserted explicitly (not // merely assumed) so this test cannot pass by accident, and the // invariant stays checked because buildRow's "no such property" branch // is still real, reachable code (the renderer has its own synthetic-row @@ -124,15 +216,62 @@ describe("checkTracePillar resolves every row against the v0.1.0 schema files at } expect(spanCounts).toEqual({ "gen_ai.tool.call": 2, // steps/toolCallRequest: gen_ai.tool.name, acs.capability - "gen_ai.tool.result": 2, // steps/toolCallResult: gen_ai.tool.name, acs.exit_status + "gen_ai.tool.result": 3, // steps/toolCallResult: gen_ai.tool.name, acs.exit_status, acs.request_id_ref "acs.session": 1, // steps/sessionStart: acs.session.id "acs.session.end": 1, // steps/sessionEnd: acs.session.reason "acs.message.user": 2, // steps/userMessage: acs.session.id, acs.content.types "acs.message.agent": 2, // steps/agentResponse: acs.session.id, acs.agent.id - "acs.decision": 6, // decision_event: 2 required + 4 conditional + "acs.decision": 12, // decision_event: 1 required + 11 conditional + "(every step span)": 2, // step_span_attributes: acs.request_id, acs.session.id "(every step span, when Provenance is attached)": 1, // provenance_attributes.required }); - expect(rows).toHaveLength(17); + expect(rows).toHaveLength(26); + }); + + it("reads both obligation lists for each in-scope step span -- the rows are exactly required_attributes plus conditional_attributes, and never optional_attributes", () => { + // Read straight from the mapping, so moving an attribute between lists + // (as #227 moved acs.capability) cannot silently drop its row. + type Entry = { span_name: string; required_attributes: string[]; conditional_attributes?: string[]; optional_attributes?: string[] }; + const mappingPath = fileURLToPath(new URL("../../../../../specification/v0.1.0/trace/otel-mapping.json", import.meta.url)); + const stepToSpan = (JSON.parse(readFileSync(mappingPath, "utf8")) as { properties: { step_to_span: { default: Record } } }) + .properties.step_to_span.default; + + const expected = METHODS_IN_SCOPE.flatMap((method) => { + const entry = stepToSpan[method]!; + return [...entry.required_attributes, ...(entry.conditional_attributes ?? [])].map((attribute) => `${entry.span_name}::${attribute}`); + }).sort(); + const stepSpans = new Set(METHODS_IN_SCOPE.map((method) => stepToSpan[method]!.span_name)); + const actual = rows.filter((r) => stepSpans.has(r.span)).map((r) => `${r.span}::${r.attribute}`).sort(); + expect(actual).toEqual(expected); + + // acs.capability is the case #227 is about: it now sits in the + // conditional list, and still produces its row. + expect(stepToSpan["steps/toolCallRequest"]!.conditional_attributes).toContain("acs.capability"); + expect(stepToSpan["steps/toolCallRequest"]!.required_attributes).not.toContain("acs.capability"); + for (const method of METHODS_IN_SCOPE) { + for (const attribute of stepToSpan[method]!.optional_attributes ?? []) { + expect(rows.some((r) => r.attribute === attribute && r.span === stepToSpan[method]!.span_name)).toBe(false); + } + } + }); + + it("every REQUIRED row is emittable -- a required attribute whose wire source is optional is the mapping defect #227 fixed, and must not come back", () => { + // Moving acs.evaluator or acs.capability back into a required list + // fails here, where before the obligation field it read the same as a + // correctly conditional row. + const broken = rows.filter((r) => r.obligation === "required" && !r.emittableByWireConsumer).map((r) => `${r.span}::${r.attribute}`); + expect(broken).toEqual([]); + }); + + it("every conditional row records its list, so the renderer can tell it from a required gap", () => { + expect(only("acs.capability").obligation).toBe("conditional"); + expect(only("acs.evaluator").obligation).toBe("conditional"); + expect(only("acs.request_id_ref").obligation).toBe("conditional"); + expect(only("acs.decision").obligation).toBe("required"); + const table = renderTraceRows(rows); + expect(table).toContain("○ acs.capability"); + // The legend names ✖ too, so only the row lines are checked. + expect(table.split("\n").filter((line) => line.startsWith(" ✖"))).toEqual([]); }); it("every row names a real span, never blank", () => { @@ -165,7 +304,7 @@ describe("renderTraceRows", () => { }); it("renders '(no wire source)' for a synthetic row whose wireSource is genuinely null", () => { - // None of today's real 17 rows has a null wireSource (see the check + // None of today's real rows has a null wireSource (see the check // suite's own test above), so this exercises render.ts's fallback // branch directly rather than leaving it untested by accident of // today's schema facts. @@ -174,6 +313,7 @@ describe("renderTraceRows", () => { span: "acs.decision", wireSource: null, emittableByWireConsumer: false, + obligation: "required", reason: "no wire source for acs.made_up", }; expect(renderTraceRows([synthetic])).toContain("(no wire source)"); @@ -186,14 +326,14 @@ describe("renderTraceRows", () => { }); it("RenderOptions.color is opt-in, and stripping the ANSI codes back out recovers exactly the plain render", () => { - // A fixture, not `rows`: none of today's real 17 has wireSource === + // A fixture, not `rows`: none of today's real rows has wireSource === // null (the check suite's own test above), which is the only case this // renderer colours -- against the real data, `color: true` currently // produces byte-identical output to the plain render, and this test // would pass without exercising the colour path at all. const fixture: TraceRow[] = [ - { attribute: "gen_ai.tool.name", span: "gen_ai.tool.call", wireSource: "hooks/tool-call-request.json#tool.name", emittableByWireConsumer: true }, - { attribute: "acs.made_up", span: "acs.decision", wireSource: null, emittableByWireConsumer: false, reason: "no wire source for acs.made_up" }, + { attribute: "gen_ai.tool.name", span: "gen_ai.tool.call", wireSource: "hooks/tool-call-request.json#tool.name", emittableByWireConsumer: true, obligation: "required" }, + { attribute: "acs.made_up", span: "acs.decision", wireSource: null, emittableByWireConsumer: false, obligation: "required", reason: "no wire source for acs.made_up" }, ]; const plain = renderTraceRows(fixture); @@ -206,7 +346,7 @@ describe("renderTraceRows", () => { expect(stripped).toBe(plain); }); - it("is still a pure function of the rows it is handed when colour is on -- rendering the real 17 rows twice with color:true produces the same string", () => { + it("is still a pure function of the rows it is handed when colour is on -- rendering the real rows twice with color:true produces the same string", () => { // Separate from the fixture test above: even though `color: true` // happens to be a no-op against today's real data (nothing here is // painted), purity still holds and is worth asserting against the diff --git a/reference-implementations/agt/packages/guardian/README.md b/reference-implementations/agt/packages/guardian/README.md index 511f979b..4c604f7f 100644 --- a/reference-implementations/agt/packages/guardian/README.md +++ b/reference-implementations/agt/packages/guardian/README.md @@ -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. @@ -46,6 +48,93 @@ 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. To go from a fresh clone to a trace file of your own, follow [Produce a trace](../../README.md#produce-a-trace) in the tree README. + +```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 `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. + +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` (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. + +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. + +The diagram shows the exporter's inside. Look for the split after a span ends: each output gets its own batch processor and its own failure switch, so nothing on one side can stop the other. + +```mermaid +flowchart LR + start["Request recorded:
span starts"] --> finish["Response recorded:
event added, span ends"] + finish --> bspfile["BatchSpanProcessor
for the file"] + finish --> bspend["BatchSpanProcessor
for the endpoint"] + bspfile --> outfile["File output"] + bspend --> outend["Endpoint output"] + outfile --> file["ACS_OTEL_FILE"] + outend --> collector["ACS_OTEL_ENDPOINT"] + outfile -.->|"first failure"| off["Output disabled,
one stderr line"] + outend -.->|"first failure"| off + stop["SIGINT or SIGTERM"] -->|"close open spans, flush, 3 s limit"| bspfile + stop -->|"close open spans, flush, 3 s limit"| bspend +``` + +Each processor sends a batch about every five seconds. At shutdown, the exporter first ends any span still open with an `acs.decision.failure` event, then flushes both processors. An output that hasn't finished within three seconds is reported on stderr and its unexported spans are dropped. + +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 ([issue #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 agent session has ended, into a file that does not exist when it starts. It refuses an existing output path, so it doesn't append into the audit log, the Guardian's trace file, or its own earlier run, and a wrong audit path or mapping leaves no file behind. + +`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. + +### 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. + +```bash +bun run sample:trace +``` + +That command regenerates it. `test/sample-trace.test.ts` checks the sample against `otel-mapping.json` and fails when the exporter's output no longer matches it, so a change to the exporter or the mapping comes with a regenerated sample. The converted span has no `gen_ai.tool.name`, because the audit log does not record the tool (issue #274). Like the exporter, the sample covers two hook methods and is not an ACS-Trace conformance claim. + +### Troubleshooting + +These are the messages export can print, with what each one means. None of them changes a decision. + +- `otel trace export to disabled after failure: ` on the Guardian's stderr. That output failed its first write or send and is off until the Guardian restarts. The other output keeps running. For an endpoint, the usual cause is a collector that wasn't running yet or was restarted: stop the Guardian, start the collector, then start the Guardian again. +- `otel trace exporter disabled after failure: `, with no output named. The exporter as a whole stopped, for example because `otel-mapping.json` didn't load. No spans reach either output for the rest of the process. +- `otel trace exporter shutdown disabled after failure: did not finish within 3000 ms; unexported spans were dropped`. An output stalled at shutdown. The spans still in its queue are lost. +- `otel trace export to : an HTTP proxy is set and NO_PROXY/no_proxy did not exempt , so on some Bun versions these spans go through the proxy.` at startup. Your shell has `HTTP_PROXY` set, and the loopback host isn't in the proxy exemption list Bun reads. The Guardian adds the host itself, which takes effect on Bun 1.3.14 and later. On older versions, start the Guardian with the host in both `NO_PROXY` and `no_proxy`, as step 1 of [Produce a trace](../../README.md#produce-a-trace) does. +- `refusing to write : it already exists, perhaps from an earlier or failed run.` from the converter, which then exits with status 1. It never appends. Delete the old output or name a new file. +- An empty `.acs/otel-collector-traces.jsonl` in the first five seconds after you send a step. That's normal: spans leave in batches about every five seconds, and stopping the Guardian with Ctrl-C sends the last one. If the file still hasn't appeared after about ten seconds, the collector isn't running [`otel-collector.yaml`](../../otel-collector.yaml), or the Guardian printed the `disabled after failure` line above. + ## Use it in-process The tests and the conformance harness start a Guardian in the same process. @@ -58,7 +147,9 @@ 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`. + +`otelTraceEndpoint` leaves the proxy variables alone. `src/main.ts` prints the proxy warning and adds a loopback endpoint's host to whichever of `NO_PROXY` and `no_proxy` is set, or to both when neither is. An embedder that wants the same imports `loopbackProxyWarning` and `keepLoopbackOffProxy` from `guardian` and calls each with `(endpoint, process.env)` before it starts the Guardian. 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. @@ -78,6 +169,11 @@ 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, assembled from the three files below | +| `src/otel-mapping.ts` | Reads `otel-mapping.json`, and says which envelope field feeds each span attribute | +| `src/otlp-file-exporter.ts` | The OTLP/JSON file writer | +| `src/loopback-proxy.ts` | Keeps a loopback trace endpoint off an HTTP proxy, applied by `src/main.ts` | +| `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 @@ -90,4 +186,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`. diff --git a/reference-implementations/agt/packages/guardian/package.json b/reference-implementations/agt/packages/guardian/package.json index f5cd92dd..49e89c88 100644 --- a/reference-implementations/agt/packages/guardian/package.json +++ b/reference-implementations/agt/packages/guardian/package.json @@ -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" diff --git a/reference-implementations/agt/packages/guardian/src/audit-to-otlp.ts b/reference-implementations/agt/packages/guardian/src/audit-to-otlp.ts new file mode 100644 index 00000000..7dd92b10 --- /dev/null +++ b/reference-implementations/agt/packages/guardian/src/audit-to-otlp.ts @@ -0,0 +1,352 @@ +/** + * audit-to-otlp turns the host adapter's audit log into the same OTLP/JSON + * file the Guardian's trace exporter writes, so a reader of that file sees + * every decision failure, not only the ones the Guardian could see itself. + * + * The Guardian's exporter records the failures it can see: an error it + * answered, a decision it finished late, a step still open at shutdown. The + * host adapter sees the rest -- a Guardian it could not reach, one that + * never answered in time -- and #37 makes it record each one in its own + * audit log (`.acs/audit.jsonl`, packages/host-adapter/src/audit-sink.ts) + * and surface it on Trace when it can. The adapter runs once per hook in a + * fresh process and should not carry the OpenTelemetry SDK, so the surfacing + * happens here, afterwards, from the log. + * + * bun run packages/guardian/src/audit-to-otlp.ts + * + * Each posture-resolved entry (`outcome` of `proceeded` or `blocked`) + * becomes one span named by otel-mapping.json for the entry's method, timed + * at `recorded_at`, carrying the same `acs.session.id` and `acs.request_id` + * the Guardian's own spans carry, and one `acs.decision.failure` event with + * `acs.failure.reported_by` set to `observed_agent`. The audit entry carries + * no elapsed time, timeout or payload size, so those "when known" attributes + * are left out. + * + * Skipped, and counted in the summary: + * - an `ungoverned` entry: a step no gate asked about, not a decision failure; + * - an entry with no method: the host never built a request (a broken + * hookmap, say), so there is no span name and no request id to carry -- + * counted by its failure kind, because a fail-open among these is still a + * bypass someone has to see; + * - an entry whose method the mapping names no span for; + * - a REPLAY_DETECTED error, whose pair belongs to the earlier request (#228); + * - a line that is not JSON, not an audit entry, or has no posture. + * + * Reads the log with plain JSON.parse and loose field reads, importing + * nothing from the host adapter: the file is the contract, the same way the + * Inspector reads it. Run it after the agent session has ended, into a file + * that does not exist when it starts: it refuses an existing output path, so + * it does not append into the audit log, the Guardian's trace file, or its + * own earlier run. + */ +import { createHash } from "node:crypto"; +import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { SpanStatusCode, type Attributes } from "@opentelemetry/api"; +import { ExportResultCode, type ExportResult } from "@opentelemetry/core"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { + AlwaysOnSampler, + BasicTracerProvider, + SimpleSpanProcessor, + type ReadableSpan, + type SpanExporter, +} from "@opentelemetry/sdk-trace-base"; +import { + asObject, + DECISION_FAILURE_EVENT, + decisionFailureAttributes, + GUARDIAN_ERROR_STATUS, + loadOtelMapping, + REPLAY_DETECTED_CODE, + type FailureClass, + type JsonObject, +} from "./otel-mapping.ts"; +import { SPAN_LIMITS } from "./otel-trace-exporter.ts"; +import { createFileSpanExporter } from "./otlp-file-exporter.ts"; + +/** + * The host's `failure.kind` (packages/host-adapter/src/failure-kinds.ts) + * to #37's `acs.failure.class`. + * + * `transport` is a request that never reached the Guardian, which is what + * `unreachable` names. `error_without_decision` and `refused` both mean an + * error came back instead of a decision (§6.4), so both are `guardian_error`; + * the code goes in `acs.failure.error_code`. `host_configuration` is the host + * failing before any request existed. `decision_unrenderable` is a decision + * that did arrive and that this host could not express, which none of the + * other classes describe, so it falls to `other` with its own detail. + * `unknown`, and any kind this table does not know, is `other`, and a rising + * `other` count is the signal the list needs a row. + */ +export const FAILURE_CLASS_BY_KIND: Readonly> = { + timeout: "timeout", + transport: "unreachable", + error_without_decision: "guardian_error", + refused: "guardian_error", + host_configuration: "observed_agent_error", +}; + +export function failureClassOf(kind: unknown): FailureClass { + return (typeof kind === "string" ? FAILURE_CLASS_BY_KIND[kind] : undefined) ?? "other"; +} + +/** + * `acs.failure.detail` for each host kind: a sentence this converter writes, + * never the host's `failure.message`. That message quotes the Guardian's + * error text, and #228 forbids copying bytes from a response or an error into + * the trace. The sentence is also the only place the host's kind survives, + * since the class folds some kinds together. A kind this table does not + * know gets the generic sentence; the kind string itself is never copied. + */ +export const FAILURE_DETAIL_BY_KIND: Readonly> = { + timeout: "No decision arrived before the host's timeout.", + transport: "The host could not reach the Guardian.", + error_without_decision: GUARDIAN_ERROR_STATUS, + refused: "The Guardian refused the request, and this host blocked the step.", + host_configuration: "The host could not build a request for this hook.", + decision_unrenderable: "A decision arrived, and this host could not express it in its hook output.", +}; +const GENERIC_DETAIL = "The host recorded a failure this converter does not classify."; + +/** The JSON-RPC code, parsed out of the two message shapes the host writes + * (packages/host-adapter/src/failure-posture.ts). Only the integer is + * taken; nothing else in the message is copied. */ +const ERROR_CODE_IN_MESSAGE = /^guardian (?:refused the envelope with|returned) error (-?\d+):/; + +/** + * The host adapter's `toSessionUuid` (packages/host-adapter/src/build-envelope.ts), + * copied because host-adapter already depends on this package, so importing + * it would be a cycle. The Guardian's spans carry this derived UUID, not the + * host's raw session id, so a converter span has to carry it too or the two + * files cannot be joined. The root test pins the two as equal. + */ +const SESSION_ID_NAMESPACE = "11c79ebd-8469-4b4e-8de0-72674add484c"; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function sessionUuid(rawSessionId: string): string { + if (UUID_RE.test(rawSessionId)) { + return rawSessionId.toLowerCase(); + } + const namespaceBytes = Buffer.from(SESSION_ID_NAMESPACE.replace(/-/g, ""), "hex"); + const hash = createHash("sha1") + .update(Buffer.concat([namespaceBytes, Buffer.from(rawSessionId, "utf8")])) + .digest(); + hash[6] = (hash[6]! & 0x0f) | 0x50; // version 5 + hash[8] = (hash[8]! & 0x3f) | 0x80; // RFC 4122 variant + const hex = hash.subarray(0, 16).toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +export type ConversionSummary = { + /** Spans built from the log. */ + converted: number; + /** Spans the file exporter confirmed written. Differs from `converted` + * only when an export failed, and then the command exits non-zero. */ + written: number; + /** Posture entries whose method the mapping names no span for. */ + skippedUnmapped: number; + /** Posture entries with no method, counted by failure kind. */ + skippedNoMethod: Record; + /** REPLAY_DETECTED errors. */ + skippedReplay: number; + /** Steps no gate asked about. */ + skippedUngoverned: number; + /** Lines that were not JSON, not an audit entry, or had no posture. */ + skippedUnreadable: number; +}; + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +/** `recorded_at` as a Date, or undefined for a value Date cannot read, in + * which case the span is timed at now rather than at a nonsense instant. */ +function readInstant(value: unknown): Date | undefined { + const text = readString(value); + if (text === undefined) return undefined; + const instant = new Date(text); + return Number.isNaN(instant.getTime()) ? undefined : instant; +} + +/** Counts the spans the wrapped exporter reports written, so the summary + * says what reached the file and not what was handed to the SDK. */ +function countingExporter(inner: SpanExporter): { exporter: SpanExporter; written: () => number } { + let written = 0; + return { + written: () => written, + exporter: { + export(spans: ReadableSpan[], done: (result: ExportResult) => void): void { + inner.export(spans, (result) => { + if (result.code === ExportResultCode.SUCCESS) { + written += spans.length; + } + done(result); + }); + }, + shutdown: () => inner.shutdown(), + forceFlush: () => inner.forceFlush?.() ?? Promise.resolve(), + }, + }; +} + +/** The converter's `service.name` and tracer name. Its own, not the + * Guardian's: these spans are derived after the fact from the host's audit + * log, and `acs.failure.reported_by` on each event already says which party + * claims the failure. A label, not attribution (see the Guardian README). */ +export const CONVERTER_SERVICE_NAME = "acs-reference-audit-converter"; + +export async function convertAuditLog(auditPath: string, otelPath: string, mappingPath?: string): Promise { + // Everything that can fail without writing goes first, so a wrong audit + // path or a bad mapping leaves no output behind to block the rerun. + const mapping = loadOtelMapping(mappingPath); + const auditText = readFileSync(auditPath, "utf8"); + // Exclusive create: an output path that exists when the converter starts is + // refused before anything is written. That one check covers the log itself, + // a hardlink to it, a symlink, the Guardian's own trace file, and a second + // run over the same output, each of which the append-only exporter would + // otherwise corrupt or double. It does not stop another process swapping + // the path mid-run. Nothing here defends against a same-user writer. + mkdirSync(dirname(otelPath), { recursive: true, mode: 0o700 }); + try { + closeSync(openSync(otelPath, "wx", 0o600)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`refusing to write ${otelPath}: it already exists, perhaps from an earlier or failed run. Give the converter a new file, or delete this one first.`); + } + throw error; + } + const output = countingExporter(createFileSpanExporter(otelPath)); + // One span per export, not batched: a batch processor's bounded queue + // overflowed inside this synchronous loop and dropped spans silently. + // Sampler set explicitly, so no OTEL_TRACES_SAMPLER can drop them either. + const provider = new BasicTracerProvider({ + resource: resourceFromAttributes({ "service.name": CONVERTER_SERVICE_NAME }), + sampler: new AlwaysOnSampler(), + // Frozen shared limits, copied: no OTEL_* variable may truncate or drop output. + spanLimits: { ...SPAN_LIMITS }, + spanProcessors: [new SimpleSpanProcessor(output.exporter)], + }); + const tracer = provider.getTracer(CONVERTER_SERVICE_NAME); + const summary: ConversionSummary = { + converted: 0, + written: 0, + skippedUnmapped: 0, + skippedNoMethod: {}, + skippedReplay: 0, + skippedUngoverned: 0, + skippedUnreadable: 0, + }; + + for (const line of auditText.split("\n")) { + if (line.trim() === "") continue; + let entry: JsonObject | undefined; + try { + entry = asObject(JSON.parse(line)); + } catch { + entry = undefined; + } + if (entry === undefined) { + summary.skippedUnreadable += 1; + continue; + } + const outcome = readString(entry.outcome); + if (outcome === "ungoverned") { + summary.skippedUngoverned += 1; + continue; + } + // acs.failure.posture is the deployment's posture (#228), which the host + // records on every posture entry. Inferring it from `outcome` would be + // wrong for a refusal, which is blocked whatever the posture said. + const posture = readString(entry.posture); + if ((outcome !== "proceeded" && outcome !== "blocked") || (posture !== "proceed" && posture !== "deny")) { + summary.skippedUnreadable += 1; + continue; + } + const failure = asObject(entry.failure) ?? {}; + const kind = readString(failure.kind); + const method = readString(entry.method); + if (method === undefined) { + const key = kind !== undefined && kind in FAILURE_DETAIL_BY_KIND ? kind : "other"; + summary.skippedNoMethod[key] = (summary.skippedNoMethod[key] ?? 0) + 1; + continue; + } + const spanName = mapping.stepToSpan[method]?.span_name; + if (spanName === undefined) { + summary.skippedUnmapped += 1; + continue; + } + const codeText = ERROR_CODE_IN_MESSAGE.exec(readString(failure.message) ?? "")?.[1]; + const errorCode = codeText === undefined ? undefined : Number(codeText); + if (errorCode === REPLAY_DETECTED_CODE) { + summary.skippedReplay += 1; + continue; + } + + const instant = readInstant(entry.recorded_at); + const detail = (kind !== undefined ? FAILURE_DETAIL_BY_KIND[kind] : undefined) ?? GENERIC_DETAIL; + const attributes: Attributes = {}; + const sessionId = readString(entry.session_id); + if (sessionId !== undefined) { + attributes["acs.session.id"] = sessionUuid(sessionId); + } + // The host builds every envelope with its JSON-RPC id equal to + // params.request_id (build-envelope.ts), so the entry's rpc_id is the + // request id the Guardian's span for the same request carries. + const requestId = readString(entry.rpc_id); + if (requestId !== undefined) { + attributes["acs.request_id"] = requestId; + } + // The entry is one instant, so the span is too: it opens and closes at + // recorded_at. The host, not the Guardian, is the reporter. + const span = tracer.startSpan(spanName, { attributes, root: true, startTime: instant }); + span.addEvent( + DECISION_FAILURE_EVENT, + decisionFailureAttributes({ + class: failureClassOf(kind), + posture: posture === "deny" ? "fail_closed" : "fail_open", + reportedBy: "observed_agent", + detail, + errorCode, + }), + instant, + ); + span.setStatus({ code: SpanStatusCode.ERROR, message: detail }); + span.end(instant); + summary.converted += 1; + } + + // Every export has settled once forceFlush resolves, so the count is final + // here; shutdown then releases the processor. + await provider.forceFlush(); + summary.written = output.written(); + await provider.shutdown(); + return summary; +} + +if (import.meta.main) { + const [auditPath, otelPath] = Bun.argv.slice(2); + if (auditPath === undefined || otelPath === undefined) { + console.error("usage: bun run packages/guardian/src/audit-to-otlp.ts "); + process.exit(2); + } + let summary: ConversionSummary; + try { + summary = await convertAuditLog(auditPath, otelPath); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + const noMethod = Object.entries(summary.skippedNoMethod) + .map(([kind, count]) => `${count} ${kind}`) + .join(", "); + console.log( + `wrote ${summary.written} of ${summary.converted} span(s) to ${otelPath}; skipped ${summary.skippedUnmapped} with no mapping entry, ` + + `${noMethod === "" ? 0 : noMethod} with no method, ${summary.skippedReplay} replay, ` + + `${summary.skippedUngoverned} ungoverned, ${summary.skippedUnreadable} unreadable`, + ); + if (summary.written !== summary.converted) { + console.error(`only ${summary.written} of ${summary.converted} span(s) reached ${otelPath}`); + process.exit(1); + } +} diff --git a/reference-implementations/agt/packages/guardian/src/index.ts b/reference-implementations/agt/packages/guardian/src/index.ts index f1bc099e..56257536 100644 --- a/reference-implementations/agt/packages/guardian/src/index.ts +++ b/reference-implementations/agt/packages/guardian/src/index.ts @@ -74,3 +74,7 @@ export { type SessionProvenance, } from "./session-context-store.ts"; export { persistIfcLabels, supplySourceLabels } from "./ifc-labels.ts"; +// Not governance verbs, but startGuardian's otelTraceEndpoint leaves the +// proxy variables alone, and an embedder that wants main.ts's loopback +// exemption needs these two to apply it. +export { keepLoopbackOffProxy, loopbackProxyWarning } from "./loopback-proxy.ts"; diff --git a/reference-implementations/agt/packages/guardian/src/loopback-proxy.ts b/reference-implementations/agt/packages/guardian/src/loopback-proxy.ts new file mode 100644 index 00000000..2ac225c7 --- /dev/null +++ b/reference-implementations/agt/packages/guardian/src/loopback-proxy.ts @@ -0,0 +1,93 @@ +/** + * Keeps a loopback trace endpoint off an HTTP proxy. Both functions take the + * environment as a parameter: main.ts, the composition root, passes + * process.env, and the exporter itself never reads or writes it. + */ + +/** The NO_PROXY entries that exempt a loopback endpoint's host, or none + * when the endpoint is not loopback. IPv6 gets both forms: Bun matches only + * `[::1]`, and other clients expect the bare `::1`. */ +function loopbackProxyEntries(endpoint: string): string[] { + const host = new URL(endpoint).hostname.replace(/^\[|\]$/g, ""); + if (host === "127.0.0.1" || host === "localhost") { + return [host]; + } + return host === "::1" ? ["::1", "[::1]"] : []; +} + +const splitProxyList = (value: string): string[] => + value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry !== ""); + +/** + * Adds a loopback endpoint's host to NO_PROXY and no_proxy, so an + * HTTP_PROXY in the Guardian's environment cannot route spans meant for this + * machine through a proxy: request content (reasoning text, provenance ids) + * would reach the proxy, and the collector would never see it. Bun's HTTP + * client honors these variables per request, and an explicit agent does not + * opt out, so the environment is the only lever. A non-loopback endpoint is + * left to the operator's proxy settings. + * + * Only the variables the user set are touched. Bun 1.3.14 reads a non-empty + * no_proxy in preference to NO_PROXY, so creating the one the user did not + * set would switch off every exemption in the one they did. "Set" follows + * Bun's own rule: any value but the empty string counts, and an empty one + * makes Bun fall back to the other. A set variable that holds no entries + * (a stray comma, say) is what Bun reads, so it is filled with the host. + * When neither is set, both get the host, and nothing is lost. + * + * Bun 1.3.11 reads these variables only when the process starts, so on that + * version this edit has no effect, and loopbackProxyWarning below is what + * tells the operator. + * + * The edit is made to the `env` it is given and never undone. main.ts + * passes process.env, so there the change is process-wide and permanent: + * anything else in that process (a host adapter embedded with the Guardian) + * and any child process it spawns inherits it. That is acceptable because + * only loopback hosts are ever added, and traffic to this machine should not + * use a proxy for any of them. The exporter never calls this itself, so an + * embedder that builds the exporter decides whether its environment changes. + */ +export function keepLoopbackOffProxy(endpoint: string, env: Record): void { + const additions = loopbackProxyEntries(endpoint); + if (additions.length === 0) { + return; + } + const keys = ["NO_PROXY", "no_proxy"] as const; + const setKeys = keys.filter((key) => (env[key] ?? "") !== ""); + for (const key of setKeys.length === 0 ? keys : setKeys) { + const entries = splitProxyList(env[key] ?? ""); + const missing = additions.filter((entry) => !entries.includes(entry)); + if (missing.length > 0) { + env[key] = [...entries, ...missing].join(","); + } + } +} + +/** + * A warning when a loopback endpoint would reach an HTTP proxy, judged by + * the variables as they stand: a proxy is set, and the list Bun reads (a + * non-empty no_proxy, otherwise NO_PROXY) does not exempt the host. main.ts + * calls it before keepLoopbackOffProxy, so on Bun 1.3.11, which ignores the + * runtime edit, the operator still learns that spans may go through the + * proxy and what to set before starting the Guardian. + */ +export function loopbackProxyWarning(endpoint: string, env: Record): string | undefined { + const additions = loopbackProxyEntries(endpoint); + const proxy = env.http_proxy || env.HTTP_PROXY; + if (additions.length === 0 || !proxy) { + return undefined; + } + const list = splitProxyList(env.no_proxy ? env.no_proxy : (env.NO_PROXY ?? "")); + // The bracketed form is the one Bun matches for IPv6. + const needed = additions[additions.length - 1]!; + if (list.includes("*") || list.includes(needed)) { + return undefined; + } + return ( + `otel trace export to ${endpoint}: an HTTP proxy is set and NO_PROXY/no_proxy did not exempt ${needed}, ` + + `so on some Bun versions these spans go through the proxy. Start the Guardian with ${needed} in both NO_PROXY and no_proxy.` + ); +} diff --git a/reference-implementations/agt/packages/guardian/src/main.ts b/reference-implementations/agt/packages/guardian/src/main.ts index a3dee199..302c81b2 100644 --- a/reference-implementations/agt/packages/guardian/src/main.ts +++ b/reference-implementations/agt/packages/guardian/src/main.ts @@ -19,6 +19,7 @@ * surface and server.ts reads none itself. */ import { buildServerHello } from "./handshake.ts"; +import { keepLoopbackOffProxy, loopbackProxyWarning } from "./loopback-proxy.ts"; import { startGuardian } from "./server.ts"; const DEFAULT_PORT = 8787; @@ -31,6 +32,27 @@ const hostname = process.env.ACS_GUARDIAN_HOST; const manifestPath = process.env.ACS_MANIFEST_PATH ?? DEFAULT_MANIFEST_PATH; const envelopeLogPath = process.env.ACS_ENVELOPE_LOG ?? DEFAULT_ENVELOPE_LOG; const sessionContextLog = process.env.ACS_SESSION_CONTEXT_LOG ?? DEFAULT_SESSION_CONTEXT_LOG; +// Trace export is off unless one of these names an output. Read here and +// passed in, like ACS_ENVELOPE_LOG: server.ts reads no env itself. +const otelTraceFile = process.env.ACS_OTEL_FILE; +const otelTraceEndpoint = process.env.ACS_OTEL_ENDPOINT; +// The proxy variables are environment too, so they are read and edited here +// and never by the exporter. The warning judges them as the operator set +// them, before the edit: Bun 1.3.11 ignores a runtime edit, and there the +// warning is all the operator gets. An endpoint that is not a URL is left to +// the exporter, which reports it and disables that output without stopping +// the Guardian. +if (otelTraceEndpoint !== undefined) { + try { + const warning = loopbackProxyWarning(otelTraceEndpoint, process.env); + if (warning !== undefined) { + console.error(warning); + } + keepLoopbackOffProxy(otelTraceEndpoint, process.env); + } catch { + // Not a URL. createOtelTraceExporter reports it below. + } +} // Read and validate the posture BEFORE starting the server. Not the audit // sink's path: that file is the host's, written by the hook, not by @@ -44,8 +66,33 @@ const sessionContextLog = process.env.ACS_SESSION_CONTEXT_LOG ?? DEFAULT_SESSION // healthy start immediately followed by one. const posture = buildServerHello().on_decision_failure; -const guardian = await startGuardian({ port, hostname, manifestPath, envelopeLogPath, sessionContextLog }); +const guardian = await startGuardian({ + port, + hostname, + manifestPath, + envelopeLogPath, + sessionContextLog, + otelTraceFile, + otelTraceEndpoint, +}); console.log(`Guardian listening at ${guardian.url}`); console.log(`Envelope log: ${envelopeLogPath}`); console.log(`Session context log: ${sessionContextLog}`); console.log(`Failure posture: ${posture} (override with ACS_ON_DECISION_FAILURE=deny)`); + +// Only when export is on. The four lines above are what README.md captures +// and test/readme-captures.test.ts holds them to the line, so a fifth line +// appears only for a Guardian the quickstart does not run. +const traceOutputs = [otelTraceFile, otelTraceEndpoint].filter((target): target is string => target !== undefined); +if (traceOutputs.length > 0) { + console.log(`OTel traces: ${traceOutputs.join(", ")}`); + // The last batch of spans reaches its output only if the processor is + // flushed before the process goes, so a signal closes the Guardian rather + // than ending it mid-batch. + const stop = async (): Promise => { + await guardian.close(); + process.exit(0); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); +} diff --git a/reference-implementations/agt/packages/guardian/src/map-verdict.ts b/reference-implementations/agt/packages/guardian/src/map-verdict.ts index acdd4004..9ead61e5 100644 --- a/reference-implementations/agt/packages/guardian/src/map-verdict.ts +++ b/reference-implementations/agt/packages/guardian/src/map-verdict.ts @@ -451,7 +451,11 @@ export function mapVerdict( // Read before `reasoning` is composed, because the composed sentence names // the rule; assigned to `out` below, in the order the wire has always // carried these fields. - const ruleId = readVerdictField(verdict, fs.policy_references.rule_id); + // An empty reason names no rule. response-envelope.json requires a non-empty + // rule_id (#263), and Trace pads its policy arrays with "" to mean "absent", + // so "" is read as no rule at all rather than sent as one. + const rawRuleId = readVerdictField(verdict, fs.policy_references.rule_id); + const ruleId = rawRuleId === "" ? undefined : rawRuleId; // Composed, not copied. AGT's verdict.message is written for an operator // reading a log, and the bundle that writes it is vendored byte-identical diff --git a/reference-implementations/agt/packages/guardian/src/otel-mapping.ts b/reference-implementations/agt/packages/guardian/src/otel-mapping.ts new file mode 100644 index 00000000..05d9443b --- /dev/null +++ b/reference-implementations/agt/packages/guardian/src/otel-mapping.ts @@ -0,0 +1,366 @@ +/** + * The trace's vocabulary: what `specification/v0.1.0/trace/otel-mapping.json` + * says, the `acs.decision.failure` event that is not in the mapping yet, and + * the source tables that say where each attribute's value comes from. + * Shared by the two writers of the same file shape: the Guardian's exporter + * (otel-trace-exporter.ts) and the audit-log converter (audit-to-otlp.ts). + * + * What the mapping does not say is which envelope field feeds which + * attribute. That is the one piece of knowledge this module adds, in the + * four source tables below, written once by hand. An attribute the mapping + * names and no table knows is not emitted, and nothing is invented to fill a + * slot the wire did not carry. A required attribute the envelope lacks is + * simply absent, which is itself a fact worth seeing. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { Attributes, AttributeValue } from "@opentelemetry/api"; + +/** The most Provenance objects one step span describes. The attribute + * length limit in otel-trace-exporter.ts bounds each string, not how many a + * column holds. Both columns are cut at the same index, so the i-th element + * of each still describes the same object. */ +const PROVENANCE_COLUMN_LIMIT = 64; + +/** JSON-RPC REPLAY_DETECTED. The pair on a replayed request belongs to the + * 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; + +// The repository's own mapping, five directories up from this file, the +// same relationship SCHEMA_ROOT in validate-envelope.ts relies on. +const OTEL_MAPPING_PATH = fileURLToPath( + new URL("../../../../../specification/v0.1.0/trace/otel-mapping.json", import.meta.url), +); + +/** + * `acs.decision.failure` is not in otel-mapping.json yet; #228 adds it next + * to `decision_event`. Until then its names live here, in one place, the way + * DECISION_DETAIL_SOURCES holds the decision extras: the event name, its + * attribute keys, and the values the decision on #37 fixed for the class, + * the posture and the reporter. When the mapping gains the event, read the + * name from it the way `decision_event` is read. + */ +export const DECISION_FAILURE_EVENT = "acs.decision.failure"; + +/** The span status for a step answered with an error instead of a decision. + * One sentence for both writers, the Guardian here and the audit converter, + * 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."; + +/** The event's attribute keys: the three required ones first, then the + * ones carried when known. */ +export const FAILURE_ATTRIBUTES = { + class: "acs.failure.class", + posture: "acs.failure.posture", + reportedBy: "acs.failure.reported_by", + detail: "acs.failure.detail", + errorCode: "acs.failure.error_code", + timeoutMs: "acs.failure.timeout_ms", + elapsedMs: "acs.failure.elapsed_ms", + payloadBytes: "acs.failure.payload_bytes", +} as const; + +export type FailureClass = "timeout" | "unreachable" | "malformed_response" | "guardian_error" | "observed_agent_error" | "other"; +export type FailurePosture = "fail_open" | "fail_closed"; +export type FailureReporter = "guardian" | "observed_agent"; + +/** One decision failure, as the event records it. `detail` is required + * when the class is `other`; the rest travel when known. */ +export type DecisionFailure = { + class: FailureClass; + posture: FailurePosture; + reportedBy: FailureReporter; + detail?: string; + errorCode?: number; + timeoutMs?: number; + elapsedMs?: number; + payloadBytes?: number; +}; + +/** The event's attributes: the required three always, the others when + * present. One function for both reporters, so the Guardian's exporter and the + * audit-log converter (audit-to-otlp.ts) build the same event. */ +export function decisionFailureAttributes(failure: DecisionFailure): Attributes { + const attributes: Attributes = { + [FAILURE_ATTRIBUTES.class]: failure.class, + [FAILURE_ATTRIBUTES.posture]: failure.posture, + [FAILURE_ATTRIBUTES.reportedBy]: failure.reportedBy, + }; + if (failure.detail !== undefined) attributes[FAILURE_ATTRIBUTES.detail] = failure.detail; + if (failure.errorCode !== undefined) attributes[FAILURE_ATTRIBUTES.errorCode] = failure.errorCode; + if (failure.timeoutMs !== undefined) attributes[FAILURE_ATTRIBUTES.timeoutMs] = failure.timeoutMs; + if (failure.elapsedMs !== undefined) attributes[FAILURE_ATTRIBUTES.elapsedMs] = failure.elapsedMs; + if (failure.payloadBytes !== undefined) attributes[FAILURE_ATTRIBUTES.payloadBytes] = failure.payloadBytes; + return attributes; +} + +/** One `step_to_span` entry. `conditional_attributes` is the list #227 + * adds, for attributes the wire carries only sometimes (`acs.capability` + * among them); read the same way as the other two. */ +type StepSpanEntry = { + span_name: string; + required_attributes: string[]; + conditional_attributes?: string[]; + optional_attributes?: string[]; +}; + +/** + * Methods this Guardian evaluates that have no `step_to_span` entry in + * otel-mapping.json yet, and why. A method here gets no span. The test + * fails for an evaluated method that is neither mapped nor listed here, so + * a new method cannot quietly go untraced: it is either mapped or named + * with its reason. + */ +export const METHODS_WITHOUT_SPAN: Readonly> = {}; + +/** otel-mapping.json is a JSON Schema whose data lives inside `default` + * keys, the same three places `packages/conformance/src/trace-pillar.ts` + * reads. This type names only those. */ +type OtelMappingSchema = { + properties: { + step_to_span: { default: Record }; + decision_event: { + properties: { + event_name: { const: string }; + required_attributes: { default: string[] }; + conditional_attributes: { default: string[] }; + }; + }; + provenance_attributes: { + properties: { + required: { default: string[] }; + optional: { default: string[] }; + }; + }; + }; +}; + +export type OtelMapping = { + stepToSpan: Record; + decisionEventName: string; + /** The event's required attributes first, then its conditional ones. Both + * are read the same way (emitted when a value is present), so one list + * serves. */ + decisionAttributes: string[]; + /** Required first, then optional. Carried on a step span whose payload + * has a Provenance object attached, as the mapping's own text says. */ + provenanceAttributes: string[]; +}; + +export function loadOtelMapping(path: string = OTEL_MAPPING_PATH): OtelMapping { + const schema = JSON.parse(readFileSync(path, "utf8")) as OtelMappingSchema; + const decision = schema.properties.decision_event.properties; + const provenance = schema.properties.provenance_attributes.properties; + return { + stepToSpan: schema.properties.step_to_span.default, + decisionEventName: decision.event_name.const, + decisionAttributes: [...decision.required_attributes.default, ...decision.conditional_attributes.default], + provenanceAttributes: [...provenance.required.default, ...provenance.optional.default], + }; +} + +export type JsonObject = Record; + +export function asObject(value: unknown): JsonObject | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as JsonObject) : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** A non-empty array of strings, or nothing. An empty array is carried by + * the wire (`policy_references: []` on an envelope-invalid deny) but says + * nothing a reader can use, so it is not emitted. */ +function asStrings(value: unknown): string[] | undefined { + if (!Array.isArray(value) || value.length === 0) return undefined; + return value.every((item) => typeof item === "string") ? (value as string[]) : undefined; +} + +/** + * One column of `policy_references`, in the wire's own order: the same + * length as `policy_references`, with `""` where a reference lacks the + * field. That is the rule agreed on #228, so a reader lines the three + * columns up by position and never loses the values the other references + * did carry. Absent only when there are no references at all. + */ +function policyColumn(references: unknown, field: string): string[] | undefined { + if (!Array.isArray(references) || references.length === 0) return undefined; + return references.map((reference) => asString(asObject(reference)?.[field]) ?? ""); +} + +/** The parts of a request envelope the source tables read. Read off the + * parsed JSON rather than the validated envelope, because the span starts + * before validation runs and an envelope that fails the schema is still a + * step this session took. */ +export type RequestParts = { params: JsonObject; metadata: JsonObject; payload: JsonObject; tool: JsonObject }; + +export function requestParts(envelope: unknown): RequestParts { + const params = asObject(asObject(envelope)?.params) ?? {}; + const metadata = asObject(params.metadata) ?? {}; + const payload = asObject(params.payload) ?? {}; + const tool = asObject(payload.tool) ?? {}; + return { params, metadata, payload, tool }; +} + +/** The decision half of a success response: the `result` and its optional + * `metadata`. `undefined` for a JSON-RPC error response, which carries no + * decision. */ +export type ResultParts = { result: JsonObject; metadata: JsonObject }; + +export function resultParts(response: unknown): ResultParts | undefined { + const result = asObject(asObject(response)?.result); + if (result === undefined) return undefined; + return { result, metadata: asObject(result.metadata) ?? {} }; +} + +export type Source = (parts: Parts) => AttributeValue | undefined; + +/** + * Attribute name -> where it is read from on the request envelope, for every + * attribute `step_to_span` can name on a span this Guardian emits. The + * mapping decides WHICH of these a span carries; this table only says HOW + * each one is read. Paths follow the hook payload schemas + * (`hooks/tool-call-request.json`, `hooks/tool-call-result.json`) and + * `request-envelope.json`'s `AcsParams.metadata`. + */ +export const STEP_ATTRIBUTE_SOURCES: Readonly>> = { + "acs.session.id": (r) => asString(r.metadata.session_id), + "acs.agent.id": (r) => asString(r.metadata.agent_id), + "gen_ai.tool.name": (r) => asString(r.tool.name), + "acs.tool.provider": (r) => asString(r.tool.provider), + "acs.tool.version": (r) => asString(r.tool.version), + "acs.capability": (r) => asString(r.payload.capability), + "acs.operation": (r) => asString(r.payload.operation), + "acs.exit_status": (r) => asString(r.payload.exit_status), + "acs.duration_ms": (r) => asNumber(r.payload.duration_ms), + "acs.request_id_ref": (r) => asString(r.payload.request_id_ref), +}; + +/** + * Carried on every span, whatever the mapping lists for it, so a reader can + * rebuild a session and pair each span with its two envelope-log lines. + * `acs.session.id` is the mapping's own name for the session. + * `acs.request_id` is this module's name for `params.request_id`, the ACS + * correlation id the response echoes; the mapping has no attribute for the + * step's own request id (its `acs.request_id_ref` names the request a + * RESULT refers back to, which is a different fact). + */ +export const CORRELATION_ATTRIBUTE_SOURCES: Record> = { + "acs.session.id": (r) => asString(r.metadata.session_id), + "acs.request_id": (r) => asString(r.params.request_id), +}; + +/** + * The `acs.decision` event's attributes, for every name the mapping's + * `decision_event` lists. Paths follow `response-envelope.json`'s + * `AcsResult`: `decision` and `reasoning` are direct members; the other four + * sit under `metadata`. `acs.evaluator` is read off the wire like the rest + * and never filled in here: the value this Guardian would send is + * `deterministic`, but it has to come from the Guardian's response (#229 + * puts it there), or the trace says something the wire never said and a + * reader cannot tell the two apart. + */ +export const DECISION_ATTRIBUTE_SOURCES: Readonly>> = { + "acs.decision": (d) => asString(d.result.decision), + "acs.evaluator": (d) => asString(d.metadata.evaluator), + "acs.reasoning": (d) => asString(d.result.reasoning), + "acs.confidence": (d) => asNumber(d.metadata.confidence), + "acs.evaluator_version": (d) => asString(d.metadata.evaluator_version), + "acs.model_id": (d) => asString(d.metadata.model_id), + // The decision facts #228 adds to the mapping. Each is emitted only when + // the response carries it; `acs.chain_hash` in particular appears only once + // a Guardian puts the chain head on the wire. + "acs.reason_codes": (d) => asStrings(d.result.reason_codes), + "acs.policy.ids": (d) => policyColumn(d.result.policy_references, "policy_id"), + "acs.policy.versions": (d) => policyColumn(d.result.policy_references, "policy_version"), + "acs.policy.rule_ids": (d) => policyColumn(d.result.policy_references, "rule_id"), + "acs.cited_provenance_ids": (d) => asStrings(d.result.cited_provenance_ids), + "acs.chain_hash": (d) => asString(d.result.chain_hash), +}; + +/** + * The decision facts emitted whether or not the mapping lists them yet. + * Before #228 the mapping does not name them; after it, it does. Reading + * this list beside the mapping's own makes the exporter correct under both, + * so this PR and #228 can land in either order. Once #228 is in, the mapping + * alone covers them and this list can go. + */ +export const DECISION_DETAIL_NAMES = [ + "acs.reason_codes", + "acs.policy.ids", + "acs.policy.versions", + "acs.policy.rule_ids", + "acs.cited_provenance_ids", + "acs.chain_hash", +] as const; + +/** + * Attribute name -> which field of a Provenance object (`provenance.json`) + * it reads. Unlike the tables above, each of these becomes a COLUMN: one + * array per attribute, one element per Provenance object the payload + * carries, in wire order. A span has one value per attribute key, and a + * payload can carry several Provenance objects (one per argument), so an + * array is the only shape that keeps every object visible; the i-th element + * of each column describes the i-th object, and `acs.provenance.origin` is + * always complete because provenance.json requires `origin`. + * + * `acs.provenance.lineage_depth` is in the mapping's optional list and has + * no entry here. provenance.json defines no lineage_depth field, so there is + * nothing on the wire to read it from, and deriving one (from the length of + * `derived_from`, say) would be a value nobody sent. + */ +export const PROVENANCE_ATTRIBUTE_SOURCES: Record string | undefined> = { + "acs.provenance.origin": (p) => asString(p.origin), + "acs.provenance.source_id": (p) => asString(p.source_id), +}; + +/** The Provenance objects a payload carries, in wire order: one per + * argument that has one, in the order the parsed object holds them, then one + * per output that has one, in array order. "Parsed" order rather than the + * bytes' order because `JSON.parse` has already hoisted integer-like + * argument names ahead of the rest (envelope-log-sink.ts notes `{"0": ...}` + * is a real shape), so this is the order every reader of the envelope log + * sees too. The two never meet on one payload (the request gate carries + * `arguments`, the result gate `outputs`), and an argument or output + * without one contributes nothing. */ +export function provenanceObjects(payload: JsonObject): JsonObject[] { + const carriers: unknown[] = [ + ...Object.values(asObject(payload.arguments) ?? {}), + ...(Array.isArray(payload.outputs) ? payload.outputs : []), + ]; + const found: JsonObject[] = []; + for (const carrier of carriers) { + if (found.length === PROVENANCE_COLUMN_LIMIT) { + break; + } + const provenance = asObject(asObject(carrier)?.provenance); + if (provenance !== undefined) { + found.push(provenance); + } + } + return found; +} + +/** One column over every Provenance object, in order, padded with `""` + * where an object lacks the field, which is the same rule policyColumn applies, for + * the same reason: every column is the length of `acs.provenance.origin`, so + * the i-th element of each describes the i-th object, and a `source_id` one + * argument carries is never hidden by another argument that has none. */ +export function provenanceColumn(objects: JsonObject[], read: (provenance: JsonObject) => string | undefined): string[] { + return objects.map((object) => read(object) ?? ""); +} + +export function collect(into: Attributes, names: Iterable, sources: Record>, parts: Parts): void { + for (const name of names) { + const value = sources[name]?.(parts); + if (value !== undefined) { + into[name] = value; + } + } +} diff --git a/reference-implementations/agt/packages/guardian/src/otel-trace-exporter.ts b/reference-implementations/agt/packages/guardian/src/otel-trace-exporter.ts new file mode 100644 index 00000000..b28bb85b --- /dev/null +++ b/reference-implementations/agt/packages/guardian/src/otel-trace-exporter.ts @@ -0,0 +1,570 @@ +/** + * createOtelTraceExporter emits one OpenTelemetry span per ACS step this + * Guardian decides, so a reviewer can rebuild a session offline from a trace + * file without reading the envelope log. Span names, the attributes each + * span must and may carry, and the event a decision is recorded as all come + * from `specification/v0.1.0/trace/otel-mapping.json`, read once at + * construction. A rename in that mapping needs no change here. + * + * This file assembles the exporter. Three neighbors hold the rest: + * otel-mapping.ts reads the mapping and says which envelope field feeds + * which attribute, otlp-file-exporter.ts writes the OTLP/JSON file, and + * loopback-proxy.ts holds the proxy helpers main.ts applies before the + * Guardian starts. + * + * Span lifetime follows the envelope log. `start` is called where the + * request is recorded and hands back the span; `end` is called with that + * same handle where the response is recorded, at the same two call sites in + * server.ts. The handle is what pairs them, never a lookup: a JSON-RPC id is + * the client's, two sessions can reuse one at the same moment, and nothing + * keyed on the id could tell their responses apart. Only a method that has a + * `step_to_span` entry AND that this Guardian dispatches becomes a span; + * `handshake/hello`, a method the Guardian does not dispatch, and an + * unparseable body produce none, and `start` answers null for them. + * + * A step that got no decision is recorded too, as the `acs.decision.failure` + * event #37 defines, for the failures this Guardian can see itself: an + * error it answered instead of a decision, a decision it finished after the + * host's negotiated timeout (so the host had already applied its posture), + * and a step still open when the Guardian shut down. The host adapter's + * audit log holds the failures only the host sees; audit-to-otlp.ts turns + * those into the same event with `reported_by` set to the observed agent. + * + * Off by default. With no file and no endpoint configured, startGuardian is + * handed NULL_OTEL_TRACE_EXPORTER, which emits nothing. The Guardian's + * decisions are the same as with export on, and so is the bounded drain in + * close(). + * + * Total by construction, as envelope-log-sink.ts is. Every entry point is + * wrapped, and nothing propagates into or delays the decision path. Each + * output fails on its own: the first failed or throwing export disables + * that output alone, reports once under its label, and reaches the + * processor as a success, so a collector that is down, restarting or + * answering 4xx cannot reject a flush and take the file down with it. The + * lab reads the file. A broken mapping or a bug inside start/end still + * disables everything, once. Ended spans go to one BatchSpanProcessor per + * output and the file is appended asynchronously, so serialisation and I/O + * happen off the request's own turn. Observability degrades; governance + * does not. + */ +import { SpanStatusCode, type Attributes, type Span, type Tracer } from "@opentelemetry/api"; +import { ExportResultCode } from "@opentelemetry/core"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { AlwaysOnSampler, BasicTracerProvider, BatchSpanProcessor, type SpanExporter, type SpanLimits } from "@opentelemetry/sdk-trace-base"; +import { + asNumber, + asObject, + collect, + CORRELATION_ATTRIBUTE_SOURCES, + DECISION_ATTRIBUTE_SOURCES, + DECISION_DETAIL_NAMES, + DECISION_FAILURE_EVENT, + decisionFailureAttributes, + GUARDIAN_ERROR_STATUS, + loadOtelMapping, + PROVENANCE_ATTRIBUTE_SOURCES, + provenanceColumn, + provenanceObjects, + REPLAY_DETECTED_CODE, + requestParts, + resultParts, + STEP_ATTRIBUTE_SOURCES, + type DecisionFailure, + type FailureClass, + type OtelMapping, +} from "./otel-mapping.ts"; +import { createFileSpanExporter } from "./otlp-file-exporter.ts"; + +// Every name this module exported before the split, so existing importers +// keep working. +export { + DECISION_ATTRIBUTE_SOURCES, + DECISION_FAILURE_EVENT, + decisionFailureAttributes, + FAILURE_ATTRIBUTES, + GUARDIAN_ERROR_STATUS, + loadOtelMapping, + METHODS_WITHOUT_SPAN, + STEP_ATTRIBUTE_SOURCES, + type DecisionFailure, + type FailureClass, + type FailurePosture, + type FailureReporter, + type OtelMapping, + type RequestParts, + type ResultParts, + type Source, +} from "./otel-mapping.ts"; +export { createFileSpanExporter } from "./otlp-file-exporter.ts"; +export { keepLoopbackOffProxy, loopbackProxyWarning } from "./loopback-proxy.ts"; + +/** The SDK's compression enum, which this package does not import directly. */ +type OtlpCompression = NonNullable[0]>["compression"]; + +/** How long the OTLP/HTTP exporter keeps retrying one batch against an + * endpoint that does not answer before it reports the failure. */ +const ENDPOINT_TIMEOUT_MS = 2000; + +/** How long one batch export, and the exporter's shutdown, may take before + * it is abandoned and reported. A stalled output (a hung network mount, a + * collector that never answers) must not hold the process open: the SDK's + * own default is 30 s, longer than many supervisors wait before a SIGKILL. */ +const EXPORT_TIMEOUT_MS = ENDPOINT_TIMEOUT_MS + 1000; + +/** The longest string attribute a span carries. Every attribute value can + * come from the request, and an uncapped value lets one large request grow + * the Guardian's memory in the export queue; a Guardian that runs out of + * memory fails open under the default posture. `acs.reasoning` longer than + * this is cut to it. */ +const ATTRIBUTE_VALUE_LENGTH_LIMIT = 4096; + +/** The `service.name` every span carries. The literal key rather than the + * semantic-conventions package: one constant does not earn a dependency. */ +export const OTEL_SERVICE_NAME = "acs-reference-guardian"; + +/** Every span limit, set in code so no OTEL_SPAN_* or OTEL_ATTRIBUTE_* + * variable changes what is exported: the SDK reads the environment for any + * limit left unset. Shared with the audit converter, so the two writers of + * the same file shape cannot drift. Frozen, because both providers hold it. */ +export const SPAN_LIMITS: Readonly = Object.freeze({ + attributeValueLengthLimit: ATTRIBUTE_VALUE_LENGTH_LIMIT, + attributeCountLimit: 128, + eventCountLimit: 128, + attributePerEventCountLimit: 128, + linkCountLimit: 128, + attributePerLinkCountLimit: 128, +}); + +/** + * The span this module started for one step, handed to the caller so the + * response can close the same span and no other. Opaque: a caller holds it + * between the two calls and reads nothing off it. Beside the span it keeps + * what a failure event needs at the end: the method, for its per-method + * timeout; when it started, for `acs.failure.elapsed_ms`; and the request + * body's size, when the caller measured one. + */ +export type StepSpan = { + readonly span: Span; + readonly method: string; + readonly startedAt: number; + readonly payloadBytes?: number; +}; + +/** The Guardian-side writer role, called at the envelope log's own two + * points: `start` where the request is recorded, `end` where the response + * is, with the handle `start` returned. */ +export type OtelTraceExporter = { + /** Null when this request produces no span, and `end` then does nothing. + * `payloadBytes` is the request body's size, for the failure event. */ + start(envelope: unknown, method: string | null, payloadBytes?: number): StepSpan | null; + end(stepSpan: StepSpan | null, response: unknown): void; + /** Pushes every ended span through to the exporters now rather than on + * the batch timer. Tests call it; shutdown does too. */ + flush(): Promise; + /** Flushes, then releases the processors. A second call waits for the + * first rather than returning at once: SIGINT followed by SIGTERM is two + * calls, and the second must not let the process exit mid-flush. */ + shutdown(): Promise; +}; + +/** The exporter a Guardian gets when neither output is configured: the + * composition root disabling trace export gets a value of the same type, + * not a special case. server.ts's close() treats it like any other. */ +export const NULL_OTEL_TRACE_EXPORTER: OtelTraceExporter = { + start(): null { + return null; + }, + end(): void {}, + async flush(): Promise {}, + async shutdown(): Promise {}, +}; + +export type CreateOtelTraceExporterOptions = { + /** OTLP/JSON file, one ExportTraceServiceRequest per line. */ + filePath?: string; + /** OTLP/HTTP traces endpoint, e.g. `http://127.0.0.1:4318/v1/traces`. */ + endpoint?: string; + /** A span exporter used beside (or instead of) the two above. Exists so + * tests can read spans back in-process; not meant for production use. */ + spanExporter?: SpanExporter; + /** The methods this Guardian dispatches: the ServerHello's own list. + * A method outside it produces no span even when the mapping names one. */ + methodsEvaluated: readonly string[]; + /** The ServerHello's `timeout_config`: how long the host waits for a + * decision before it applies its posture. A decision that finishes after + * that is one the host never acted on. Omitted means no timeout is known + * and no step is reported as late. */ + timeoutConfig?: { default_ms: number; per_method_ms?: Record }; + /** The posture the host applies when no decision arrives, in the trace + * vocabulary: `fail_open` for a ServerHello declaring `proceed`, + * `fail_closed` for `deny`. Defaults to `fail_open`, the spec's own default + * posture (handshake.json), for an exporter built without a ServerHello. */ + posture?: "fail_open" | "fail_closed"; + /** Overrides the mapping file. Defaults to the repository's own. */ + mappingPath?: string; + /** How long one export, and shutdown, may take before it is abandoned and + * reported. Defaults to EXPORT_TIMEOUT_MS; tests pass a short one. */ + exportTimeoutMs?: number; + /** Called at most once per output, on that output's first failure, and + * at most once more for a failure of the exporter as a whole (a mapping + * that will not load, a bug inside start or end). Defaults to one stderr + * line each. */ + onError?: (error: unknown) => void; +}; + +/** One place spans go, with the name its failure is reported under: the + * file's path, the endpoint's URL, or "span exporter" for an injected one. */ +type Output = { label: string; exporter: SpanExporter }; + +/** + * Wraps one output so it can fail without taking any other output with it. + * + * The first failed result, thrown export, or rejected flush marks this output + * dead and reports it once, under its label; every later batch for it is + * dropped. The processor is answered with SUCCESS either way, deliberately: + * a FAILED result makes the processor's forceFlush reject, the provider's + * forceFlush and shutdown reject with it, and a collector that is merely down + * would then end export to the file as well. The failure is this module's + * finding, reported here, not the processor's. The SDK's own route for a + * failed result is its global error handler, silent unless a diag logger is + * installed, which is why the report happens here at all. + */ +function isolated({ label, exporter }: Output, report: (label: string, error: unknown) => void): SpanExporter { + let dead = false; + const die = (error: unknown): void => { + if (dead) { + return; + } + dead = true; + report(label, error); + }; + return { + export(spans, resultCallback): void { + if (dead) { + resultCallback({ code: ExportResultCode.SUCCESS }); + return; + } + try { + exporter.export(spans, (result) => { + if (result.code === ExportResultCode.FAILED) { + die(result.error ?? new Error("span export failed")); + } + resultCallback({ code: ExportResultCode.SUCCESS }); + }); + } catch (error) { + die(error); + resultCallback({ code: ExportResultCode.SUCCESS }); + } + }, + async shutdown(): Promise { + // Shut the output down whether or not it died: a dead HTTP output + // still holds its keep-alive agent until its exporter releases it. + try { + await exporter.shutdown(); + } catch (error) { + die(error); + } + }, + async forceFlush(): Promise { + if (dead) { + return; + } + try { + await exporter.forceFlush?.(); + } catch (error) { + die(error); + } + }, + }; +} + +export function createOtelTraceExporter({ + filePath, + endpoint, + spanExporter, + methodsEvaluated, + timeoutConfig, + posture = "fail_open", + mappingPath, + exportTimeoutMs = EXPORT_TIMEOUT_MS, + onError, +}: CreateOtelTraceExporterOptions): OtelTraceExporter { + /** One report, to the caller's hook or to stderr, never thrown: the + * reporter is the one component here that exists only to report, and a + * throw out of it would be the failure it is meant to describe. */ + const report = (what: string, error: unknown): void => { + try { + if (onError) { + onError(error); + return; + } + const message = error instanceof Error ? error.message : String(error); + console.error(`${what} disabled after failure: ${message}`); + } catch { + // Silently swallow any error from the callback or console.error + // to maintain the total-by-construction guarantee + } + }; + + // The exporter as a whole: a mapping that will not load, a provider that + // will not build, a bug inside start or end. Once, then quiet. + let disabled = false; + const fail = (error: unknown): void => { + if (disabled) { + return; + } + disabled = true; + report("otel trace exporter", error); + }; + + // One output: its own first failure, under its own label, and only it. + const reportOutput = (label: string, error: unknown): void => report(`otel trace export to ${label}`, error); + + // Each output is constructed on its own, so an unwritable file path costs + // the file and not the endpoint, and the other way round. + const outputs: Output[] = []; + const construct = (label: string, build: () => SpanExporter): void => { + try { + outputs.push({ label, exporter: build() }); + } catch (error) { + reportOutput(label, error); + } + }; + if (filePath !== undefined) { + construct(filePath, () => createFileSpanExporter(filePath)); + } + if (endpoint !== undefined) { + // A refused connection is retryable to this exporter, so a collector that + // is down costs its whole timeout before the failure is reported. Two + // seconds rather than the 10 s default: long enough for a collector that + // is restarting, short enough that a flush or a shutdown behind a dead + // one does not hold the process for ten. + // compression set in code: OTEL_EXPORTER_OTLP_COMPRESSION would otherwise + // gzip the body. Headers from OTEL_EXPORTER_OTLP_*HEADERS are merged by + // the SDK under any set here and cannot be refused, so the README lists + // them as honored. + construct( + endpoint, + () => new OTLPTraceExporter({ url: endpoint, timeoutMillis: ENDPOINT_TIMEOUT_MS, compression: "none" as OtlpCompression }), + ); + } + if (spanExporter !== undefined) { + construct("span exporter", () => spanExporter); + } + if (outputs.length === 0) { + return NULL_OTEL_TRACE_EXPORTER; + } + + let provider: BasicTracerProvider | undefined; + let tracer: Tracer | undefined; + let mapping: OtelMapping | undefined; + try { + mapping = loadOtelMapping(mappingPath); + provider = new BasicTracerProvider({ + resource: resourceFromAttributes({ "service.name": OTEL_SERVICE_NAME }), + // Set explicitly so no OTEL_* environment variable can sample spans + // away or drop decision events: the SDK reads OTEL_TRACES_SAMPLER and + // the span limits from the environment for anything left unset. + sampler: new AlwaysOnSampler(), + // Copied, because the SDK may write to the object it is given. + spanLimits: { ...SPAN_LIMITS }, + spanProcessors: outputs.map( + (output) => + new BatchSpanProcessor(isolated(output, reportOutput), { + // The SDK defaults, written out: anything left unset is read from + // OTEL_BSP_*, and a queue of one drops spans. + maxQueueSize: 2048, + maxExportBatchSize: 512, + scheduledDelayMillis: 5000, + exportTimeoutMillis: exportTimeoutMs, + }), + ), + }); + tracer = provider.getTracer(OTEL_SERVICE_NAME); + } catch (error) { + fail(error); + } + + const handled = new Set(methodsEvaluated); + + // Started and not yet ended, so shutdown can close what no response ever + // reached: a bridge that never answers, or a signal during traffic. An + // open span is never exported, and without this the step left no trace. + const openSpans = new Set(); + + /** The failure event's attributes for one step, as this Guardian reports + * them: its own posture, elapsed time since the span started, and the + * request's size when known. */ + function failureAttributes(stepSpan: StepSpan, failureClass: FailureClass, known: Partial = {}): Attributes { + return decisionFailureAttributes({ + class: failureClass, + posture, + reportedBy: "guardian", + elapsedMs: Math.round(performance.now() - stepSpan.startedAt), + payloadBytes: stepSpan.payloadBytes, + ...known, + }); + } + + function begin(envelope: unknown, method: string | null, payloadBytes: number | undefined, tracer: Tracer, mapping: OtelMapping): StepSpan | null { + if (method === null || !handled.has(method)) { + return null; + } + const entry = mapping.stepToSpan[method]; + if (entry === undefined) { + return null; + } + const parts = requestParts(envelope); + const attributes: Attributes = {}; + collect(attributes, Object.keys(CORRELATION_ATTRIBUTE_SOURCES), CORRELATION_ATTRIBUTE_SOURCES, parts); + collect( + attributes, + [...entry.required_attributes, ...(entry.conditional_attributes ?? []), ...(entry.optional_attributes ?? [])], + STEP_ATTRIBUTE_SOURCES, + parts, + ); + const provenance = provenanceObjects(parts.payload); + if (provenance.length > 0) { + for (const name of mapping.provenanceAttributes) { + const read = PROVENANCE_ATTRIBUTE_SOURCES[name]; + // A column is emitted only when at least one object carries the + // field (#228). `origin` is required, so it always is; an + // all-padding `source_id` column would say something nobody sent. + if (read !== undefined && provenance.some((object) => read(object) !== undefined)) { + attributes[name] = provenanceColumn(provenance, read); + } + } + } + const stepSpan: StepSpan = { + span: tracer.startSpan(entry.span_name, { attributes, root: true }), + method, + startedAt: performance.now(), + payloadBytes, + }; + openSpans.add(stepSpan); + return stepSpan; + } + + function finish(stepSpan: StepSpan, envelope: unknown, mapping: OtelMapping): void { + const { span } = stepSpan; + const decision = resultParts(envelope); + if (decision === undefined) { + // A JSON-RPC error: the step was answered but not decided. The first + // of the two failures a Guardian can see itself (#37). Only the code is + // carried: the message can quote the request (a schema error names its + // argument keys), and #228 forbids copying response or error bytes into + // the trace, so the status message is a fixed sentence too. + const error = asObject(asObject(envelope)?.error); + const errorCode = asNumber(error?.code); + span.setStatus({ code: SpanStatusCode.ERROR, message: GUARDIAN_ERROR_STATUS }); + if (errorCode !== REPLAY_DETECTED_CODE) { + span.addEvent(DECISION_FAILURE_EVENT, failureAttributes(stepSpan, "guardian_error", { errorCode })); + } + } else { + const attributes: Attributes = {}; + collect(attributes, new Set([...mapping.decisionAttributes, ...DECISION_DETAIL_NAMES]), DECISION_ATTRIBUTE_SOURCES, decision); + if (attributes["acs.decision"] !== undefined) { + span.addEvent(mapping.decisionEventName, attributes); + } + // The second: a decision that finished after the host stopped waiting. + // The host applied its posture at the timeout, so the decision above + // probably never took effect, and a reader has to be told. + const timeoutMs = timeoutConfig?.per_method_ms?.[stepSpan.method] ?? timeoutConfig?.default_ms; + if (timeoutMs !== undefined && performance.now() - stepSpan.startedAt > timeoutMs) { + span.addEvent(DECISION_FAILURE_EVENT, failureAttributes(stepSpan, "timeout", { timeoutMs })); + } + } + span.end(); + } + + let stopped = false; + // The one shutdown, started by the first call and returned to every call + // after it, so a second signal waits for the flush the first began. + let shutdownPromise: Promise | undefined; + + return { + start(envelope, method, payloadBytes): StepSpan | null { + if (disabled || stopped || tracer === undefined || mapping === undefined) { + return null; + } + try { + return begin(envelope, method, payloadBytes, tracer, mapping); + } catch (error) { + fail(error); + return null; + } + }, + end(stepSpan, response): void { + if (stepSpan === null) { + return; + } + // Removed first, whatever happens next: a span the caller is ending + // is no longer one shutdown has to close. + openSpans.delete(stepSpan); + if (disabled || stopped || mapping === undefined) { + return; + } + try { + finish(stepSpan, response, mapping); + } catch (error) { + fail(error); + } + }, + async flush(): Promise { + if (provider === undefined || stopped) { + return; + } + try { + await provider.forceFlush(); + } catch (error) { + fail(error); + } + }, + shutdown(): Promise { + shutdownPromise ??= (async () => { + if (provider === undefined) { + return; + } + stopped = true; + // Steps still open: a bridge that never answered, or a signal during + // traffic. Closed here, as the third failure this Guardian can see, + // so the step is exported at all. + for (const stepSpan of openSpans) { + try { + stepSpan.span.addEvent( + DECISION_FAILURE_EVENT, + failureAttributes(stepSpan, "other", { detail: "the Guardian shut down before it answered this step" }), + ); + stepSpan.span.setStatus({ code: SpanStatusCode.ERROR, message: "no response before shutdown" }); + stepSpan.span.end(); + } catch (error) { + fail(error); + } + } + openSpans.clear(); + // Flushes every processor, then releases them. Bounded, because a + // stalled output would otherwise hold the process open. The SDK's own + // export timeout covers a batch in flight; this covers an exporter + // whose shutdown never returns. + let timer: ReturnType | undefined; + const deadline = new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), exportTimeoutMs); + timer.unref?.(); + }); + try { + const outcome = await Promise.race([provider.shutdown().then(() => "done" as const), deadline]); + if (outcome === "timeout") { + report("otel trace exporter shutdown", new Error(`did not finish within ${exportTimeoutMs} ms; unexported spans were dropped`)); + } + } catch (error) { + fail(error); + } finally { + clearTimeout(timer); + } + })(); + return shutdownPromise; + }, + }; +} + diff --git a/reference-implementations/agt/packages/guardian/src/otlp-file-exporter.ts b/reference-implementations/agt/packages/guardian/src/otlp-file-exporter.ts new file mode 100644 index 00000000..2d524082 --- /dev/null +++ b/reference-implementations/agt/packages/guardian/src/otlp-file-exporter.ts @@ -0,0 +1,69 @@ +/** + * The OTLP/JSON file output, the one span exporter this package writes + * itself. The Guardian's exporter and the audit-log converter both use it, + * so the two files share one shape. + */ +import { mkdirSync } from "node:fs"; +import { appendFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { ExportResultCode } from "@opentelemetry/core"; +import { JsonTraceSerializer } from "@opentelemetry/otlp-transformer"; +import type { SpanExporter } from "@opentelemetry/sdk-trace-base"; + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +/** + * Writes OTLP/JSON, one `ExportTraceServiceRequest` per line (the shape + * the OpenTelemetry Collector's file exporter writes), serialised by the + * official JsonTraceSerializer rather than by hand. + * + * The append is asynchronous: this exporter runs on the same event loop the + * Guardian answers requests on, and a synchronous write would hold every + * in-flight decision for the duration of the disk I/O. Writes are chained on + * one promise so lines land in export order even when two batches are in + * flight, and `forceFlush` and `shutdown` await the chain so a close never + * leaves a batch unwritten. The chain itself never rejects: one failed write + * is reported through its own batch's callback and cannot poison every write + * after it. + * + * 0o700 on the directory and 0o600 on the file: the file holds reasoning + * text and provenance source ids, so other local users should not read it + * (#71 is the same problem in the envelope log). The mode applies when the + * exporter creates the path; an existing file keeps whatever it had. + */ +export function createFileSpanExporter(path: string): SpanExporter { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + let chain: Promise = Promise.resolve(); + return { + export(spans, resultCallback): void { + let line: string; + try { + const bytes = JsonTraceSerializer.serializeRequest(spans); + if (bytes === undefined) { + throw new Error("JsonTraceSerializer produced no request"); + } + line = `${new TextDecoder().decode(bytes)}\n`; + } catch (error) { + resultCallback({ code: ExportResultCode.FAILED, error: asError(error) }); + return; + } + const write = chain.then(() => appendFile(path, line, { mode: 0o600 })); + chain = write.then( + () => undefined, + () => undefined, + ); + write.then( + () => resultCallback({ code: ExportResultCode.SUCCESS }), + (error: unknown) => resultCallback({ code: ExportResultCode.FAILED, error: asError(error) }), + ); + }, + async shutdown(): Promise { + await chain; + }, + async forceFlush(): Promise { + await chain; + }, + }; +} diff --git a/reference-implementations/agt/packages/guardian/src/server.ts b/reference-implementations/agt/packages/guardian/src/server.ts index 9fd4e21f..922c11bd 100644 --- a/reference-implementations/agt/packages/guardian/src/server.ts +++ b/reference-implementations/agt/packages/guardian/src/server.ts @@ -93,6 +93,7 @@ import { import { checkResponse } from "./check-response.ts"; import { buildServerHello, type ServerHello } from "./handshake.ts"; import { createEnvelopeLogSink, NULL_ENVELOPE_LOG_SINK, type EnvelopeLogSink } from "./envelope-log-sink.ts"; +import { createOtelTraceExporter, NULL_OTEL_TRACE_EXPORTER, type OtelTraceExporter } from "./otel-trace-exporter.ts"; import { appendContextEntry, createMemorySessionContextStore, @@ -331,6 +332,17 @@ export type StartGuardianOptions = { * `sessionContextLog` when both are supplied. */ sessionContextStore?: SessionContextStore; + /** OTLP/JSON trace file, one export request per line. Omitted means no + * span is written. `packages/guardian/src/main.ts` threads ACS_OTEL_FILE + * into this; otel-trace-exporter.ts says what a span carries. */ + otelTraceFile?: string; + /** OTLP/HTTP traces endpoint. Omitted means no span is sent. main.ts + * threads ACS_OTEL_ENDPOINT into this. */ + otelTraceEndpoint?: string; + /** Overrides the trace exporter entirely, bypassing both paths above. + * Exists so a test can read spans back in-process or stand in one that + * fails. Not meant for production use. */ + traceExporter?: OtelTraceExporter; }; export type StartedGuardian = { url: string; close(): Promise }; @@ -398,6 +410,9 @@ export async function startGuardian({ bridge: bridgeOverride, sessionContextLog, sessionContextStore: sessionContextStoreOverride, + otelTraceFile, + otelTraceEndpoint, + traceExporter: traceExporterOverride, }: StartGuardianOptions): Promise { // Construct the bridge once at boot, not per request -- and through the // shared recipe rather than assembling one here, so this Guardian and every @@ -420,6 +435,25 @@ export async function startGuardian({ createMemorySessionContextStore( sessionContextLog ? { appendLine: createSessionContextLogAppender(sessionContextLog) } : {}, ); + // Off unless an output is named -- the same relationship envelopeLogPath + // has to the envelope log. The override wins over both paths, as the other + // test seams above do. + // The same ServerHello the handshake sends, read once here, so the trace + // agrees with what this Guardian told the host: which methods it + // evaluates, how long the host waits, and which posture it applies when + // no decision arrives. The timeout also bounds close() below. + const hello = buildServerHello(onDecisionFailure === undefined ? undefined : { ACS_ON_DECISION_FAILURE: onDecisionFailure }); + const traceExporter = + traceExporterOverride ?? + (otelTraceFile !== undefined || otelTraceEndpoint !== undefined + ? createOtelTraceExporter({ + filePath: otelTraceFile, + endpoint: otelTraceEndpoint, + methodsEvaluated: hello.methods_evaluated, + timeoutConfig: hello.timeout_config, + posture: hello.on_decision_failure === "deny" ? "fail_closed" : "fail_open", + }) + : NULL_OTEL_TRACE_EXPORTER); const server = Bun.serve({ hostname: hostname ?? LOOPBACK_ONLY, @@ -429,7 +463,7 @@ export async function startGuardian({ if (req.method !== "POST" || pathname !== ACS_PATH) { return new Response("Not Found", { status: 404 }); } - const response = await handleAcsRequest(req, bridge, mapping, envelopeLog, onDecisionFailure, sessionContextStore); + const response = await handleAcsRequest(req, bridge, mapping, envelopeLog, onDecisionFailure, sessionContextStore, traceExporter); return Response.json(response); }, }); @@ -437,7 +471,21 @@ export async function startGuardian({ return { url: `http://localhost:${server.port}${ACS_PATH}`, async close() { - await server.stop(true); + // The same close whether trace export is on or off: + // NULL_OTEL_TRACE_EXPORTER's shutdown resolves at once, so with export + // off the drain is all that remains. Bun.serve's stop(true) closes + // every connection at once, and on Bun 1.3.11 it resolves only when + // every in-flight handler has returned. A handler whose bridge never + // answers never returns, so the drain is bounded by the negotiated + // timeout: past it, no host is still waiting for the answer. The timer + // is unref'd so a prompt stop does not leave it holding the process + // open. The exporter shuts down after the drain, so a step still open + // then is closed as the failure it is rather than lost. + const deadline = new Promise((resolve) => { + setTimeout(resolve, hello.timeout_config.default_ms).unref(); + }); + await Promise.race([server.stop(true), deadline]); + await traceExporter.shutdown(); }, }; } @@ -469,7 +517,10 @@ export async function startGuardian({ * MAX_REQUEST_BODY_BYTES). * * The sink itself is total (see envelope-log-sink.ts): these two calls cannot - * throw, so they cannot turn a governed tool call into an ungoverned one. + * throw, so they cannot turn a governed tool call into an ungoverned one. The + * trace exporter's `start` and `end` sit beside them: the handle `start` + * returns is what `end` closes, so a span can only ever close on its own + * response, and both are total for the same reason (otel-trace-exporter.ts). */ async function handleAcsRequest( req: Request, @@ -481,6 +532,7 @@ async function handleAcsRequest( envelopeLog: EnvelopeLogSink, onDecisionFailure: "proceed" | "deny" | undefined, sessionContextStore: SessionContextStore, + traceExporter: OtelTraceExporter, ): Promise { const body = await readCappedBody(req); if (body.withinLimit === false) { @@ -518,6 +570,7 @@ async function handleAcsRequest( // visible to the Inspector rather than invisible. const method = extractMethod(raw); envelopeLog.write("request", raw, method); + const stepSpan = traceExporter.start(raw, method, Buffer.byteLength(body.text)); let response: JsonRpcSuccess | JsonRpcFailure; try { @@ -563,6 +616,7 @@ async function handleAcsRequest( } envelopeLog.write("response", response, method); + traceExporter.end(stepSpan, response); return response; } diff --git a/reference-implementations/agt/packages/guardian/test/helpers/envelopes.ts b/reference-implementations/agt/packages/guardian/test/helpers/envelopes.ts new file mode 100644 index 00000000..b216e3ca --- /dev/null +++ b/reference-implementations/agt/packages/guardian/test/helpers/envelopes.ts @@ -0,0 +1,35 @@ +/** + * Request envelopes for the guardian package's tests. Shared because both + * the exporter tests and main.test.ts post a real `steps/toolCallRequest`. + */ + +export function makeEnvelope( + method: string, + payload: Record, + overrides: { id?: number | null; requestId?: string; sessionId?: string } = {}, +): Record { + const { id = 1, requestId = crypto.randomUUID(), sessionId = crypto.randomUUID() } = overrides; + return { + jsonrpc: "2.0", + method, + ...(id === null ? {} : { id }), + params: { + acs_version: "0.1.0", + request_id: requestId, + timestamp: new Date().toISOString(), + metadata: { agent_id: "agent-1", session_id: sessionId }, + payload, + }, + }; +} + +/** Carries `capability`, which hooks/tool-call-request.json declares + * optional and the mapping requires on the span, so a span built from this + * envelope has every required attribute the mapping names. */ +export function toolCallEnvelope(command: string, overrides: { id?: number | null; requestId?: string; sessionId?: string } = {}) { + return makeEnvelope( + "steps/toolCallRequest", + { tool: { name: "run_shell" }, capability: "process.exec", arguments: { command: { value: command } } }, + overrides, + ); +} diff --git a/reference-implementations/agt/packages/guardian/test/main.test.ts b/reference-implementations/agt/packages/guardian/test/main.test.ts new file mode 100644 index 00000000..b4d2f5ac --- /dev/null +++ b/reference-implementations/agt/packages/guardian/test/main.test.ts @@ -0,0 +1,178 @@ +/** + * main.ts is the composition root, so it is the one place that reads the + * proxy variables and applies the loopback exemption (Ariel's review on + * PR #286). These tests run it as the operator does, as a process, because + * the wiring under test is the entrypoint itself. + */ +import { afterEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdtempSync, rmdirSync, unlinkSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { toolCallEnvelope } from "./helpers/envelopes.ts"; + +const GUARDIAN = fileURLToPath(new URL("../src/main.ts", import.meta.url)); +/** Absolute, because the process runs from a scratch cwd rather than from + * the repo root, the same reason test/readme-captures.test.ts gives. */ +const MANIFEST = fileURLToPath(new URL("../../../policy/manifest.yaml", import.meta.url)); + +const dirs: string[] = []; +function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), "acs-main-")); + dirs.push(dir); + return dir; +} + +/** What a run can leave in its scratch cwd: the `.acs/` directory the + * Guardian makes at startup, and the two default logs once an envelope + * arrives. Each is removed by name, never recursively, so rmdirSync fails + * loudly on anything else. */ +const LEFT_BEHIND = [".acs/envelopes.jsonl", ".acs/session-context.jsonl"]; +afterEach(() => { + for (const dir of dirs.splice(0)) { + for (const file of LEFT_BEHIND) { + if (existsSync(join(dir, file))) { + unlinkSync(join(dir, file)); + } + } + const acs = join(dir, ".acs"); + if (existsSync(acs)) { + rmdirSync(acs); + } + rmdirSync(dir); + } +}); + +/** A port nothing listens on once this returns. No span is sent in these + * tests, so the endpoint only has to be a well-formed loopback URL. */ +async function freePort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as { port: number }; + await new Promise((resolve) => server.close(() => resolve())); + return port; +} + +describe("main.ts applies the loopback proxy exemption", () => { + it("warns on stderr at startup when a proxy is set and loopback is not exempt", async () => { + const endpoint = `http://127.0.0.1:${await freePort()}/v1/traces`; + // process.execPath, not "bun" off PATH, so the entrypoint runs on the + // same Bun version as the suite. The environment is built up rather than + // inherited, so a NO_PROXY in the developer's shell cannot hide the + // warning. `timeout` is the runtime's own watchdog, shorter than bun + // test's five-second cap, so a missing warning fails on the assertion. + const proc = Bun.spawn([process.execPath, GUARDIAN], { + cwd: scratch(), + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "", + NO_COLOR: "1", + ACS_GUARDIAN_PORT: "0", + ACS_MANIFEST_PATH: MANIFEST, + ACS_OTEL_ENDPOINT: endpoint, + HTTP_PROXY: "http://127.0.0.1:9/", + }, + stdout: "ignore", + stderr: "pipe", + timeout: 3_000, + }); + const decoder = new TextDecoder(); + let stderr = ""; + for await (const chunk of proc.stderr) { + stderr += decoder.decode(chunk, { stream: true }); + if (stderr.includes("did not exempt 127.0.0.1")) { + proc.kill(); + } + } + proc.kill(); + await proc.exited; + expect(stderr).toContain( + `otel trace export to ${endpoint}: an HTTP proxy is set and NO_PROXY/no_proxy did not exempt 127.0.0.1, ` + + "so on some Bun versions these spans go through the proxy. Start the Guardian with 127.0.0.1 in both NO_PROXY and no_proxy.", + ); + }); + + // The warning above proves main.ts reads the variables. This proves it + // edits them: spans reach the collector and not the proxy. Gated to Bun + // 1.3.14 and later, because 1.3.11 reads the proxy variables only at + // startup and ignores the edit. + it.skipIf(!Bun.semver.satisfies(Bun.version, ">=1.3.14"))( + "sends spans to a loopback collector directly when NO_PROXY holds only the user's own entries", + async () => { + // A proxy that only counts connections and never answers. + let proxyConnections = 0; + const proxy = createServer((socket) => { + proxyConnections += 1; + socket.destroy(); + }); + await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve)); + const proxyUrl = `http://127.0.0.1:${(proxy.address() as { port: number }).port}`; + let exports = 0; + const collector = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch() { + exports += 1; + return new Response("{}", { headers: { "content-type": "application/json" } }); + }, + }); + // Async spawn, not spawnSync: the proxy and the collector run on this + // process's event loop and could not answer the child while blocked. + // `timeout` kills a Guardian that never exits, so the test fails on + // the assertions rather than hanging. + const proc = Bun.spawn([process.execPath, GUARDIAN], { + cwd: scratch(), + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "", + NO_COLOR: "1", + ACS_GUARDIAN_PORT: "0", + ACS_MANIFEST_PATH: MANIFEST, + ACS_OTEL_ENDPOINT: `http://127.0.0.1:${collector.port}/v1/traces`, + HTTP_PROXY: proxyUrl, + http_proxy: proxyUrl, + // A user's own exemption, which the edit must keep. no_proxy is + // left unset, so the edit goes to NO_PROXY alone. + NO_PROXY: "corp.internal", + }, + stdout: "pipe", + stderr: "ignore", + timeout: 12_000, + }); + try { + // main.ts prints "OTel traces:" in the same turn that installs its + // SIGTERM handler, so once that line is in, a SIGTERM flushes. + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let stdout = ""; + while (!stdout.includes("OTel traces:")) { + const { value, done } = await reader.read(); + if (done) { + break; + } + stdout += decoder.decode(value, { stream: true }); + } + reader.releaseLock(); + const url = /Guardian listening at (\S+)/.exec(stdout)?.[1]; + expect(url).toBeDefined(); + const response = await fetch(url!, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(toolCallEnvelope("ls -la")), + }); + expect(response.ok).toBe(true); + await response.json(); + proc.kill("SIGTERM"); + await proc.exited; + expect({ proxyConnections, exportsReceived: exports > 0 }).toEqual({ proxyConnections: 0, exportsReceived: true }); + } finally { + proc.kill(); + await proc.exited; + collector.stop(true); + proxy.close(); + } + }, + 15_000, + ); +}); diff --git a/reference-implementations/agt/packages/guardian/test/map-verdict.test.ts b/reference-implementations/agt/packages/guardian/test/map-verdict.test.ts index 2c60d84d..5bdd52b0 100644 --- a/reference-implementations/agt/packages/guardian/test/map-verdict.test.ts +++ b/reference-implementations/agt/packages/guardian/test/map-verdict.test.ts @@ -27,6 +27,21 @@ describe("mapVerdict", () => { expect(d.policy_references?.[0]?.rule_id).toBe("destructive_shell_command_blocked"); }); + // response-envelope.json requires a non-empty rule_id (#263), and Trace pads + // its policy arrays with "" to mean "absent". An empty AGT reason names no + // rule, so it produces no reference rather than one that fails the schema. + it("treats an empty AGT reason as no rule: a deny carries no policy_references", () => { + const d = mapVerdict({ decision: "deny", reason: "" }, m, "pre_tool_call", "command"); + expect(d.decision).toBe("deny"); + expect(d.policy_references).toBeUndefined(); + }); + + it("treats an empty AGT reason as no rule: a warn, which needs one, fails", () => { + expect(() => mapVerdict({ decision: "warn", reason: "" }, m, "pre_tool_call", "command")).toThrow( + "verdict.reason was empty", + ); + }); + // The whole warn round trip rests on this. it("maps warn to allow WITH non-empty policy_references", () => { const d = mapVerdict( diff --git a/reference-implementations/agt/packages/guardian/test/otel-trace-exporter.test.ts b/reference-implementations/agt/packages/guardian/test/otel-trace-exporter.test.ts new file mode 100644 index 00000000..0d69ca9d --- /dev/null +++ b/reference-implementations/agt/packages/guardian/test/otel-trace-exporter.test.ts @@ -0,0 +1,1649 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SpanStatusCode } from "@opentelemetry/api"; +import { InMemorySpanExporter, type ReadableSpan, type SpanExporter } from "@opentelemetry/sdk-trace-base"; +import type { PolicyBridge } from "agt-bridge"; +import { startGuardian, type GuardianSnapshot } from "../src/index.ts"; +import { FAILURE_DETAIL_BY_KIND } from "../src/audit-to-otlp.ts"; +import { buildServerHello } from "../src/handshake.ts"; +import { makeEnvelope, toolCallEnvelope } from "./helpers/envelopes.ts"; +import { + createFileSpanExporter, + keepLoopbackOffProxy, + createOtelTraceExporter, + DECISION_ATTRIBUTE_SOURCES, + loadOtelMapping, + loopbackProxyWarning, + METHODS_WITHOUT_SPAN, + NULL_OTEL_TRACE_EXPORTER, + OTEL_SERVICE_NAME, + STEP_ATTRIBUTE_SOURCES, + type CreateOtelTraceExporterOptions, + type OtelTraceExporter, +} from "../src/otel-trace-exporter.ts"; + +/** The live mapping, read the same way the exporter reads it, so every + * expected name below comes from the file and not from a literal that could + * drift from it. */ +const MAPPING = loadOtelMapping(); +/** The methods this Guardian evaluates, from the ServerHello it sends -- + * the same source server.ts builds the exporter from. */ +const METHODS_EVALUATED = buildServerHello().methods_evaluated; +const TOOL_CALL_SPAN = MAPPING.stepToSpan["steps/toolCallRequest"]!; + +/** A Provenance object as provenance.json defines it: `provenance_id` and + * `origin` required, everything else optional. */ +function provenance(origin: string, extra: Record = {}): Record { + return { provenance_id: crypto.randomUUID(), origin, ...extra }; +} + +function decisionResponse(id: number, result: Record): Record { + return { jsonrpc: "2.0", id, result: { type: "final", acs_version: "0.1.0", request_id: "r-1", ...result } }; +} + +const DENY = { + decision: "deny", + reasoning: "blocked", + reason_codes: ["dangerous_command"], + policy_references: [{ policy_id: "agt_stock", rule_id: "deny-rm-rf" }], +}; + +function memoryExporter(options: Partial = {}): { + exporter: OtelTraceExporter; + spans(): ReadableSpan[]; +} { + const memory = new InMemorySpanExporter(); + const exporter = createOtelTraceExporter({ spanExporter: memory, methodsEvaluated: METHODS_EVALUATED, ...options }); + return { exporter, spans: () => memory.getFinishedSpans() }; +} + +/** A span exporter that fails on every export, the way an unwritable file or + * an unreachable collector would. */ +function throwingSpanExporter(): SpanExporter { + return { + export(): void { + throw new Error("exporter threw"); + }, + async shutdown(): Promise {}, + }; +} + +async function postRaw(url: string, body: unknown): Promise> { + const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + return (await res.json()) as Record; +} + +const baseOptions = { port: 0, manifestPath: "policy/manifest.yaml" } as const; + +/** The OTLP/JSON `ExportTraceServiceRequest` shape, as far as these tests + * read it: resource attributes, and span names and events. */ +type OtlpAttribute = { key: string; value: { stringValue?: string; intValue?: string | number } }; +type OtlpEvent = { name: string; attributes?: OtlpAttribute[] }; +type OtlpSpan = { name: string; attributes?: OtlpAttribute[]; events?: OtlpEvent[]; status?: { code?: number } }; +type OtlpRequest = { + resourceSpans: { + resource: { attributes: OtlpAttribute[] }; + scopeSpans: { spans: OtlpSpan[] }[]; + }[]; +}; + +function otlpSpans(requests: OtlpRequest[]): OtlpSpan[] { + return requests.flatMap((r) => r.resourceSpans.flatMap((rs) => rs.scopeSpans.flatMap((ss) => ss.spans))); +} + +/** One string attribute off an OTLP/JSON span or event, or undefined. */ +function otlpAttribute(carrier: OtlpSpan | OtlpEvent | undefined, key: string): string | undefined { + return carrier?.attributes?.find((attribute) => attribute.key === key)?.value.stringValue; +} + +/** Every line of an OTLP/JSON file, parsed. */ +function otlpFile(path: string): OtlpRequest[] { + return readFileSync(path, "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line) as OtlpRequest); +} + +describe("createOtelTraceExporter -- what one exchange becomes", () => { + it("names the span from the live mapping and carries every required attribute the wire supplied", async () => { + const { exporter, spans } = memoryExporter(); + const sessionId = crypto.randomUUID(); + const requestId = crypto.randomUUID(); + const request = toolCallEnvelope("rm -rf /", { id: 7, requestId, sessionId }); + + const step = exporter.start(request, "steps/toolCallRequest"); + exporter.end(step, decisionResponse(7, DENY)); + await exporter.flush(); + + const [span] = spans(); + expect(spans()).toHaveLength(1); + expect(span?.name).toBe(TOOL_CALL_SPAN.span_name); + for (const attribute of TOOL_CALL_SPAN.required_attributes) { + expect({ attribute, present: span?.attributes[attribute] !== undefined }).toEqual({ attribute, present: true }); + } + expect(span?.attributes["gen_ai.tool.name"]).toBe("run_shell"); + expect(span?.attributes["acs.session.id"]).toBe(sessionId); + expect(span?.attributes["acs.request_id"]).toBe(requestId); + expect(span?.resource.attributes["service.name"]).toBe(OTEL_SERVICE_NAME); + }); + + it("records the decision as the mapping's event on that span, with acs.decision and no evaluator the response did not send", async () => { + const { exporter, spans } = memoryExporter(); + exporter.end(exporter.start(toolCallEnvelope("rm -rf /", { id: 7 }), "steps/toolCallRequest"), decisionResponse(7, DENY)); + await exporter.flush(); + + const [event] = spans()[0]?.events ?? []; + expect(event?.name).toBe(MAPPING.decisionEventName); + expect(event?.attributes?.["acs.decision"]).toBe("deny"); + // The response carried no metadata.evaluator, so the event carries no + // acs.evaluator: the trace never says what the wire did not. + expect(event?.attributes?.["acs.evaluator"]).toBeUndefined(); + expect(event?.attributes?.["acs.reasoning"]).toBe("blocked"); + expect(event?.attributes?.["acs.reason_codes"]).toEqual(["dangerous_command"]); + expect(event?.attributes?.["acs.policy.ids"]).toEqual(["agt_stock"]); + expect(event?.attributes?.["acs.policy.rule_ids"]).toEqual(["deny-rm-rf"]); + // The reference carried no version: the column is still there, one "" + // per reference, so the three columns always line up (#228). + expect(event?.attributes?.["acs.policy.versions"]).toEqual([""]); + }); + + it("takes acs.evaluator from the response when it carries one", async () => { + const { exporter, spans } = memoryExporter(); + exporter.end( + exporter.start(toolCallEnvelope("ls", { id: 7 }), "steps/toolCallRequest"), + decisionResponse(7, { decision: "allow", metadata: { evaluator: "agent", model_id: "m-1" } }), + ); + await exporter.flush(); + + const attributes = spans()[0]?.events[0]?.attributes; + expect(attributes?.["acs.evaluator"]).toBe("agent"); + expect(attributes?.["acs.model_id"]).toBe("m-1"); + }); + + it("lines up policy columns in wire order, with \"\" where a reference lacks the field", async () => { + const { exporter, spans } = memoryExporter(); + exporter.end( + exporter.start(toolCallEnvelope("ls", { id: 7 }), "steps/toolCallRequest"), + decisionResponse(7, { + decision: "deny", + reasoning: "x", + policy_references: [ + { policy_id: "p1", policy_version: "v1", rule_id: "r1" }, + { policy_id: "p2", rule_id: "r2" }, + ], + }), + ); + await exporter.flush(); + + const attributes = spans()[0]?.events[0]?.attributes; + expect(attributes?.["acs.policy.ids"]).toEqual(["p1", "p2"]); + expect(attributes?.["acs.policy.rule_ids"]).toEqual(["r1", "r2"]); + expect(attributes?.["acs.policy.versions"]).toEqual(["v1", ""]); + }); + + it("pads a missing rule_id with \"\" in its own position", async () => { + const { exporter, spans } = memoryExporter(); + exporter.end( + exporter.start(toolCallEnvelope("ls", { id: 7 }), "steps/toolCallRequest"), + decisionResponse(7, { + decision: "deny", + reasoning: "x", + policy_references: [ + { policy_id: "p1", rule_id: "r1" }, + { policy_id: "p2" }, + { policy_id: "p3", rule_id: "r3" }, + ], + }), + ); + await exporter.flush(); + + const attributes = spans()[0]?.events[0]?.attributes; + expect(attributes?.["acs.policy.rule_ids"]).toEqual(["r1", "", "r3"]); + }); + + // The #228 rule, as the maintainer's own case states it: every policy + // column is emitted whenever policy_references is non-empty, all the same + // length, "" where a reference has no value. + it("emits all three policy columns at the length of policy_references", async () => { + const { exporter, spans } = memoryExporter(); + exporter.end( + exporter.start(toolCallEnvelope("ls", { id: 7 }), "steps/toolCallRequest"), + decisionResponse(7, { + decision: "deny", + reasoning: "x", + policy_references: [{ policy_id: "a", rule_id: "r" }, { policy_id: "b" }], + }), + ); + await exporter.flush(); + + const attributes = spans()[0]?.events[0]?.attributes; + expect(attributes?.["acs.policy.ids"]).toEqual(["a", "b"]); + expect(attributes?.["acs.policy.rule_ids"]).toEqual(["r", ""]); + expect(attributes?.["acs.policy.versions"]).toEqual(["", ""]); + }); + + it("carries acs.chain_hash only when the response does", async () => { + const { exporter, spans } = memoryExporter(); + exporter.end(exporter.start(toolCallEnvelope("ls", { id: 1 }), "steps/toolCallRequest"), decisionResponse(1, { decision: "allow", chain_hash: "a".repeat(64) })); + exporter.end(exporter.start(toolCallEnvelope("ls", { id: 2 }), "steps/toolCallRequest"), decisionResponse(2, { decision: "allow" })); + await exporter.flush(); + + const [withHash, withoutHash] = spans(); + expect(withHash?.events[0]?.attributes?.["acs.chain_hash"]).toBe("a".repeat(64)); + expect(withoutHash?.events[0]?.attributes?.["acs.chain_hash"]).toBeUndefined(); + }); + + it("emits no span for a method the mapping does not name, nor for one this Guardian does not dispatch", async () => { + const { exporter, spans } = memoryExporter(); + // handshake/hello has no step_to_span entry. steps/sessionStart has one, + // and is not in the ServerHello's methods_evaluated. + const hello = exporter.start(makeEnvelope("handshake/hello", {}, { id: 1 }), "handshake/hello"); + const sessionStart = exporter.start(makeEnvelope("steps/sessionStart", {}, { id: 2 }), "steps/sessionStart"); + expect(hello).toBeNull(); + expect(sessionStart).toBeNull(); + exporter.end(hello, { jsonrpc: "2.0", id: 1, result: { negotiated_version: "0.1.0" } }); + exporter.end(sessionStart, { jsonrpc: "2.0", id: 2, error: { code: -32011, message: "not dispatched" } }); + await exporter.flush(); + + expect(spans()).toHaveLength(0); + }); + + // The first of the two failures a Guardian can see itself (#37): it + // answered an error instead of a decision. No acs.decision event, since + // there was no decision; one acs.decision.failure event, with the code. + it("ends a span answered with a JSON-RPC error in error status, with a guardian_error failure event and no decision event", async () => { + const { exporter, spans } = memoryExporter(); + exporter.end( + exporter.start(toolCallEnvelope("ls", { id: 9 }), "steps/toolCallRequest"), + { jsonrpc: "2.0", id: 9, error: { code: -32020, message: "evaluation failed" } }, + ); + await exporter.flush(); + + const [span] = spans(); + expect(span?.status.code).toBe(SpanStatusCode.ERROR); + expect(span?.status.message).toBe(FAILURE_DETAIL_BY_KIND.error_without_decision); + expect(span?.events.map((event) => event.name)).toEqual(["acs.decision.failure"]); + const failure = span?.events[0]?.attributes; + expect(failure?.["acs.failure.class"]).toBe("guardian_error"); + expect(failure?.["acs.failure.error_code"]).toBe(-32020); + expect(failure?.["acs.failure.detail"]).toBeUndefined(); + expect(failure?.["acs.failure.reported_by"]).toBe("guardian"); + expect(failure?.["acs.failure.posture"]).toBe("fail_open"); + expect(typeof failure?.["acs.failure.elapsed_ms"]).toBe("number"); + }); + + // The pairing is the handle, not the id: an envelope with no JSON-RPC id + // at all still gets its span, closed by its own response. + it("pairs request and response by handle, so a request with no JSON-RPC id still gets its span", async () => { + const { exporter, spans } = memoryExporter(); + const step = exporter.start(toolCallEnvelope("ls", { id: null }), "steps/toolCallRequest"); + expect(step).not.toBeNull(); + exporter.end(step, { jsonrpc: "2.0", id: null, error: { code: -32010, message: "invalid" } }); + await exporter.flush(); + + expect(spans()).toHaveLength(1); + expect(spans()[0]?.status.code).toBe(SpanStatusCode.ERROR); + }); + + // The whole reason the exporter is total: a broken output must cost + // observability, never a decision. + it("never throws when the span exporter throws, reports once, and goes quiet", async () => { + const errors: unknown[] = []; + const exporter = createOtelTraceExporter({ + spanExporter: throwingSpanExporter(), + methodsEvaluated: METHODS_EVALUATED, + onError: (error) => errors.push(error), + }); + + const step = exporter.start(toolCallEnvelope("ls", { id: 1 }), "steps/toolCallRequest"); + expect(() => exporter.end(step, decisionResponse(1, DENY))).not.toThrow(); + await exporter.flush(); + expect(() => exporter.start(toolCallEnvelope("ls", { id: 2 }), "steps/toolCallRequest")).not.toThrow(); + await exporter.flush(); + await exporter.shutdown(); + + expect(errors).toHaveLength(1); + }); + + it("never throws when the file path is unwritable, and reports once", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-otel-broken-")); + const blocker = join(dir, "blocker"); + await Bun.write(blocker, ""); + const errors: unknown[] = []; + try { + // A path *through* a regular file: mkdirSync fails with ENOTDIR, + // deterministically, on every platform. + const exporter = createOtelTraceExporter({ + filePath: join(blocker, "nested", "traces.jsonl"), + methodsEvaluated: METHODS_EVALUATED, + onError: (error) => errors.push(error), + }); + expect(exporter.start(toolCallEnvelope("ls", { id: 1 }), "steps/toolCallRequest")).toBeNull(); + await exporter.shutdown(); + expect(errors).toHaveLength(1); + } finally { + unlinkSync(blocker); + rmdirSync(dir); + } + }); + + // The lab reads the file. A collector that is down, restarting or + // answering 4xx must cost the endpoint and nothing else: every span still + // reaches the file, and the endpoint is reported once. + it("keeps writing the file when the HTTP collector rejects every export, and reports that output once", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-otel-isolated-")); + const path = join(dir, "traces.jsonl"); + const collector = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("bad request", { status: 400 }) }); + const errors: unknown[] = []; + const exporter = createOtelTraceExporter({ + filePath: path, + endpoint: `http://127.0.0.1:${collector.port}/v1/traces`, + methodsEvaluated: METHODS_EVALUATED, + onError: (error) => errors.push(error), + }); + try { + for (const id of [1, 2, 3]) { + exporter.end(exporter.start(toolCallEnvelope("ls", { id }), "steps/toolCallRequest"), decisionResponse(id, { decision: "allow" })); + await exporter.flush(); + } + await exporter.shutdown(); + + expect(otlpSpans(otlpFile(path)).map((span) => span.name)).toEqual([ + TOOL_CALL_SPAN.span_name, + TOOL_CALL_SPAN.span_name, + TOOL_CALL_SPAN.span_name, + ]); + expect(errors).toHaveLength(1); + } finally { + collector.stop(true); + unlinkSync(path); + rmdirSync(dir); + } + }); + + it("sends one OTLP/JSON request over HTTP whose span is named by the mapping", async () => { + const bodies: OtlpRequest[] = []; + const collector = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + bodies.push((await req.json()) as OtlpRequest); + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + }, + }); + const exporter = createOtelTraceExporter({ endpoint: `http://127.0.0.1:${collector.port}/v1/traces`, methodsEvaluated: METHODS_EVALUATED }); + try { + exporter.end(exporter.start(toolCallEnvelope("ls", { id: 1 }), "steps/toolCallRequest"), decisionResponse(1, { decision: "allow" })); + await exporter.flush(); + await exporter.shutdown(); + + expect(bodies).toHaveLength(1); + expect(otlpSpans(bodies).map((span) => span.name)).toEqual([TOOL_CALL_SPAN.span_name]); + } finally { + collector.stop(true); + } + }); + + it("carries provenance facts as one column per attribute, one element per Provenance object, in argument order", async () => { + const { exporter, spans } = memoryExporter(); + const request = makeEnvelope( + "steps/toolCallRequest", + { + tool: { name: "run_shell" }, + capability: "process.exec", + arguments: { + command: { value: "ls", provenance: provenance("user_input", { source_id: "user-1" }) }, + cwd: { value: "/tmp", provenance: provenance("tool_output", { source_id: "run_shell" }) }, + // No provenance: contributes no element to any column. + flags: { value: "-la" }, + }, + }, + { id: 1 }, + ); + exporter.end(exporter.start(request, "steps/toolCallRequest"), decisionResponse(1, { decision: "allow" })); + await exporter.flush(); + + const attributes = spans()[0]?.attributes; + expect(attributes?.["acs.provenance.origin"]).toEqual(["user_input", "tool_output"]); + expect(attributes?.["acs.provenance.source_id"]).toEqual(["user-1", "run_shell"]); + }); + + it("pads acs.provenance.source_id to the length of acs.provenance.origin with \"\"", async () => { + const { exporter, spans } = memoryExporter(); + const request = makeEnvelope( + "steps/toolCallRequest", + { + tool: { name: "run_shell" }, + arguments: { + command: { value: "ls", provenance: provenance("user_input", { source_id: "user-1" }) }, + cwd: { value: "/tmp", provenance: provenance("system") }, + }, + }, + { id: 1 }, + ); + exporter.end(exporter.start(request, "steps/toolCallRequest"), decisionResponse(1, { decision: "allow" })); + await exporter.flush(); + + const attributes = spans()[0]?.attributes; + expect(attributes?.["acs.provenance.origin"]).toEqual(["user_input", "system"]); + expect(attributes?.["acs.provenance.source_id"]).toEqual(["user-1", ""]); + }); + + it("carries no provenance attribute when nothing in the payload has one", async () => { + const { exporter, spans } = memoryExporter(); + exporter.end(exporter.start(toolCallEnvelope("ls", { id: 1 }), "steps/toolCallRequest"), decisionResponse(1, { decision: "allow" })); + await exporter.flush(); + + const attributes = spans()[0]?.attributes ?? {}; + expect(Object.keys(attributes).filter((key) => key.startsWith("acs.provenance."))).toEqual([]); + }); + + it("reads output provenance on the result gate the same way, in array order", async () => { + const { exporter, spans } = memoryExporter(); + const request = makeEnvelope( + "steps/toolCallResult", + { + tool: { name: "Bash" }, + exit_status: "success", + outputs: [{ value: "a", provenance: provenance("tool_output", { source_id: "Bash" }) }, { value: "b" }], + }, + { id: 1 }, + ); + exporter.end(exporter.start(request, "steps/toolCallResult"), decisionResponse(1, { decision: "allow" })); + await exporter.flush(); + + const [span] = spans(); + expect(span?.name).toBe(MAPPING.stepToSpan["steps/toolCallResult"]!.span_name); + expect(span?.attributes["acs.provenance.origin"]).toEqual(["tool_output"]); + expect(span?.attributes["acs.provenance.source_id"]).toEqual(["Bash"]); + }); + + // The file exporter on its own: two exports handed over back to back, + // without waiting between them, land as two lines in export order. The + // append is asynchronous, so order is a property the chain has to keep. + it("writes two exports as two lines, in export order", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-otel-file-order-")); + const path = join(dir, "traces.jsonl"); + const { exporter, spans } = memoryExporter(); + const first = crypto.randomUUID(); + const second = crypto.randomUUID(); + exporter.end(exporter.start(toolCallEnvelope("ls", { id: 1, requestId: first }), "steps/toolCallRequest"), decisionResponse(1, { decision: "allow" })); + exporter.end(exporter.start(toolCallEnvelope("ls", { id: 2, requestId: second }), "steps/toolCallRequest"), decisionResponse(2, { decision: "allow" })); + await exporter.flush(); + const [spanA, spanB] = spans(); + try { + const file = createFileSpanExporter(path); + const results = await Promise.all([ + new Promise((resolve) => file.export([spanA!], (result) => resolve(result.code))), + new Promise((resolve) => file.export([spanB!], (result) => resolve(result.code))), + ]); + await file.shutdown(); + + expect(results).toEqual([0, 0]); + const lines = otlpFile(path).map((request) => otlpSpans([request]).map((span) => otlpAttribute(span, "acs.request_id"))); + expect(lines).toEqual([[first], [second]]); + } finally { + await exporter.shutdown(); + unlinkSync(path); + rmdirSync(dir); + } + }); + + // The file holds reasoning text and provenance source ids (#71), so it is + // created readable by its owner only, and so is the directory the + // exporter makes for it. + it("creates the file 0600 and its directory 0700", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-otel-file-mode-")); + const nested = join(dir, "nested"); + const path = join(nested, "traces.jsonl"); + const exporter = createOtelTraceExporter({ filePath: path, methodsEvaluated: METHODS_EVALUATED }); + try { + exporter.end(exporter.start(toolCallEnvelope("ls", { id: 1 }), "steps/toolCallRequest"), decisionResponse(1, { decision: "allow" })); + await exporter.shutdown(); + + expect((statSync(nested).mode & 0o777).toString(8)).toBe("700"); + expect((statSync(path).mode & 0o777).toString(8)).toBe("600"); + } finally { + unlinkSync(path); + rmdirSync(nested); + rmdirSync(dir); + } + }); + + // SIGINT followed by SIGTERM is two calls to close(). The second must + // wait for the first's flush to finish, not return at once and let the + // process exit in the middle of it. + it("makes a second shutdown() call wait for the first", async () => { + let shutDown = false; + const slow: SpanExporter = { + export(_spans, resultCallback): void { + resultCallback({ code: 0 }); + }, + async shutdown(): Promise { + await Bun.sleep(150); + shutDown = true; + }, + }; + const exporter = createOtelTraceExporter({ spanExporter: slow, methodsEvaluated: METHODS_EVALUATED }); + exporter.end(exporter.start(toolCallEnvelope("ls", { id: 1 }), "steps/toolCallRequest"), decisionResponse(1, { decision: "allow" })); + + const first = exporter.shutdown(); + const second = exporter.shutdown(); + await second; + + expect(shutDown).toBe(true); + await first; + }); + + // #227 moves acs.capability into a per-span conditional_attributes list. + // A small mapping of this test's own proves the exporter reads that list; + // the real-mapping test below proves the sources cover whatever the real + // file lists. + it("emits an attribute a step_to_span entry lists under conditional_attributes", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-otel-conditional-")); + const mappingPath = join(dir, "otel-mapping.json"); + writeFileSync( + mappingPath, + JSON.stringify({ + properties: { + step_to_span: { + default: { + "steps/toolCallRequest": { + span_name: "test.tool.call", + required_attributes: ["gen_ai.tool.name"], + conditional_attributes: ["acs.capability"], + }, + }, + }, + decision_event: { + properties: { + event_name: { const: "acs.decision" }, + required_attributes: { default: ["acs.decision"] }, + conditional_attributes: { default: [] }, + }, + }, + provenance_attributes: { properties: { required: { default: [] }, optional: { default: [] } } }, + }, + }), + ); + try { + const { exporter, spans } = memoryExporter({ mappingPath }); + exporter.end(exporter.start(toolCallEnvelope("ls", { id: 1 }), "steps/toolCallRequest"), decisionResponse(1, { decision: "allow" })); + await exporter.flush(); + + const [span] = spans(); + expect(span?.name).toBe("test.tool.call"); + expect(span?.attributes["gen_ai.tool.name"]).toBe("run_shell"); + expect(span?.attributes["acs.capability"]).toBe("process.exec"); + } finally { + unlinkSync(mappingPath); + rmdirSync(dir); + } + }); + + // Against the real mapping, not a fixture: a name the mapping lists for + // an evaluated method, in any of its three lists, must have a source here, + // or that attribute quietly stops appearing when the mapping changes -- + // which is what #227's conditional_attributes would have done. + it("has a source for every attribute the real mapping lists on an evaluated method, and for every decision attribute", () => { + for (const method of METHODS_EVALUATED) { + const entry = MAPPING.stepToSpan[method]; + if (entry === undefined) { + continue; + } + const listed = [...entry.required_attributes, ...(entry.conditional_attributes ?? []), ...(entry.optional_attributes ?? [])]; + for (const attribute of listed) { + expect({ method, attribute, known: attribute in STEP_ATTRIBUTE_SOURCES }).toEqual({ method, attribute, known: true }); + } + } + for (const attribute of MAPPING.decisionAttributes) { + expect({ attribute, known: attribute in DECISION_ATTRIBUTE_SOURCES }).toEqual({ attribute, known: true }); + } + }); + + it("maps every method the ServerHello evaluates, or names it in METHODS_WITHOUT_SPAN with a reason", () => { + for (const method of METHODS_EVALUATED) { + const mapped = MAPPING.stepToSpan[method] !== undefined; + const listed = typeof METHODS_WITHOUT_SPAN[method] === "string" && METHODS_WITHOUT_SPAN[method].length > 0; + expect({ method, mappedOrListed: mapped || listed }).toEqual({ method, mappedOrListed: true }); + } + }); + + it("NULL_OTEL_TRACE_EXPORTER writes nothing and never throws", async () => { + expect(NULL_OTEL_TRACE_EXPORTER.start(toolCallEnvelope("ls"), "steps/toolCallRequest")).toBeNull(); + expect(() => NULL_OTEL_TRACE_EXPORTER.end(null, decisionResponse(1, DENY))).not.toThrow(); + await NULL_OTEL_TRACE_EXPORTER.flush(); + await NULL_OTEL_TRACE_EXPORTER.shutdown(); + }); +}); + +describe("Guardian trace export wiring", () => { + it("emits one span per decided step, carrying the session and the decision event", async () => { + const { exporter, spans } = memoryExporter(); + const guardian = await startGuardian({ ...baseOptions, traceExporter: exporter }); + const sessionId = crypto.randomUUID(); + const requestId = crypto.randomUUID(); + try { + await postRaw(guardian.url, toolCallEnvelope("rm -rf /", { id: 11, requestId, sessionId })); + await exporter.flush(); + + const [span] = spans(); + expect(spans()).toHaveLength(1); + expect(span?.name).toBe(TOOL_CALL_SPAN.span_name); + expect(span?.attributes["acs.session.id"]).toBe(sessionId); + expect(span?.attributes["acs.request_id"]).toBe(requestId); + expect(span?.attributes["gen_ai.tool.name"]).toBe("run_shell"); + const [event] = span?.events ?? []; + expect(event?.name).toBe(MAPPING.decisionEventName); + expect(event?.attributes?.["acs.decision"]).toBe("deny"); + // This Guardian sends metadata.evaluator once #229 lands; until then + // the event carries none, rather than one the exporter made up. + expect(event?.attributes?.["acs.evaluator"]).toBeUndefined(); + expect(typeof event?.attributes?.["acs.reasoning"]).toBe("string"); + expect(Array.isArray(event?.attributes?.["acs.reason_codes"])).toBe(true); + expect(Array.isArray(event?.attributes?.["acs.policy.ids"])).toBe(true); + } finally { + await guardian.close(); + } + }); + + // Hosts run hooks in parallel and number their requests independently, so + // two sessions can be in flight with the same JSON-RPC id. Each must end + // its own span with its own decision: the deny for the destructive call, + // the allow for the benign one. + // Every span a real Guardian emits is named by the mapping: one of its + // step_to_span span_name values, never a name this module made up. + it("names every emitted span with a span_name from step_to_span", async () => { + const { exporter, spans } = memoryExporter(); + const guardian = await startGuardian({ ...baseOptions, traceExporter: exporter }); + try { + await postRaw(guardian.url, toolCallEnvelope("ls -la", { id: 1 })); + await postRaw( + guardian.url, + makeEnvelope("steps/toolCallResult", { tool: { name: "Bash" }, exit_status: "success", outputs: [{ value: "ok" }] }, { id: 2 }), + ); + await exporter.flush(); + + const names = new Set(Object.values(MAPPING.stepToSpan).map((entry) => entry.span_name)); + expect(spans()).toHaveLength(2); + for (const span of spans()) { + expect({ span: span.name, mapped: names.has(span.name) }).toEqual({ span: span.name, mapped: true }); + } + } finally { + await guardian.close(); + } + }); + + it("keeps two concurrent sessions apart when they reuse one JSON-RPC id", async () => { + const { exporter, spans } = memoryExporter(); + const guardian = await startGuardian({ ...baseOptions, traceExporter: exporter }); + const denied = { requestId: crypto.randomUUID(), sessionId: crypto.randomUUID() }; + const allowed = { requestId: crypto.randomUUID(), sessionId: crypto.randomUUID() }; + try { + await Promise.all([ + postRaw(guardian.url, toolCallEnvelope("rm -rf /", { id: 1, ...denied })), + postRaw(guardian.url, toolCallEnvelope("ls -la", { id: 1, ...allowed })), + ]); + await exporter.flush(); + + expect(spans()).toHaveLength(2); + const bySession = new Map(spans().map((span) => [span.attributes["acs.session.id"], span])); + const deniedSpan = bySession.get(denied.sessionId); + const allowedSpan = bySession.get(allowed.sessionId); + expect(deniedSpan?.attributes["acs.request_id"]).toBe(denied.requestId); + expect(allowedSpan?.attributes["acs.request_id"]).toBe(allowed.requestId); + expect(deniedSpan?.events).toHaveLength(1); + expect(allowedSpan?.events).toHaveLength(1); + expect(deniedSpan?.events[0]?.attributes?.["acs.decision"]).toBe("deny"); + expect(allowedSpan?.events[0]?.attributes?.["acs.decision"]).toBe("allow"); + } finally { + await guardian.close(); + } + }); + + // `capability` is optional on the wire (#227) and required on the span by + // the mapping. The exporter emits what the envelope supplies and invents + // nothing: no capability, no acs.capability, and the span is otherwise whole. + it("emits a span without acs.capability for a request that carries no capability, and nothing breaks", async () => { + const { exporter, spans } = memoryExporter(); + const guardian = await startGuardian({ ...baseOptions, traceExporter: exporter }); + try { + const response = await postRaw( + guardian.url, + makeEnvelope("steps/toolCallRequest", { tool: { name: "run_shell" }, arguments: { command: { value: "ls -la" } } }, { id: 1 }), + ); + await exporter.flush(); + + expect((response.result as { decision?: string }).decision).toBe("allow"); + const [span] = spans(); + expect(span?.name).toBe(TOOL_CALL_SPAN.span_name); + expect(span?.attributes["gen_ai.tool.name"]).toBe("run_shell"); + expect(span?.attributes["acs.capability"]).toBeUndefined(); + expect(span?.events[0]?.attributes?.["acs.decision"]).toBe("allow"); + } finally { + await guardian.close(); + } + }); + + it("emits no span for handshake/hello or for a method this Guardian does not dispatch", async () => { + const { exporter, spans } = memoryExporter(); + const guardian = await startGuardian({ ...baseOptions, traceExporter: exporter }); + try { + await postRaw(guardian.url, makeEnvelope("handshake/hello", {}, { id: 42 })); + await postRaw(guardian.url, makeEnvelope("steps/sessionStart", {}, { id: 43 })); + await exporter.flush(); + + expect(spans()).toHaveLength(0); + } finally { + await guardian.close(); + } + }); + + // The span opens before validation, so the envelope that fails the schema + // is visible in the trace too -- as a deny, and with no chain hash, because + // the step never reached the chain. + it("gives a schema-invalid steps/* envelope a span with a deny event and no acs.chain_hash", async () => { + const { exporter, spans } = memoryExporter(); + const guardian = await startGuardian({ ...baseOptions, traceExporter: exporter }); + try { + const bad = toolCallEnvelope("rm -rf /", { id: 12 }); + delete (bad.params as Record).acs_version; + await postRaw(guardian.url, bad); + await exporter.flush(); + + const [span] = spans(); + expect(spans()).toHaveLength(1); + const attributes = span?.events[0]?.attributes; + expect(attributes?.["acs.decision"]).toBe("deny"); + expect(attributes?.["acs.reason_codes"]).toEqual(["envelope_invalid"]); + expect(attributes?.["acs.chain_hash"]).toBeUndefined(); + } finally { + await guardian.close(); + } + }); + + it("carries the provenance a real envelope's argument attaches", async () => { + const { exporter, spans } = memoryExporter(); + const guardian = await startGuardian({ ...baseOptions, traceExporter: exporter }); + try { + const envelope = makeEnvelope( + "steps/toolCallRequest", + { + tool: { name: "run_shell" }, + arguments: { command: { value: "ls -la", provenance: provenance("user_input", { source_id: "user-12345" }) } }, + }, + { id: 31 }, + ); + const response = await postRaw(guardian.url, envelope); + await exporter.flush(); + + expect((response.result as { decision?: string }).decision).toBe("allow"); + const [span] = spans(); + expect(span?.attributes["acs.provenance.origin"]).toEqual(["user_input"]); + expect(span?.attributes["acs.provenance.source_id"]).toEqual(["user-12345"]); + } finally { + await guardian.close(); + } + }); + + // End to end: the exporter is on the decision path, so this is the test + // that says a broken exporter cannot change what the host receives. + it("answers identically with a failing exporter and with export off, and reports once", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exporter = createOtelTraceExporter({ spanExporter: throwingSpanExporter(), methodsEvaluated: METHODS_EVALUATED }); + const exporting = await startGuardian({ ...baseOptions, traceExporter: exporter }); + const silent = await startGuardian(baseOptions); + try { + const requestId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + const envelope = toolCallEnvelope("rm -rf /", { id: 5, requestId, sessionId }); + + const withExporter = await postRaw(exporting.url, envelope); + await exporter.flush(); + const withoutExporter = await postRaw(silent.url, envelope); + + expect(withExporter).toEqual(withoutExporter); + expect((withExporter.result as { decision?: string }).decision).toBe("deny"); + expect(errorSpy).toHaveBeenCalledTimes(1); + } finally { + errorSpy.mockRestore(); + await exporting.close(); + await silent.close(); + } + }); + + it("adds no failure event to a decision that arrives in time", async () => { + const memory = new InMemorySpanExporter(); + const exporter = createOtelTraceExporter({ + spanExporter: memory, + methodsEvaluated: METHODS_EVALUATED, + timeoutConfig: { default_ms: 5000 }, + posture: "fail_open", + }); + const guardian = await startGuardian({ ...baseOptions, traceExporter: exporter }); + try { + await postRaw(guardian.url, toolCallEnvelope("ls -la", { id: 1 })); + await exporter.flush(); + + const [span] = memory.getFinishedSpans(); + expect(span?.events.map((event) => event.name)).toEqual([MAPPING.decisionEventName]); + } finally { + await guardian.close(); + } + }); + + // The second failure a Guardian can see itself: a decision that finished + // after the host stopped waiting. The host applied its posture at the + // timeout, so the decision on the span probably never took effect, and a + // reader has to be told. A 50 ms timeout against a 200 ms bridge keeps + // the test fast and the ordering certain. + it("adds a timeout failure event when the decision finishes after the negotiated timeout", async () => { + const memory = new InMemorySpanExporter(); + const exporter = createOtelTraceExporter({ + spanExporter: memory, + methodsEvaluated: METHODS_EVALUATED, + timeoutConfig: { default_ms: 50 }, + posture: "fail_open", + }); + const slow: PolicyBridge = { + async evaluate() { + await Bun.sleep(200); + return { decision: "allow" }; + }, + }; + const guardian = await startGuardian({ ...baseOptions, bridge: slow, traceExporter: exporter }); + try { + await postRaw(guardian.url, toolCallEnvelope("ls -la", { id: 1 })); + await exporter.flush(); + + const [span] = memory.getFinishedSpans(); + expect(span?.events.map((event) => event.name)).toEqual([MAPPING.decisionEventName, "acs.decision.failure"]); + const failure = span?.events[1]?.attributes; + expect(failure?.["acs.failure.class"]).toBe("timeout"); + expect(failure?.["acs.failure.timeout_ms"]).toBe(50); + expect(failure?.["acs.failure.elapsed_ms"]).toBeGreaterThanOrEqual(200); + expect(failure?.["acs.failure.posture"]).toBe("fail_open"); + expect(failure?.["acs.failure.reported_by"]).toBe("guardian"); + expect(failure?.["acs.failure.payload_bytes"]).toBeGreaterThan(0); + } finally { + await guardian.close(); + } + }); + + // A bridge that never answers leaves the span open, and an open span is + // never exported: the step would leave no trace at all. Shutdown closes + // it with an `other` failure event that says why. close() drains for the + // negotiated timeout (5 s in the ServerHello) before it gives up on the + // handler, which is what bounds this test. + it("closes a step still open at shutdown with an other failure event and ERROR status", async () => { + // The file, not an in-memory exporter: close() shuts the provider down, + // and InMemorySpanExporter forgets its spans when it is shut down. + const dir = mkdtempSync(join(tmpdir(), "acs-otel-open-at-shutdown-")); + const path = join(dir, "traces.jsonl"); + const exporter = createOtelTraceExporter({ filePath: path, methodsEvaluated: METHODS_EVALUATED, posture: "fail_open" }); + let reached: () => void = () => {}; + const reachedBridge = new Promise((resolve) => { + reached = resolve; + }); + const silent: PolicyBridge = { + evaluate() { + reached(); + return new Promise(() => {}); + }, + }; + const guardian = await startGuardian({ ...baseOptions, bridge: silent, traceExporter: exporter }); + const pending = postRaw(guardian.url, toolCallEnvelope("ls -la", { id: 1 })).catch(() => undefined); + try { + await reachedBridge; + const closeStarted = performance.now(); + await guardian.close(); + const closeMs = performance.now() - closeStarted; + // The request is answered by the server going away, never by a + // decision; bounded so a connection that lingers cannot hang the test. + await Promise.race([pending, Bun.sleep(2000)]); + + const spans = otlpSpans(otlpFile(path)); + expect(spans).toHaveLength(1); + expect(spans[0]?.status?.code).toBe(2); + expect(spans[0]?.events?.map((event) => event.name)).toEqual(["acs.decision.failure"]); + const failure = spans[0]?.events?.[0]; + expect(otlpAttribute(failure, "acs.failure.class")).toBe("other"); + expect(typeof otlpAttribute(failure, "acs.failure.detail")).toBe("string"); + expect(otlpAttribute(failure, "acs.failure.reported_by")).toBe("guardian"); + expect(closeMs).toBeLessThan(10000); + } finally { + unlinkSync(path); + rmdirSync(dir); + } + }, 15_000); + + // Ariel's review on PR #286: close() applies the same bounded drain with + // export off as with it on, since the null exporter's shutdown resolves at + // once. Before, a Guardian with export off waited on stop(true) alone. On + // Bun 1.3.11 that waits for every in-flight handler, and a handler whose + // bridge never answers never returns, so close() hung. Bun 1.3.14 resolves + // stop(true) at once, and there this test passes either way. + it("closes in bounded time with export off while a step is still in flight", async () => { + let reached: () => void = () => {}; + const reachedBridge = new Promise((resolve) => { + reached = resolve; + }); + const silent: PolicyBridge = { + evaluate() { + reached(); + return new Promise(() => {}); + }, + }; + const guardian = await startGuardian({ ...baseOptions, bridge: silent }); + const pending = postRaw(guardian.url, toolCallEnvelope("ls -la", { id: 1 })).then( + () => "answered", + () => "connection closed", + ); + await reachedBridge; + const closeStarted = performance.now(); + await guardian.close(); + const closeMs = performance.now() - closeStarted; + // The request is never decided. It ends because the server went away. + expect(await Promise.race([pending, Bun.sleep(2000).then(() => "still open")])).toBe("connection closed"); + // At most the ServerHello's default timeout, 5 s: past it, no host is + // still waiting for the answer. + expect(closeMs).toBeLessThan(buildServerHello().timeout_config.default_ms + 1000); + }, 15_000); + + // server.ts builds the exporter from the ServerHello, so the posture on a + // failure event is the one this Guardian declared to the host. An envelope + // with neither an id nor a request_id earns a deny that cannot be + // addressed to it, so the Guardian answers a bare JSON-RPC error: a + // guardian_error on the span, through the file path that production uses. + it("reports the ServerHello's posture on a failure event: a Guardian declaring deny says fail_closed", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-otel-posture-")); + const path = join(dir, "traces.jsonl"); + const guardian = await startGuardian({ ...baseOptions, onDecisionFailure: "deny", otelTraceFile: path }); + try { + const response = await postRaw(guardian.url, { jsonrpc: "2.0", method: "steps/toolCallRequest", params: {} }); + expect(response.error).toBeDefined(); + } finally { + await guardian.close(); + } + try { + const [span] = otlpSpans(otlpFile(path)); + const failure = span?.events?.find((event) => event.name === "acs.decision.failure"); + expect(otlpAttribute(failure, "acs.failure.class")).toBe("guardian_error"); + expect(otlpAttribute(failure, "acs.failure.posture")).toBe("fail_closed"); + expect(otlpAttribute(failure, "acs.failure.reported_by")).toBe("guardian"); + } finally { + unlinkSync(path); + rmdirSync(dir); + } + }); + + // A collector that is down must cost the endpoint and nothing else. The + // real OTLPTraceExporter against a closed port, not the throwing stub: a + // real collector fails late, after the exporter's retry budget (a refused + // connection is retryable in this exporter), and the stub never walked + // that path. Port 1 is closed on any normal machine. + it("keeps the file running after the HTTP endpoint dies, answers as with export off, and closes in bounded time", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-otel-dead-endpoint-")); + const path = join(dir, "traces.jsonl"); + const errors: unknown[] = []; + const exporter = createOtelTraceExporter({ + filePath: path, + endpoint: "http://127.0.0.1:1/v1/traces", + methodsEvaluated: METHODS_EVALUATED, + onError: (error) => errors.push(error), + }); + const exporting = await startGuardian({ ...baseOptions, traceExporter: exporter }); + const silent = await startGuardian(baseOptions); + let exportingClosed = false; + try { + // Decisions are the same as with export off, before the endpoint dies. + for (const envelope of [toolCallEnvelope("rm -rf /", { id: 1 }), toolCallEnvelope("ls -la", { id: 2 })]) { + expect(await postRaw(exporting.url, envelope)).toEqual(await postRaw(silent.url, envelope)); + } + // flush() returns once every output has answered, and the dead + // endpoint answers only when it gives up -- so this is where its + // death is reported, and how long that took. + const flushStarted = performance.now(); + await exporter.flush(); + const flushMs = performance.now() - flushStarted; + expect(errors).toHaveLength(1); + + // And the same again, after the endpoint died. + const after = [crypto.randomUUID(), crypto.randomUUID()]; + for (const [index, requestId] of after.entries()) { + const envelope = toolCallEnvelope("ls -la", { id: 10 + index, requestId }); + expect(await postRaw(exporting.url, envelope)).toEqual(await postRaw(silent.url, envelope)); + } + const closeStarted = performance.now(); + await exporting.close(); + exportingClosed = true; + const closeMs = performance.now() - closeStarted; + + // The file holds every span, the two sent after the endpoint failed + // included -- the assertion that fails when one dead output stops the + // others. + const requestIds = otlpSpans(otlpFile(path)).map((span) => otlpAttribute(span, "acs.request_id")); + expect(requestIds).toHaveLength(4); + for (const requestId of after) { + expect(requestIds).toContain(requestId); + } + expect(errors).toHaveLength(1); + // Bounded: the endpoint gives up within its own short timeout, not the + // exporter's 10 s default, and closing never waits that budget again. + expect(flushMs).toBeLessThan(5000); + expect(closeMs).toBeLessThan(5000); + } finally { + if (!exportingClosed) { + await exporting.close(); + } + await silent.close(); + unlinkSync(path); + rmdirSync(dir); + } + }, 20_000); + + it("writes nothing when neither output is configured", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-otel-off-")); + const path = join(dir, "traces.jsonl"); + const guardian = await startGuardian(baseOptions); + try { + await postRaw(guardian.url, toolCallEnvelope("ls -la")); + expect(existsSync(path)).toBe(false); + } finally { + await guardian.close(); + rmdirSync(dir); + } + }); + + it("flushes the file on close: every line is OTLP/JSON holding the expected spans", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-otel-file-")); + const path = join(dir, "traces.jsonl"); + const guardian = await startGuardian({ ...baseOptions, otelTraceFile: path }); + try { + await postRaw(guardian.url, toolCallEnvelope("rm -rf /", { id: 21 })); + await postRaw(guardian.url, toolCallEnvelope("ls -la", { id: 22 })); + } finally { + // The spans are batched; close is what flushes them. + await guardian.close(); + } + try { + expect(existsSync(path)).toBe(true); + const requests = otlpFile(path); + expect(requests.length).toBeGreaterThan(0); + const spans = otlpSpans(requests); + expect(spans.map((s) => s.name)).toEqual([TOOL_CALL_SPAN.span_name, TOOL_CALL_SPAN.span_name]); + expect(spans.every((s) => s.events?.some((e) => e.name === MAPPING.decisionEventName))).toBe(true); + const serviceName = requests[0]?.resourceSpans[0]?.resource.attributes.find((a) => a.key === "service.name"); + expect(serviceName?.value.stringValue).toBe(OTEL_SERVICE_NAME); + } finally { + unlinkSync(path); + rmdirSync(dir); + } + }); +}); + +// Review fixes on PR #244. Each test names the finding it pins. +describe("createOtelTraceExporter -- review fixes", () => { + const SENTINEL = "SENTINEL-/Users/alice/.secrets/token"; + + // #228: acs.failure.detail MUST be text the emitter composes itself, and + // never bytes copied from a response or an error. The span status is held + // to the same rule, since it lands in the same backend. + it("copies no JSON-RPC error text into the failure event or the span status, and keeps the code", async () => { + const { exporter, spans } = memoryExporter(); + exporter.end(exporter.start(toolCallEnvelope("ls", { id: 3 }), "steps/toolCallRequest"), { + jsonrpc: "2.0", + id: 3, + error: { code: -32020, message: `evaluation failed: ${SENTINEL}` }, + }); + await exporter.flush(); + + const [span] = spans(); + expect(JSON.stringify(span?.events)).not.toContain("SENTINEL"); + expect(span?.status.message ?? "").not.toContain("SENTINEL"); + expect(span?.events[0]?.attributes?.["acs.failure.error_code"]).toBe(-32020); + expect(span?.events[0]?.attributes?.["acs.failure.detail"]).toBeUndefined(); + }); + + // #228: a Guardian MUST NOT emit the failure event for a request it rejects + // as a replay, because that pair belongs to the earlier request. + it("emits no failure event for REPLAY_DETECTED (-32005)", async () => { + const { exporter, spans } = memoryExporter(); + exporter.end(exporter.start(toolCallEnvelope("ls", { id: 4 }), "steps/toolCallRequest"), { + jsonrpc: "2.0", + id: 4, + error: { code: -32005, message: "replay" }, + }); + await exporter.flush(); + + expect(spans()[0]?.events.map((event) => event.name)).toEqual([]); + }); + + // #228: acs.provenance.source_id is emitted only when at least one attached + // Provenance object has a source_id. + it("omits acs.provenance.source_id when no Provenance object carries one", async () => { + const { exporter, spans } = memoryExporter(); + const envelope = makeEnvelope("steps/toolCallRequest", { + tool: { name: "run_shell" }, + arguments: { a: { value: "x", provenance: provenance("tool_output") }, b: { value: "y", provenance: provenance("user_input") } }, + }); + exporter.end(exporter.start(envelope, "steps/toolCallRequest"), decisionResponse(1, DENY)); + await exporter.flush(); + + const attributes = spans()[0]?.attributes; + expect(attributes?.["acs.provenance.origin"]).toEqual(["tool_output", "user_input"]); + expect(attributes?.["acs.provenance.source_id"]).toBeUndefined(); + }); + + // Unbounded attribute values let one large request grow the Guardian's + // memory without limit; a crash then fails open under the default posture. + // Driven through start() directly, because the server's body cap would + // refuse a request this large before any span existed. + it("caps attribute values at 4096 characters and provenance columns at 64 entries", async () => { + const { exporter, spans } = memoryExporter(); + const args: Record = {}; + for (let i = 0; i < 200; i += 1) { + args[`a${i}`] = { value: i, provenance: provenance("tool_output", { source_id: `s${i}` }) }; + } + const envelope = makeEnvelope( + "steps/toolCallRequest", + { tool: { name: "run_shell" }, arguments: args }, + { sessionId: "x".repeat(10_000) }, + ); + exporter.end(exporter.start(envelope, "steps/toolCallRequest"), decisionResponse(1, DENY)); + await exporter.flush(); + + expect(spans()).toHaveLength(1); + const attributes = spans()[0]?.attributes; + expect(String(attributes?.["acs.session.id"]).length).toBeLessThanOrEqual(4096); + expect((attributes?.["acs.provenance.origin"] as string[]).length).toBe(64); + expect((attributes?.["acs.provenance.source_id"] as string[]).length).toBe(64); + }); + + // A machine-wide OTEL_TRACES_SAMPLER or OTEL_SPAN_EVENT_COUNT_LIMIT must + // not silently drop the Guardian's spans or its decision events. + it("ignores sampler and event-limit environment variables", async () => { + const saved = { sampler: process.env.OTEL_TRACES_SAMPLER, events: process.env.OTEL_SPAN_EVENT_COUNT_LIMIT }; + process.env.OTEL_TRACES_SAMPLER = "always_off"; + process.env.OTEL_SPAN_EVENT_COUNT_LIMIT = "0"; + try { + const { exporter, spans } = memoryExporter(); + exporter.end(exporter.start(toolCallEnvelope("ls"), "steps/toolCallRequest"), decisionResponse(1, DENY)); + await exporter.flush(); + + expect(spans()).toHaveLength(1); + expect(spans()[0]?.events.map((event) => event.name)).toEqual([MAPPING.decisionEventName]); + } finally { + for (const [key, value] of [["OTEL_TRACES_SAMPLER", saved.sampler], ["OTEL_SPAN_EVENT_COUNT_LIMIT", saved.events]] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + + // A stalled output must not hold shutdown, and with it the process, open. + it("bounds shutdown when an output never finishes, and reports it", async () => { + const stuck: SpanExporter = { + export(): void {}, + shutdown(): Promise { + return new Promise(() => {}); + }, + }; + const errors: unknown[] = []; + const exporter = createOtelTraceExporter({ + spanExporter: stuck, + methodsEvaluated: METHODS_EVALUATED, + exportTimeoutMs: 100, + onError: (error) => errors.push(error), + }); + exporter.end(exporter.start(toolCallEnvelope("ls"), "steps/toolCallRequest"), decisionResponse(1, DENY)); + + const started = performance.now(); + await exporter.shutdown(); + expect(performance.now() - started).toBeLessThan(2000); + expect(errors.length).toBeGreaterThan(0); + }); + + // #228 moves the six decision extras into decision_event's own list. The + // exporter has to emit them under both mappings, so #244 and #228 can land + // in either order. + it("emits the decision extras whether the mapping lists them or not", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-mapping-228-")); + const path = join(dir, "otel-mapping.json"); + try { + const schema = JSON.parse( + readFileSync(new URL("../../../../../specification/v0.1.0/trace/otel-mapping.json", import.meta.url), "utf8"), + ) as { properties: { decision_event: { properties: { conditional_attributes: { default: string[] } } } } }; + const extras = ["acs.reason_codes", "acs.policy.ids", "acs.policy.versions", "acs.policy.rule_ids", "acs.cited_provenance_ids", "acs.chain_hash"]; + const listed = schema.properties.decision_event.properties.conditional_attributes; + listed.default = [...listed.default.filter((name) => !extras.includes(name)), ...extras]; + writeFileSync(path, JSON.stringify(schema)); + + for (const attribute of loadOtelMapping(path).decisionAttributes) { + expect({ attribute, known: attribute in DECISION_ATTRIBUTE_SOURCES }).toEqual({ attribute, known: true }); + } + for (const mappingPath of [undefined, path]) { + const { exporter, spans } = memoryExporter({ mappingPath }); + exporter.end( + exporter.start(toolCallEnvelope("ls"), "steps/toolCallRequest"), + decisionResponse(1, { ...DENY, reason_codes: ["blocked"], chain_hash: "a".repeat(64) }), + ); + await exporter.flush(); + const event = spans()[0]?.events.find((e) => e.name === MAPPING.decisionEventName); + expect(event?.attributes?.["acs.reason_codes"]).toEqual(["blocked"]); + expect(event?.attributes?.["acs.chain_hash"]).toBe("a".repeat(64)); + } + } finally { + unlinkSync(path); + rmdirSync(dir); + } + }); +}); + +// Issue #274: nothing in the lab's shell changes what the Guardian exports. +describe("the Guardian's exporter does not depend on its environment", () => { + const saved: Record = {}; + const remember = (key: string): void => { + if (!(key in saved)) saved[key] = process.env[key]; + }; + const setEnv = (values: Record): void => { + for (const [key, value] of Object.entries(values)) { + remember(key); + process.env[key] = value; + } + }; + const unsetEnv = (...keys: string[]): void => { + for (const key of keys) { + remember(key); + delete process.env[key]; + } + }; + /** A local stand-in for the collector that records what it receives. */ + const startCollector = () => { + const received: { headers: Headers }[] = []; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(request) { + // Copied now: Bun invalidates the Request once the handler returns, + // and a header read after that comes back empty. + received.push({ headers: new Headers(request.headers) }); + return new Response("{}", { headers: { "content-type": "application/json" } }); + }, + }); + return { server, received, url: `http://127.0.0.1:${server.port}/v1/traces` }; + }; + afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + // Bun's HTTP client latches a proxy variable when it is set and does + // not see it deleted, so every later fetch in this process would go + // to the dead proxy. Assigning an empty value first clears it. + // Seen on Bun 1.3.14 (the suite was also confirmed on 1.3.11). + process.env[key] = ""; + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + const envelope = (id: number) => ({ + jsonrpc: "2.0", + method: "steps/toolCallRequest", + id, + params: { + acs_version: "0.1.0", + request_id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + metadata: { agent_id: "a", session_id: crypto.randomUUID() }, + payload: { tool: { name: "run_shell" }, capability: "process.exec", arguments: { command: { value: "ls" } } }, + }, + }); + const allow = (id: number) => ({ jsonrpc: "2.0", id, result: { type: "final", acs_version: "0.1.0", request_id: "r", decision: "allow" } }); + + it("keeps every span when OTEL_BSP_MAX_QUEUE_SIZE is 1", async () => { + setEnv({ OTEL_BSP_MAX_QUEUE_SIZE: "1" }); + const dir = mkdtempSync(join(tmpdir(), "acs-bsp-")); + try { + const path = join(dir, "trace.jsonl"); + const exporter = createOtelTraceExporter({ filePath: path, methodsEvaluated: METHODS_EVALUATED }); + // Three spans ended in one synchronous turn: with a queue of one, the + // SDK's default-from-environment processor drops the ones that arrive + // while the first export is in flight. + for (const id of [1, 2, 3]) exporter.end(exporter.start(envelope(id), "steps/toolCallRequest"), allow(id)); + await exporter.shutdown(); + const spans = readFileSync(path, "utf8") + .split("\n") + .filter(Boolean) + .flatMap((line) => JSON.parse(line).resourceSpans[0].scopeSpans[0].spans); + expect(spans.length).toBe(3); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 10_000); + + it("keeps full ids, every attribute, and its own service.name under hostile limit and resource variables", async () => { + // A guard, not a red test: the Guardian already set its limits in code. + // It pins that the Task 1 refactor to the shared SPAN_LIMITS kept them. + setEnv({ + OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT: "8", + OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT: "1", + OTEL_SERVICE_NAME: "zzz", + OTEL_RESOURCE_ATTRIBUTES: "service.name=evil", + }); + const dir = mkdtempSync(join(tmpdir(), "acs-attr-")); + try { + const path = join(dir, "trace.jsonl"); + const exporter = createOtelTraceExporter({ filePath: path, methodsEvaluated: METHODS_EVALUATED }); + const request = envelope(1); + exporter.end(exporter.start(request, "steps/toolCallRequest"), allow(1)); + await exporter.shutdown(); + const resourceSpans = JSON.parse(readFileSync(path, "utf8").split("\n")[0]!).resourceSpans[0]; + expect(resourceSpans.resource.attributes).toEqual([{ key: "service.name", value: { stringValue: OTEL_SERVICE_NAME } }]); + const span = resourceSpans.scopeSpans[0].spans[0]; + expect(span.droppedAttributesCount ?? 0).toBe(0); + expect(span.attributes.find((a: { key: string }) => a.key === "acs.request_id").value.stringValue).toBe(request.params.request_id); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 10_000); + + it("sends uncompressed JSON even when OTEL_EXPORTER_OTLP_COMPRESSION is gzip", async () => { + setEnv({ OTEL_EXPORTER_OTLP_COMPRESSION: "gzip", OTEL_EXPORTER_OTLP_TRACES_COMPRESSION: "gzip" }); + const collector = startCollector(); + try { + const exporter = createOtelTraceExporter({ endpoint: collector.url, methodsEvaluated: METHODS_EVALUATED }); + exporter.end(exporter.start(envelope(1), "steps/toolCallRequest"), allow(1)); + await exporter.shutdown(); + expect(collector.received.map((r) => r.headers.get("content-encoding"))).toEqual([null]); + } finally { + collector.server.stop(true); + } + }, 10_000); + + it("still sends headers from OTEL_EXPORTER_OTLP_TRACES_HEADERS, which the README lists as honored", async () => { + // The SDK merges environment headers under any set in code, so they + // cannot be refused. This pins today's behavior: if an SDK update changes + // it, the README's sentence has to change with it. + setEnv({ OTEL_EXPORTER_OTLP_TRACES_HEADERS: "x-acs-probe=1" }); + const collector = startCollector(); + try { + const exporter = createOtelTraceExporter({ endpoint: collector.url, methodsEvaluated: METHODS_EVALUATED }); + exporter.end(exporter.start(envelope(1), "steps/toolCallRequest"), allow(1)); + await exporter.shutdown(); + expect(collector.received.map((r) => r.headers.get("x-acs-probe"))).toEqual(["1"]); + } finally { + collector.server.stop(true); + } + }, 10_000); + + // The exemption is main.ts's to apply (Ariel's review on PR #286), so this + // test applies it the way main.ts does, before the exporter is built. It + // runs in a fresh child for the reason the routing test further down + // gives: Bun latches proxy variables across edits within a process. In + // process, this test passed only when an earlier test had already put + // 127.0.0.1 in NO_PROXY, and failed when run alone. Gated to Bun 1.3.14 + // and later, which honor a runtime edit; 1.3.11 reads the variables only + // at startup, so main.ts's warning is what an operator gets there. + it.skipIf(!Bun.semver.satisfies(Bun.version, ">=1.3.14"))( + "sends to a loopback endpoint directly when HTTP_PROXY is set and the exemption is applied", + async () => { + // A proxy that only counts connections and never answers. + let proxyConnections = 0; + const proxy = createServer((socket) => { + proxyConnections += 1; + socket.destroy(); + }); + await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve)); + const proxyUrl = `http://127.0.0.1:${(proxy.address() as { port: number }).port}`; + const source = new URL("../src/otel-trace-exporter.ts", import.meta.url).pathname; + const script = ` + const { createOtelTraceExporter, keepLoopbackOffProxy } = await import(${JSON.stringify(source)}); + let collected = 0; + const collector = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch() { + collected += 1; + return new Response("{}", { headers: { "content-type": "application/json" } }); + }, + }); + const endpoint = "http://127.0.0.1:" + collector.port + "/v1/traces"; + keepLoopbackOffProxy(endpoint, process.env); + const exporter = createOtelTraceExporter({ endpoint, methodsEvaluated: ["steps/toolCallRequest"], onError: () => {} }); + exporter.end(exporter.start(${JSON.stringify(envelope(1))}, "steps/toolCallRequest"), ${JSON.stringify(allow(1))}); + await exporter.shutdown(); + console.log(JSON.stringify({ collected })); + collector.stop(true); + process.exit(0); + `; + try { + // Async spawn, so the proxy listener on this event loop can count + // the child's connections. + const child = Bun.spawn([process.execPath, "-e", script], { + env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", HTTP_PROXY: proxyUrl, http_proxy: proxyUrl }, + stdout: "pipe", + }); + const output = await new Response(child.stdout).text(); + await child.exited; + const result = JSON.parse(output.trim().split("\n").pop()!); + expect({ proxyConnections, collected: result.collected }).toEqual({ proxyConnections: 0, collected: 1 }); + } finally { + proxy.close(); + } + }, + 10_000, + ); + + // Ariel's review on PR #286: the composition root owns the environment. + // main.ts applies the loopback exemption, so an embedder that builds the + // exporter itself decides whether its own environment changes. + it("leaves NO_PROXY and no_proxy as they were when built for a loopback endpoint", async () => { + unsetEnv("NO_PROXY", "no_proxy"); + const unset = createOtelTraceExporter({ endpoint: "http://127.0.0.1:4318/v1/traces", methodsEvaluated: METHODS_EVALUATED, onError: () => {} }); + await unset.shutdown(); + expect({ NO_PROXY: "NO_PROXY" in process.env, no_proxy: "no_proxy" in process.env }).toEqual({ NO_PROXY: false, no_proxy: false }); + + setEnv({ NO_PROXY: "corp.internal", no_proxy: "corp.internal" }); + const set = createOtelTraceExporter({ endpoint: "http://127.0.0.1:4318/v1/traces", methodsEvaluated: METHODS_EVALUATED, onError: () => {} }); + await set.shutdown(); + expect([process.env.NO_PROXY, process.env.no_proxy]).toEqual(["corp.internal", "corp.internal"]); + }); +}); + +describe("keepLoopbackOffProxy", () => { + const saved: Record = { + NO_PROXY: process.env.NO_PROXY, + no_proxy: process.env.no_proxy, + }; + const reset = (value: string | undefined): void => { + for (const key of ["NO_PROXY", "no_proxy"]) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; + afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it("adds localhost for a localhost endpoint", () => { + reset(undefined); + keepLoopbackOffProxy("http://localhost:4318/v1/traces", process.env); + expect([process.env.NO_PROXY, process.env.no_proxy]).toEqual(["localhost", "localhost"]); + }); + + it("adds ::1 both bare and bracketed for an IPv6 loopback endpoint, because Bun matches only [::1]", () => { + reset(undefined); + keepLoopbackOffProxy("http://[::1]:4318/v1/traces", process.env); + expect([process.env.NO_PROXY, process.env.no_proxy]).toEqual(["::1,[::1]", "::1,[::1]"]); + }); + + // Bar's finding on issue #274: Bun 1.3.14 reads a non-empty no_proxy in + // preference to NO_PROXY, so creating the one the user did not set switches + // off every exemption in the one they did. + it("appends to NO_PROXY alone when only NO_PROXY is set, and does not create no_proxy", () => { + reset(undefined); + process.env.NO_PROXY = "corp.internal"; + keepLoopbackOffProxy("http://127.0.0.1:4318/v1/traces", process.env); + expect(process.env.NO_PROXY).toBe("corp.internal,127.0.0.1"); + expect("no_proxy" in process.env).toBe(false); + }); + + it("appends to no_proxy alone when only no_proxy is set, and does not create NO_PROXY", () => { + reset(undefined); + process.env.no_proxy = "corp.internal"; + keepLoopbackOffProxy("http://127.0.0.1:4318/v1/traces", process.env); + expect(process.env.no_proxy).toBe("corp.internal,127.0.0.1"); + expect("NO_PROXY" in process.env).toBe(false); + }); + + it("treats an empty no_proxy as unset, since Bun then falls back to NO_PROXY", () => { + reset(undefined); + process.env.NO_PROXY = "corp.internal"; + process.env.no_proxy = ""; + keepLoopbackOffProxy("http://127.0.0.1:4318/v1/traces", process.env); + expect([process.env.NO_PROXY, process.env.no_proxy]).toEqual(["corp.internal,127.0.0.1", ""]); + }); + + it("fills a no_proxy that holds no entries, because Bun reads any non-empty value as the list", () => { + for (const blank of [",", " ", " , "]) { + reset(undefined); + process.env.NO_PROXY = "corp.internal"; + process.env.no_proxy = blank; + keepLoopbackOffProxy("http://127.0.0.1:4318/v1/traces", process.env); + expect({ blank, NO_PROXY: process.env.NO_PROXY, no_proxy: process.env.no_proxy }).toEqual({ + blank, + NO_PROXY: "corp.internal,127.0.0.1", + no_proxy: "127.0.0.1", + }); + } + }); + + it("sets both when neither is set, and appends once to each when both are set", () => { + reset(undefined); + keepLoopbackOffProxy("http://127.0.0.1:4318/v1/traces", process.env); + expect([process.env.NO_PROXY, process.env.no_proxy]).toEqual(["127.0.0.1", "127.0.0.1"]); + process.env.NO_PROXY = "a.internal"; + process.env.no_proxy = "b.internal"; + keepLoopbackOffProxy("http://127.0.0.1:4318/v1/traces", process.env); + keepLoopbackOffProxy("http://127.0.0.1:4318/v1/traces", process.env); + expect([process.env.NO_PROXY, process.env.no_proxy]).toEqual(["a.internal,127.0.0.1", "b.internal,127.0.0.1"]); + }); + + it("does not duplicate the entry when called twice", () => { + reset(undefined); + keepLoopbackOffProxy("http://127.0.0.1:4318/v1/traces", process.env); + keepLoopbackOffProxy("http://127.0.0.1:4318/v1/traces", process.env); + expect([process.env.NO_PROXY, process.env.no_proxy]).toEqual(["127.0.0.1", "127.0.0.1"]); + }); + + it("preserves existing entries", () => { + reset("example.internal"); + keepLoopbackOffProxy("http://localhost:4318/v1/traces", process.env); + expect([process.env.NO_PROXY, process.env.no_proxy]).toEqual(["example.internal,localhost", "example.internal,localhost"]); + }); + + it("leaves both variables exactly as they were for a non-loopback endpoint", () => { + reset(undefined); + keepLoopbackOffProxy("http://collector.example.com:4318/v1/traces", process.env); + expect("NO_PROXY" in process.env).toBe(false); + expect("no_proxy" in process.env).toBe(false); + reset("example.internal"); + keepLoopbackOffProxy("http://collector.example.com:4318/v1/traces", process.env); + expect([process.env.NO_PROXY, process.env.no_proxy]).toEqual(["example.internal", "example.internal"]); + }); +}); + +// Bun 1.3.11 reads the proxy variables only when the process starts, so the +// runtime edit above cannot help there. The warning is what every version +// gets: it judges the variables as they stand when the exporter is built. +describe("loopbackProxyWarning", () => { + const endpoint = "http://127.0.0.1:4318/v1/traces"; + + it("warns when a proxy is set and the list Bun reads does not exempt the host", () => { + const warning = loopbackProxyWarning(endpoint, { HTTP_PROXY: "http://proxy:3128", NO_PROXY: "corp.internal" }); + expect(warning).toContain("127.0.0.1"); + expect(warning).toContain("NO_PROXY"); + }); + + it("judges by no_proxy when it is non-empty, the list Bun reads first", () => { + expect(loopbackProxyWarning(endpoint, { http_proxy: "http://proxy:3128", NO_PROXY: "127.0.0.1", no_proxy: "corp.internal" })).toBeDefined(); + expect(loopbackProxyWarning(endpoint, { http_proxy: "http://proxy:3128", NO_PROXY: "corp.internal", no_proxy: "127.0.0.1" })).toBeUndefined(); + expect(loopbackProxyWarning(endpoint, { http_proxy: "http://proxy:3128", NO_PROXY: "127.0.0.1", no_proxy: "" })).toBeUndefined(); + }); + + it("stays quiet with no proxy, with a wildcard exemption, or for a non-loopback endpoint", () => { + expect(loopbackProxyWarning(endpoint, {})).toBeUndefined(); + expect(loopbackProxyWarning(endpoint, { HTTP_PROXY: "http://proxy:3128", NO_PROXY: "*" })).toBeUndefined(); + expect(loopbackProxyWarning("http://collector.example.com:4318/v1/traces", { HTTP_PROXY: "http://proxy:3128" })).toBeUndefined(); + }); + + it("wants the bracketed form for IPv6, the only one Bun matches", () => { + const v6 = "http://[::1]:4318/v1/traces"; + expect(loopbackProxyWarning(v6, { HTTP_PROXY: "http://proxy:3128", NO_PROXY: "::1" })).toBeDefined(); + expect(loopbackProxyWarning(v6, { HTTP_PROXY: "http://proxy:3128", NO_PROXY: "[::1]" })).toBeUndefined(); + }); +}); + +// Routing, not strings. It runs in a fresh child with its environment set at +// spawn, because Bun latches proxy variables across edits within a process, +// which makes any in-process routing check pass for the wrong reason. Bun +// 1.3.11 ignores runtime edits altogether, so this is gated to 1.3.14 and +// later, and CI on 1.3.11 skips it. +describe("keepLoopbackOffProxy, routed through a real proxy in a child process", () => { + const runtimeEditsHonored = Bun.semver.satisfies(Bun.version, ">=1.3.14"); + + it.skipIf(!runtimeEditsHonored)("keeps loopback off the proxy without switching off the user's existing NO_PROXY entries", async () => { + let proxyConnections = 0; + const proxy = createServer((socket) => { + proxyConnections += 1; + socket.destroy(); + }); + await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve)); + const proxyUrl = `http://127.0.0.1:${(proxy.address() as { port: number }).port}`; + const source = new URL("../src/otel-trace-exporter.ts", import.meta.url).pathname; + const script = ` + const { keepLoopbackOffProxy } = await import(${JSON.stringify(source)}); + const target = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("ok") }); + const go = async (host) => { + try { + const response = await fetch("http://" + host + ":" + target.port + "/", { signal: AbortSignal.timeout(1500) }); + return (await response.text()) === "ok" ? "direct" : "other"; + } catch { + return "proxied"; + } + }; + const guard = await go("127.0.0.1"); + keepLoopbackOffProxy("http://127.0.0.1:" + target.port + "/v1/traces", process.env); + const loopback = await go("127.0.0.1"); + const existing = await go("localhost"); + console.log(JSON.stringify({ guard, loopback, existing })); + target.stop(true); + process.exit(0); + `; + try { + // localhost stands in for a corporate host the user already exempted. + // Async spawn, not spawnSync: the proxy listener runs on this process's + // event loop and could not count the child's connection while blocked. + const child = Bun.spawn([process.execPath, "-e", script], { + env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", HTTP_PROXY: proxyUrl, http_proxy: proxyUrl, NO_PROXY: "localhost" }, + stdout: "pipe", + }); + const output = await new Response(child.stdout).text(); + await child.exited; + const result = JSON.parse(output.trim().split("\n").pop()!); + // The guard proves the proxy was in the path before the call. + expect(result).toEqual({ guard: "proxied", loopback: "direct", existing: "direct" }); + expect(proxyConnections).toBe(1); + } finally { + proxy.close(); + } + }, 15_000); +}); diff --git a/reference-implementations/agt/packages/host-adapter/test/build-envelope.test.ts b/reference-implementations/agt/packages/host-adapter/test/build-envelope.test.ts index c85ad649..de58ac5c 100644 --- a/reference-implementations/agt/packages/host-adapter/test/build-envelope.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/build-envelope.test.ts @@ -151,6 +151,15 @@ describe("buildEnvelope", () => { expect(envelope.params.metadata.session_id).toBe(realUuid); }); + // The audit log records the envelope's JSON-RPC id as `rpc_id`, and the + // trace converter (guardian/src/audit-to-otlp.ts) carries it as + // acs.request_id to join the host's failure spans to the Guardian's. That + // join holds only while the two ids are the same value. + it("uses the request_id as the JSON-RPC id", () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + expect(envelope.id).toBe(envelope.params.request_id); + }); + it("toSessionUuid is deterministic and produces a valid uuid for a non-uuid input", () => { expect(toSessionUuid("abc123")).toMatch(UUID_RE); expect(toSessionUuid("abc123")).toBe(toSessionUuid("abc123")); diff --git a/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl b/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl new file mode 100644 index 00000000..437a8f22 --- /dev/null +++ b/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl @@ -0,0 +1,2 @@ +{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"acs-reference-guardian"}}],"droppedAttributesCount":0},"scopeSpans":[{"scope":{"name":"acs-reference-guardian"},"spans":[{"traceId":"9785c34288d27dbd92fcf12e53ac3315","spanId":"31258fb4b343b292","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792065946937000000","endTimeUnixNano":"1792065947036723875","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.capability","value":{"stringValue":"process.exec"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}}],"name":"acs.decision","timeUnixNano":"1792065947036676541","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"112037a108c3893225309fa193536b77","spanId":"46883c95863bb8fb","name":"gen_ai.tool.result","kind":1,"startTimeUnixNano":"1792065947038000000","endTimeUnixNano":"1792065947088187667","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b02"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.exit_status","value":{"stringValue":"success"}},{"key":"acs.request_id_ref","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}}],"name":"acs.decision","timeUnixNano":"1792065947088182292","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"981a929f7cea4c999642a38dd3d822b1","spanId":"3b199edb6364b94d","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792065947089000000","endTimeUnixNano":"1792065947141424750","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b03"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.provenance.origin","value":{"arrayValue":{"values":[{"stringValue":"user_input"},{"stringValue":"agent_generated"}]}}},{"key":"acs.provenance.source_id","value":{"arrayValue":{"values":[{"stringValue":"sample-user-1"},{"stringValue":""}]}}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"deny"}},{"key":"acs.reasoning","value":{"stringValue":"This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0."}},{"key":"acs.reason_codes","value":{"arrayValue":{"values":[{"stringValue":"destructive_shell_command_blocked"}]}}},{"key":"acs.policy.ids","value":{"arrayValue":{"values":[{"stringValue":"agt_stock"}]}}},{"key":"acs.policy.versions","value":{"arrayValue":{"values":[{"stringValue":""}]}}},{"key":"acs.policy.rule_ids","value":{"arrayValue":{"values":[{"stringValue":"destructive_shell_command_blocked"}]}}}],"name":"acs.decision","timeUnixNano":"1792065947141419958","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"95c92494c004b23662900ae8c17ea04c","spanId":"02d46f81d8206a0f","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792065947141000000","endTimeUnixNano":"1792065947141270000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b04"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.failure.class","value":{"stringValue":"guardian_error"}},{"key":"acs.failure.posture","value":{"stringValue":"fail_open"}},{"key":"acs.failure.reported_by","value":{"stringValue":"guardian"}},{"key":"acs.failure.error_code","value":{"intValue":-32010}},{"key":"acs.failure.elapsed_ms","value":{"intValue":0}},{"key":"acs.failure.payload_bytes","value":{"intValue":340}}],"name":"acs.decision.failure","timeUnixNano":"1792065947141268125","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":2,"message":"The Guardian returned an error instead of a decision."},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"e5703a6e87d75324de28fd7cc555724b","spanId":"e813670f52f6a811","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792065947142000000","endTimeUnixNano":"1792065949203024291","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b05"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}}],"name":"acs.decision","timeUnixNano":"1792065949202988875","droppedAttributesCount":0},{"attributes":[{"key":"acs.failure.class","value":{"stringValue":"timeout"}},{"key":"acs.failure.posture","value":{"stringValue":"fail_open"}},{"key":"acs.failure.reported_by","value":{"stringValue":"guardian"}},{"key":"acs.failure.timeout_ms","value":{"intValue":1500}},{"key":"acs.failure.elapsed_ms","value":{"intValue":2061}},{"key":"acs.failure.payload_bytes","value":{"intValue":367}}],"name":"acs.decision.failure","timeUnixNano":"1792065949203022666","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257}]}]}]} +{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"acs-reference-audit-converter"}}],"droppedAttributesCount":0},"scopeSpans":[{"scope":{"name":"acs-reference-audit-converter"},"spans":[{"traceId":"9b082079ac3695104b2da1df27340e16","spanId":"99e8d283763d16d3","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792065949205000000","endTimeUnixNano":"1792065949205000000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b06"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.failure.class","value":{"stringValue":"unreachable"}},{"key":"acs.failure.posture","value":{"stringValue":"fail_open"}},{"key":"acs.failure.reported_by","value":{"stringValue":"observed_agent"}},{"key":"acs.failure.detail","value":{"stringValue":"The host could not reach the Guardian."}}],"name":"acs.decision.failure","timeUnixNano":"1792065949205000000","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":2,"message":"The host could not reach the Guardian."},"links":[],"droppedLinksCount":0,"flags":257}]}]}]} diff --git a/reference-implementations/agt/scripts/make-sample-trace.ts b/reference-implementations/agt/scripts/make-sample-trace.ts new file mode 100644 index 00000000..de4d5e4a --- /dev/null +++ b/reference-implementations/agt/scripts/make-sample-trace.ts @@ -0,0 +1,190 @@ +/** + * Writes the committed sample trace (issue #266): what the reference + * Guardian's OTLP export actually looks like, from a fixed set of synthetic + * requests through the two live hook methods, plus one host-side failure run + * through the audit converter. + * + * bun run sample:trace + * + * Decisions come from the real AGT policy in policy/manifest.yaml. The one + * change is a delay on the request carrying SLOW_MARKER, so that decision + * finishes after the short timeout this script negotiates and the sample + * shows a `timeout` failure. Every id is fixed and every value synthetic: + * the sample is committed, and `acs.reasoning` and `acs.provenance.source_id` + * go out as written. + */ +import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { convertAuditLog } from "../packages/guardian/src/audit-to-otlp.ts"; +import { createDeploymentBridge } from "../packages/guardian/src/deployment-bridge.ts"; +import { buildServerHello } from "../packages/guardian/src/handshake.ts"; +import { startGuardian } from "../packages/guardian/src/index.ts"; +import { createOtelTraceExporter } from "../packages/guardian/src/otel-trace-exporter.ts"; + +export const SAMPLE_PATH = fileURLToPath(new URL("../samples/trace/acs-trace-sample.jsonl", import.meta.url)); +const MANIFEST_PATH = fileURLToPath(new URL("../policy/manifest.yaml", import.meta.url)); + +/** How long the host waits for a decision in this sample, and how long the + * marked request makes the Guardian take. Far enough apart that the stock + * policy's own evaluation time never crosses the timeout. */ +const TIMEOUT_MS = 1500; +const SLOW_MS = 2000; +const SLOW_MARKER = "sample-slow-marker"; + +const SESSION_ID = "5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"; + +export type Scenario = { + name: string; + requestId: string; + /** The `capability` the request carried, if any. */ + capability?: string; + /** The `request_id_ref` a result carried, if any. */ + requestIdRef?: string; + /** The decision the Guardian answered with, or none when it answered an + * error or was never reached. */ + decision?: "allow" | "deny"; +}; + +/** Every step the sample holds, keyed by the request id its span carries. */ +export const SCENARIOS: readonly Scenario[] = [ + { name: "allowed", requestId: "1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01", capability: "process.exec", decision: "allow" }, + { name: "result", requestId: "1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b02", requestIdRef: "1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01", decision: "allow" }, + { name: "denied", requestId: "1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b03", decision: "deny" }, + { name: "error", requestId: "1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b04" }, + { name: "late", requestId: "1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b05", decision: "allow" }, + { name: "unreachable", requestId: "1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b06" }, +]; + +function requestIdOf(name: string): string { + return SCENARIOS.find((scenario) => scenario.name === name)!.requestId; +} + +function envelope(method: string, requestId: string, payload: Record, id: number | null): Record { + return { + jsonrpc: "2.0", + method, + ...(id === null ? {} : { id }), + params: { + acs_version: "0.1.0", + request_id: requestId, + timestamp: new Date().toISOString(), + metadata: { agent_id: "sample-agent", session_id: SESSION_ID }, + payload, + }, + }; +} + +function bash(command: string, extra: Record = {}): Record { + return { tool: { name: "Bash" }, arguments: { command: { value: command } }, ...extra }; +} + +/** The envelopes the Guardian receives, in order. */ +function requests(): Record[] { + return [ + envelope("steps/toolCallRequest", requestIdOf("allowed"), bash("ls -la", { capability: "process.exec" }), 1), + envelope( + "steps/toolCallResult", + requestIdOf("result"), + { + tool: { name: "Bash" }, + exit_status: "success", + outputs: [{ value: "README.md\nsrc" }], + request_id_ref: requestIdOf("allowed"), + }, + 2, + ), + // Two Provenance objects, one with a source_id and one without, so the + // source_id column shows its "" padding. + envelope( + "steps/toolCallRequest", + requestIdOf("denied"), + { + tool: { name: "Bash" }, + arguments: { + command: { + // Matches the stock policy's destructive-command rule without + // naming a real path. + value: "rm -rf .", + provenance: { provenance_id: "prov-sample-1", origin: "user_input", source_id: "sample-user-1" }, + }, + note: { value: "cleanup", provenance: { provenance_id: "prov-sample-2", origin: "agent_generated" } }, + }, + }, + 3, + ), + // No JSON-RPC id: the Guardian answers with an error, not a decision. + envelope("steps/toolCallRequest", requestIdOf("error"), bash("pwd"), null), + envelope("steps/toolCallRequest", requestIdOf("late"), bash(`echo ${SLOW_MARKER}`), 5), + ]; +} + +/** The host's record of a step that never reached the Guardian, in the + * audit-log format packages/host-adapter/src/audit-sink.ts writes. */ +function unreachableAuditLine(): string { + return `${JSON.stringify({ + seq: 1, + recorded_at: new Date().toISOString(), + session_id: SESSION_ID, + method: "steps/toolCallRequest", + rpc_id: requestIdOf("unreachable"), + outcome: "proceeded", + posture: "proceed", + posture_source: "negotiated", + failure: { kind: "transport", message: "connect ECONNREFUSED 127.0.0.1:8787" }, + })}\n`; +} + +export async function generateSampleTrace(outPath: string = SAMPLE_PATH): Promise { + const dir = mkdtempSync(join(tmpdir(), "acs-make-sample-trace-")); + try { + const guardianTrace = join(dir, "guardian.jsonl"); + const auditPath = join(dir, "audit.jsonl"); + const converterTrace = join(dir, "converter.jsonl"); + + // The bridge the Guardian itself builds, built-in annotator included: the + // stock manifest declares an egress annotator, and a bridge without it + // fails every step closed, which is not what a sample should show. + const real = createDeploymentBridge(MANIFEST_PATH); + const bridge = { + async evaluate(point: string, snapshot: Record) { + if (JSON.stringify(snapshot).includes(SLOW_MARKER)) { + await Bun.sleep(SLOW_MS); + } + return real.evaluate(point, snapshot); + }, + }; + const traceExporter = createOtelTraceExporter({ + filePath: guardianTrace, + methodsEvaluated: buildServerHello().methods_evaluated, + timeoutConfig: { default_ms: TIMEOUT_MS }, + posture: "fail_open", + }); + const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST_PATH, bridge, traceExporter }); + try { + for (const request of requests()) { + await fetch(guardian.url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(request) }); + } + } finally { + // Closing the Guardian shuts the exporter down, which flushes the file. + await guardian.close(); + } + + writeFileSync(auditPath, unreachableAuditLine()); + const summary = await convertAuditLog(auditPath, converterTrace); + if (summary.written !== 1) { + throw new Error(`the audit converter wrote ${summary.written} span(s), expected 1`); + } + + writeFileSync(outPath, readFileSync(guardianTrace, "utf8")); + appendFileSync(outPath, readFileSync(converterTrace, "utf8")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +if (import.meta.main) { + await generateSampleTrace(); + console.log(`wrote ${SAMPLE_PATH}`); +} diff --git a/reference-implementations/agt/test/audit-to-otlp.test.ts b/reference-implementations/agt/test/audit-to-otlp.test.ts new file mode 100644 index 00000000..5d8896db --- /dev/null +++ b/reference-implementations/agt/test/audit-to-otlp.test.ts @@ -0,0 +1,488 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { appendFileSync, existsSync, linkSync, mkdtempSync, readFileSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { auditUngovernedStep, createAuditSink, createGuardianClient, governStep, toSessionUuid, type Hookmap } from "host-adapter"; +import { + CONVERTER_SERVICE_NAME, + convertAuditLog, + FAILURE_CLASS_BY_KIND, + FAILURE_DETAIL_BY_KIND, + failureClassOf, + sessionUuid as converterSessionUuid, +} from "../packages/guardian/src/audit-to-otlp.ts"; +import { loadOtelMapping, OTEL_SERVICE_NAME, SPAN_LIMITS } from "../packages/guardian/src/otel-trace-exporter.ts"; + +/** + * The converter's contract, measured with the Guardian down. The host + * adapter records the failures only it can see (#37: a Guardian it could + * not reach, one that answered too late) in its own audit log, and the + * converter turns that log into the same OTLP/JSON file the Guardian's + * exporter writes. Here, because it uses two packages: the adapter writes + * the log for real, against a port nothing listens on, and the converter + * in the guardian package reads it back. + * + * The same deliberately synthetic hookmap packages/host-adapter/test/govern-step.test.ts + * uses, naming no real host's fields. + */ +const ON_STEP = { + acs_method: "steps/toolCallRequest", + tool_name: "$.tool_name", + arguments: "$.tool_input", + decisions: { + allow: { output: { outcome: { value: "go" }, note: { from: "reasoning", type: "string" } } }, + deny: { output: { outcome: { value: "stop" }, note: { from: "reasoning", type: "string" } } }, + }, +} as const; + +const hookmap: Hookmap = { + host: "test-host", + hooks: { + OnStep: ON_STEP, + // The same gate scoped to one tool, so a step on any other tool is one + // the gate declines to ask about -- an `ungoverned` entry. + OnScopedStep: { ...ON_STEP, tools: ["Bash"] }, + }, +}; + +const payload = { session_id: "sess-1", tool_name: "Bash", tool_input: { command: "ls -la" } }; + +type OtlpAttribute = { key: string; value: { stringValue?: string } }; +type OtlpEvent = { name: string; attributes?: OtlpAttribute[] }; +type OtlpSpan = { name: string; attributes?: OtlpAttribute[]; events?: OtlpEvent[] }; +type OtlpRequest = { resourceSpans: { scopeSpans: { spans: OtlpSpan[] }[] }[] }; + +function spansIn(path: string): OtlpSpan[] { + if (!existsSync(path)) { + return []; + } + return readFileSync(path, "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .flatMap((line) => (JSON.parse(line) as OtlpRequest).resourceSpans.flatMap((rs) => rs.scopeSpans.flatMap((ss) => ss.spans))); +} + +function attribute(carrier: OtlpSpan | OtlpEvent | undefined, key: string): string | undefined { + return carrier?.attributes?.find((a) => a.key === key)?.value.stringValue; +} + +type AuditLine = { session_id: string; method: string | null; outcome: string; failure?: { kind: string; message: string } }; + +function auditLines(path: string): AuditLine[] { + return readFileSync(path, "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line) as AuditLine); +} + +/** Non-recursive cleanup: the files a test can create, by name, then the directory. */ +function withScratch(run: (auditPath: string, otelPath: string) => Promise): Promise { + const dir = mkdtempSync(join(tmpdir(), "acs-audit-to-otlp-")); + const auditPath = join(dir, "audit.jsonl"); + const otelPath = join(dir, "otel.jsonl"); + return run(auditPath, otelPath).finally(() => { + for (const path of [auditPath, otelPath]) { + if (existsSync(path)) { + unlinkSync(path); + } + } + rmdirSync(dir); + }); +} + +describe("audit-to-otlp: the host's decision failures become acs.decision.failure spans", () => { + // Port 1 is closed on any normal machine, so the request never reaches a + // Guardian and the adapter records a `transport` failure resolved by its + // `proceed` posture. That one entry is the converter's whole input here. + it("turns the transport entry a dead Guardian leaves into one unreachable span, reported by the observed agent, fail_open", async () => { + await withScratch(async (auditPath, otelPath) => { + const audit = createAuditSink({ path: auditPath }); + await governStep({ + hookEventName: "OnStep", + payload, + hookmap, + guardian: createGuardianClient("http://127.0.0.1:1/acs"), + session: { config: { timeout_config: { default_ms: 1000 }, on_decision_failure: "proceed" }, failure: undefined }, + sessionId: "sess-1", + audit, + }); + const [entry] = auditLines(auditPath); + expect(entry?.outcome).toBe("proceeded"); + expect(entry?.failure?.kind).toBe("transport"); + + const summary = await convertAuditLog(auditPath, otelPath); + + expect(summary).toEqual({ + converted: 1, + written: 1, + skippedUnmapped: 0, + skippedNoMethod: {}, + skippedReplay: 0, + skippedUngoverned: 0, + skippedUnreadable: 0, + }); + const spans = spansIn(otelPath); + expect(spans).toHaveLength(1); + expect(spans[0]?.name).toBe(loadOtelMapping().stepToSpan["steps/toolCallRequest"]!.span_name); + expect(attribute(spans[0], "acs.session.id")).toBe(toSessionUuid(entry!.session_id)); + const failure = spans[0]?.events?.find((event) => event.name === "acs.decision.failure"); + expect(attribute(failure, "acs.failure.class")).toBe("unreachable"); + expect(attribute(failure, "acs.failure.reported_by")).toBe("observed_agent"); + expect(attribute(failure, "acs.failure.posture")).toBe("fail_open"); + expect(attribute(failure, "acs.failure.detail")).toBe(FAILURE_DETAIL_BY_KIND.transport); + }); + }); + + it("skips ungoverned entries, and counts unmapped methods apart from entries with no method", async () => { + await withScratch(async (auditPath, otelPath) => { + const audit = createAuditSink({ path: auditPath }); + // A step the scoped gate declined to ask about: not a decision failure, + // so none of #37's classes describes it. + auditUngovernedStep({ hookmap, hookEventName: "OnScopedStep", tool: "WebFetch", sessionId: "sess-1", audit }); + // Two posture entries the mapping has no span for: a method with no + // step_to_span entry, and an entry with no method at all. + const recorded_at = new Date().toISOString(); + appendFileSync( + auditPath, + `${JSON.stringify({ seq: 2, recorded_at, session_id: "sess-1", method: "protocols/MCP/tools/call", rpc_id: 2, outcome: "proceeded", posture: "proceed", posture_source: "negotiated", failure: { kind: "timeout", message: "no decision within 1000ms" } })}\n`, + ); + appendFileSync( + auditPath, + `${JSON.stringify({ seq: 3, recorded_at, session_id: "sess-1", method: null, rpc_id: null, outcome: "blocked", posture: "deny", posture_source: "default", failure: { kind: "host_configuration", message: "no hookmap entry for this hook" } })}\n`, + ); + expect(auditLines(auditPath).map((line) => line.outcome)).toEqual(["ungoverned", "proceeded", "blocked"]); + + const summary = await convertAuditLog(auditPath, otelPath); + + expect(summary).toEqual({ + converted: 0, + written: 0, + skippedUnmapped: 1, + skippedNoMethod: { host_configuration: 1 }, + skippedReplay: 0, + skippedUngoverned: 1, + skippedUnreadable: 0, + }); + expect(spansIn(otelPath)).toHaveLength(0); + }); + }); + + it("maps every host failure kind to a class from #37, and anything else to other", () => { + expect(FAILURE_CLASS_BY_KIND).toEqual({ + timeout: "timeout", + transport: "unreachable", + error_without_decision: "guardian_error", + refused: "guardian_error", + host_configuration: "observed_agent_error", + }); + // A decision did arrive, so observed_agent_error does not fit (#244 review). + expect(failureClassOf("decision_unrenderable")).toBe("other"); + expect(failureClassOf("unknown")).toBe("other"); + expect(failureClassOf(undefined)).toBe("other"); + }); +}); + +// Review fixes on PR #244. Each test names the finding it pins. +describe("audit-to-otlp: review fixes", () => { + const SENTINEL = "SENTINEL-ignore-previous-instructions"; + + function entryLine(fields: Record): string { + return `${JSON.stringify({ + seq: 1, + recorded_at: new Date().toISOString(), + session_id: "sess-1", + method: "steps/toolCallRequest", + rpc_id: "3f6c1e2a-8b4d-4c1e-9a7f-2d5b6e8c9a01", + outcome: "proceeded", + posture: "proceed", + posture_source: "negotiated", + failure: { kind: "transport", message: "connect ECONNREFUSED 127.0.0.1:1" }, + ...fields, + })}\n`; + } + + function numberAttribute(carrier: OtlpEvent | undefined, key: string): number | undefined { + const value = (carrier?.attributes?.find((a) => a.key === key)?.value as { intValue?: string | number } | undefined)?.intValue; + return value === undefined ? undefined : Number(value); + } + + // #228: posture is the deployment's, and detail is text the emitter writes. + it("reports a refusal under a proceed posture as guardian_error and fail_open, keeps the code, and copies no message text", async () => { + await withScratch(async (auditPath, otelPath) => { + appendFileSync( + auditPath, + entryLine({ outcome: "blocked", failure: { kind: "refused", message: `guardian refused the envelope with error -32003: ${SENTINEL}` } }), + ); + + await convertAuditLog(auditPath, otelPath); + + const raw = readFileSync(otelPath, "utf8"); + expect(raw).not.toContain("SENTINEL"); + const failure = spansIn(otelPath)[0]?.events?.find((event) => event.name === "acs.decision.failure"); + expect(attribute(failure, "acs.failure.class")).toBe("guardian_error"); + expect(attribute(failure, "acs.failure.posture")).toBe("fail_open"); + expect(numberAttribute(failure, "acs.failure.error_code")).toBe(-32003); + expect(attribute(failure, "acs.failure.detail")).toBe(FAILURE_DETAIL_BY_KIND.refused); + }); + }); + + it("files decision_unrenderable under other, with a detail", async () => { + await withScratch(async (auditPath, otelPath) => { + appendFileSync(auditPath, entryLine({ failure: { kind: "decision_unrenderable", message: `no hookmap entry: ${SENTINEL}` } })); + + await convertAuditLog(auditPath, otelPath); + + const failure = spansIn(otelPath)[0]?.events?.find((event) => event.name === "acs.decision.failure"); + expect(attribute(failure, "acs.failure.class")).toBe("other"); + expect(attribute(failure, "acs.failure.detail")).toBe(FAILURE_DETAIL_BY_KIND.decision_unrenderable); + }); + }); + + // The batch processor's queue used to overflow inside the synchronous + // loop: 5000 entries wrote 2560 spans while the summary claimed 5000. + it("writes every span for a large audit log, and the summary counts what was written", async () => { + await withScratch(async (auditPath, otelPath) => { + let lines = ""; + for (let i = 0; i < 5000; i += 1) { + lines += entryLine({ seq: i + 1, rpc_id: crypto.randomUUID() }); + } + appendFileSync(auditPath, lines); + + const summary = await convertAuditLog(auditPath, otelPath); + + expect(summary.converted).toBe(5000); + expect(summary.written).toBe(5000); + expect(spansIn(otelPath)).toHaveLength(5000); + }); + }); + + // The Guardian's spans carry the derived UUID and the request id; a + // converter span has to carry the same pair, or #228's pair rules can't + // join the two files. + it("carries the same acs.session.id and acs.request_id the Guardian's spans carry", async () => { + await withScratch(async (auditPath, otelPath) => { + const rpcId = crypto.randomUUID(); + appendFileSync(auditPath, entryLine({ rpc_id: rpcId })); + + await convertAuditLog(auditPath, otelPath); + + const [span] = spansIn(otelPath); + expect(attribute(span, "acs.session.id")).toBe(toSessionUuid("sess-1")); + expect(attribute(span, "acs.request_id")).toBe(rpcId); + }); + }); + + it("derives the session UUID exactly as the host adapter does", () => { + for (const raw of ["sess-1", "ses_opencode_abc", "6B1F2C9E-4D7A-4E3B-8A51-9C0D2E7F1A36", "セッション-é", ""]) { + expect({ raw, uuid: converterSessionUuid(raw) }).toEqual({ raw, uuid: toSessionUuid(raw) }); + } + }); + + it("counts an entry with no method by its failure kind, apart from unmapped methods", async () => { + await withScratch(async (auditPath, otelPath) => { + appendFileSync(auditPath, entryLine({ method: null, rpc_id: null, outcome: "blocked", posture: "deny", failure: { kind: "host_configuration", message: "x" } })); + appendFileSync(auditPath, entryLine({ method: "protocols/MCP/tools/call" })); + + const summary = await convertAuditLog(auditPath, otelPath); + + expect(summary.skippedNoMethod).toEqual({ host_configuration: 1 }); + expect(summary.skippedUnmapped).toBe(1); + expect(summary.written).toBe(0); + }); + }); + + // The posture field has been on every posture entry since the first audit + // format, so a missing one is a hand-edited log. Inferring it from outcome + // is the bug this PR fixes, so the entry is skipped and counted instead. + it("skips and counts an entry with no posture rather than guessing one", async () => { + await withScratch(async (auditPath, otelPath) => { + appendFileSync(auditPath, entryLine({ posture: undefined })); + + const summary = await convertAuditLog(auditPath, otelPath); + + expect(summary.skippedUnreadable).toBe(1); + expect(summary.written).toBe(0); + }); + }); + + // #228: no failure event for a replay, because the pair is the earlier request's. + it("skips and counts a REPLAY_DETECTED error", async () => { + await withScratch(async (auditPath, otelPath) => { + appendFileSync(auditPath, entryLine({ outcome: "blocked", failure: { kind: "refused", message: "guardian refused the envelope with error -32005: replay" } })); + + const summary = await convertAuditLog(auditPath, otelPath); + + expect(summary.skippedReplay).toBe(1); + expect(summary.written).toBe(0); + }); + }); + + it("ignores sampler environment variables", async () => { + const saved = process.env.OTEL_TRACES_SAMPLER; + process.env.OTEL_TRACES_SAMPLER = "always_off"; + try { + await withScratch(async (auditPath, otelPath) => { + appendFileSync(auditPath, entryLine({})); + const summary = await convertAuditLog(auditPath, otelPath); + expect(summary.written).toBe(1); + }); + } finally { + if (saved === undefined) delete process.env.OTEL_TRACES_SAMPLER; + else process.env.OTEL_TRACES_SAMPLER = saved; + } + }); +}); + +// Issue #276 item 4 and the issue #274 service name: the converter's output +// must not depend on OTEL_* variables in the shell that runs it, and its +// spans carry their own producer label. +describe("audit-to-otlp: output does not depend on the environment", () => { + const AUDIT_LINE = `${JSON.stringify({ + seq: 1, + recorded_at: "2026-10-15T00:00:00.000Z", + session_id: "sess-1", + method: "steps/toolCallRequest", + rpc_id: "3f6c1e2a-8b4d-4c1e-9a7f-2d5b6e8c9a01", + outcome: "proceeded", + posture: "proceed", + posture_source: "negotiated", + failure: { kind: "transport", message: "connect ECONNREFUSED 127.0.0.1:1" }, + })}\n`; + const HOSTILE: Record = { + OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT: "8", + OTEL_SPAN_EVENT_COUNT_LIMIT: "0", + OTEL_SPAN_ATTRIBUTE_PER_EVENT_COUNT_LIMIT: "1", + OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT: "1", + OTEL_TRACES_SAMPLER: "always_off", + OTEL_SERVICE_NAME: "zzz", + OTEL_RESOURCE_ATTRIBUTES: "service.name=evil", + }; + const saved: Record = {}; + afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it("keeps full ids, the whole failure event, and the converter's own label", async () => { + for (const [key, value] of Object.entries(HOSTILE)) { + saved[key] = process.env[key]; + process.env[key] = value; + } + await withScratch(async (auditPath, otelPath) => { + appendFileSync(auditPath, AUDIT_LINE); + const summary = await convertAuditLog(auditPath, otelPath); + expect(summary.written).toBe(1); + const request = JSON.parse(readFileSync(otelPath, "utf8").split("\n")[0]!); + const resourceSpans = request.resourceSpans[0]; + expect(resourceSpans.resource.attributes).toEqual([{ key: "service.name", value: { stringValue: CONVERTER_SERVICE_NAME } }]); + expect(resourceSpans.scopeSpans[0].scope.name).toBe(CONVERTER_SERVICE_NAME); + const span = resourceSpans.scopeSpans[0].spans[0]; + expect(attribute(span, "acs.request_id")).toBe("3f6c1e2a-8b4d-4c1e-9a7f-2d5b6e8c9a01"); + expect(attribute(span, "acs.session.id")).toBe(converterSessionUuid("sess-1")); + expect(span.droppedAttributesCount ?? 0).toBe(0); + const event = span.events.find((e: OtlpEvent) => e.name === "acs.decision.failure"); + expect(event).toBeDefined(); + expect(event.droppedAttributesCount ?? 0).toBe(0); + expect(attribute(event, "acs.failure.class")).toBe("unreachable"); + expect(attribute(event, "acs.failure.posture")).toBe("fail_open"); + expect(attribute(event, "acs.failure.reported_by")).toBe("observed_agent"); + expect(attribute(event, "acs.failure.detail")).toBe(FAILURE_DETAIL_BY_KIND.transport); + }); + }, 10_000); + + it("labels its spans apart from the Guardian's", () => { + expect(CONVERTER_SERVICE_NAME).toBe("acs-reference-audit-converter"); + expect(CONVERTER_SERVICE_NAME).not.toBe(OTEL_SERVICE_NAME); + }); + + it("shares one frozen set of span limits with the Guardian", () => { + expect(Object.isFrozen(SPAN_LIMITS)).toBe(true); + expect(SPAN_LIMITS).toEqual({ + attributeValueLengthLimit: 4096, + attributeCountLimit: 128, + eventCountLimit: 128, + attributePerEventCountLimit: 128, + linkCountLimit: 128, + attributePerLinkCountLimit: 128, + }); + }); +}); + +// Converting onto an existing file appended OTLP lines to it. Onto the audit +// log itself (or a hardlink of it) that corrupts the evidence and shifts the +// next audit seq. Twice over the same output it doubles every span. Onto the +// Guardian's trace file it merges the two streams. One rule covers all of them. +describe("audit-to-otlp: refuses an output that already exists", () => { + const LINE = `${JSON.stringify({ + seq: 1, + recorded_at: "2026-10-15T00:00:00.000Z", + session_id: "sess-1", + method: "steps/toolCallRequest", + rpc_id: "3f6c1e2a-8b4d-4c1e-9a7f-2d5b6e8c9a01", + outcome: "proceeded", + posture: "proceed", + posture_source: "negotiated", + failure: { kind: "transport", message: "connect ECONNREFUSED 127.0.0.1:1" }, + })}\n`; + + /** A fresh directory with the audit log in it, removed whatever the test does. */ + async function inScratch(run: (dir: string, audit: string) => Promise): Promise { + const dir = mkdtempSync(join(tmpdir(), "acs-audit-exclusive-")); + const audit = join(dir, "audit.jsonl"); + writeFileSync(audit, LINE); + try { + await run(dir, audit); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it("refuses to convert a log onto itself and leaves it byte-identical", () => + inScratch(async (_dir, audit) => { + await expect(convertAuditLog(audit, audit)).rejects.toThrow(/^refusing to write/); + expect(readFileSync(audit, "utf8")).toBe(LINE); + })); + + it("refuses a hardlink to the log", () => + inScratch(async (dir, audit) => { + const link = join(dir, "otel.jsonl"); + linkSync(audit, link); + await expect(convertAuditLog(audit, link)).rejects.toThrow(/^refusing to write/); + expect(readFileSync(audit, "utf8")).toBe(LINE); + })); + + it("refuses an existing unrelated output, such as the Guardian's trace file", () => + inScratch(async (dir, audit) => { + const guardianTrace = join(dir, "otel-traces.jsonl"); + writeFileSync(guardianTrace, "guardian line\n"); + await expect(convertAuditLog(audit, guardianTrace)).rejects.toThrow(/^refusing to write/); + expect(readFileSync(guardianTrace, "utf8")).toBe("guardian line\n"); + })); + + it("refuses a dangling symlink and creates nothing at its target", () => + inScratch(async (dir, audit) => { + const target = join(dir, "elsewhere.jsonl"); + const link = join(dir, "otel.jsonl"); + symlinkSync(target, link); + await expect(convertAuditLog(audit, link)).rejects.toThrow(/^refusing to write/); + expect(existsSync(target)).toBe(false); + })); + + it("leaves no output behind when the audit log cannot be read, so the corrected rerun is not refused", () => + inScratch(async (dir) => { + const out = join(dir, "otel.jsonl"); + await expect(convertAuditLog(join(dir, "missing.jsonl"), out)).rejects.toThrow(); + expect(existsSync(out)).toBe(false); + })); + + it("writes a new output owner-only, creating its directory owner-only", () => + inScratch(async (dir, audit) => { + const out = join(dir, "new-dir", "otel.jsonl"); + const summary = await convertAuditLog(audit, out); + expect(summary.written).toBe(1); + expect(statSync(out).mode & 0o777).toBe(0o600); + expect(statSync(join(dir, "new-dir")).mode & 0o777).toBe(0o700); + })); +}); diff --git a/reference-implementations/agt/test/collector-config.test.ts b/reference-implementations/agt/test/collector-config.test.ts new file mode 100644 index 00000000..4778f3f6 --- /dev/null +++ b/reference-implementations/agt/test/collector-config.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +/** + * otel-collector.yaml as committed (issue #276). Structure only: the proof + * that otelcol-contrib 0.162.0 accepts it and behaves is the end-to-end run + * in the PR. What this pins is what a later edit could quietly undo. + */ +const CONFIG_PATH = fileURLToPath(new URL("../otel-collector.yaml", import.meta.url)); +const IMAGE = "otel/opentelemetry-collector-contrib:0.162.0@sha256:39923a8e431bd1f57be82411999d389fcfe40857492e4365456d97a4c1f74be6"; +const text = readFileSync(CONFIG_PATH, "utf8"); +const config = Bun.YAML.parse(text) as { + receivers: { otlp: { protocols: { http: { endpoint: string } } } }; + exporters: { file: { path: string; append?: unknown; create_directory?: unknown; directory_permissions?: unknown } }; + service: { telemetry?: { metrics?: { level?: string } } }; +}; + +describe("otel-collector.yaml", () => { + it("listens on loopback only, as a literal: an empty env var would bind every interface", () => { + expect(config.receivers.otlp.protocols.http.endpoint).toBe("127.0.0.1:4318"); + expect(text).not.toMatch(/\$\{/); + }); + + it("publishes the container's port on host loopback only", () => { + expect(text).toContain("-p 127.0.0.1:4318:4318"); + expect(text).not.toMatch(/-p 4318:4318/); + }); + + it("appends across restarts and creates its directory owner-only", () => { + expect(config.exporters.file.append).toBe(true); + expect(config.exporters.file.create_directory).toBe(true); + expect(config.exporters.file.directory_permissions).toBe("0700"); + }); + + it("turns off its own metrics endpoint, so port 8888 cannot clash", () => { + expect(config.service.telemetry?.metrics?.level).toBe("none"); + }); + + it("names the digest-pinned image", () => { + expect(text).toContain(IMAGE); + }); +}); + +const README_PATH = fileURLToPath(new URL("../README.md", import.meta.url)); +const readme = readFileSync(README_PATH, "utf8"); + +describe("the walkthrough's collector step", () => { + it("names the same digest-pinned image and loopback-only port as the config", () => { + expect(readme).toContain(IMAGE); + expect(readme).toContain("-p 127.0.0.1:4318:4318"); + }); + + it("states what was verified, with no unfilled placeholder", () => { + expect(readme).not.toContain("[run on"); + expect(readme).not.toContain("| not run yet]"); + expect(readme).toContain("with the Docker command above on Docker Desktop for Mac"); + }); +}); + +// Bar's two pre-handoff findings on issue #274. +describe("the walkthrough's proxy and rerun instructions", () => { + const STOP_FIRST = + "Stop the Guardian and the collector first, with Ctrl-C or `docker stop acs-otelcol`. A process that is still running keeps writing into the deleted file, and its spans are lost without an error."; + + it("tells the reader to stop the Guardian and the collector before deleting .acs", () => { + const paragraph = readme.slice(readme.indexOf("**Run it again.**")); + expect(paragraph.indexOf(STOP_FIRST)).toBeGreaterThan(-1); + expect(paragraph.indexOf(STOP_FIRST)).toBeLessThan(paragraph.indexOf("rm -rf .acs")); + }); + + it("names the container, so docker stop has something to stop, in both the README and the config", () => { + expect(readme).toContain("docker run --rm --name acs-otelcol"); + expect(text).toContain("docker run --rm --name acs-otelcol"); + }); + + it("keeps the reader's own exemptions in both proxy variables in step 1", () => { + expect(readme).toContain('export NO_PROXY="${NO_PROXY:+$NO_PROXY,}${no_proxy:+$no_proxy,}127.0.0.1,localhost"\nexport no_proxy="$NO_PROXY"'); + expect(readme).not.toContain("export NO_PROXY=127.0.0.1,localhost no_proxy=127.0.0.1,localhost"); + }); +}); diff --git a/reference-implementations/agt/test/sample-trace.test.ts b/reference-implementations/agt/test/sample-trace.test.ts new file mode 100644 index 00000000..dbc86ab5 --- /dev/null +++ b/reference-implementations/agt/test/sample-trace.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CONVERTER_SERVICE_NAME } from "../packages/guardian/src/audit-to-otlp.ts"; +import { OTEL_SERVICE_NAME } from "../packages/guardian/src/otel-trace-exporter.ts"; +import { generateSampleTrace, SAMPLE_PATH, SCENARIOS } from "../scripts/make-sample-trace.ts"; + +/** + * The committed sample trace (issue #266), held to the mapping it claims to + * follow, and to the exporter that wrote it. If the exporter stops emitting + * an attribute the mapping requires or makes conditional, every unit test can + * still pass; a test over real output cannot. + */ + +const MAPPING_PATH = new URL("../../../specification/v0.1.0/trace/otel-mapping.json", import.meta.url); + +type OtlpValue = { stringValue?: string; intValue?: string | number; arrayValue?: { values?: OtlpValue[] } }; +type OtlpAttribute = { key: string; value: OtlpValue }; +type OtlpEvent = { name: string; timeUnixNano?: string; attributes?: OtlpAttribute[] }; +type OtlpSpan = { + name: string; + traceId?: string; + spanId?: string; + startTimeUnixNano?: string; + endTimeUnixNano?: string; + attributes?: OtlpAttribute[]; + events?: OtlpEvent[]; + status?: { code?: number; message?: string }; +}; +type OtlpRequest = { + resourceSpans: { + resource?: { attributes?: OtlpAttribute[] }; + scopeSpans: { scope?: { name?: string }; spans: OtlpSpan[] }[]; + }[]; +}; +type LabeledSpan = OtlpSpan & { service?: string; scope?: string }; + +function spansIn(path: string | URL): LabeledSpan[] { + return readFileSync(path, "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .flatMap((line) => + (JSON.parse(line) as OtlpRequest).resourceSpans.flatMap((rs) => + rs.scopeSpans.flatMap((ss) => + ss.spans.map((span) => ({ + ...span, + service: rs.resource?.attributes?.find((a) => a.key === "service.name")?.value.stringValue, + scope: ss.scope?.name, + })), + ), + ), + ); +} + +function plain(value: OtlpValue): unknown { + if (value.arrayValue !== undefined) return (value.arrayValue.values ?? []).map(plain); + if (value.intValue !== undefined) return Number(value.intValue); + return value.stringValue; +} + +function attributes(carrier: { attributes?: OtlpAttribute[] } | undefined): Record { + return Object.fromEntries((carrier?.attributes ?? []).map((a) => [a.key, plain(a.value)])); +} + +/** What a span says once the values that differ between runs are taken out: + * ids, timestamps, elapsed time, and which export batch it landed in. */ +function normalize(spans: LabeledSpan[]): unknown[] { + return spans + .map((span) => ({ + service: span.service, + scope: span.scope, + name: span.name, + attributes: attributes(span), + status: span.status ?? {}, + events: (span.events ?? []).map((event) => { + const { "acs.failure.elapsed_ms": _elapsed, ...rest } = attributes(event); + return { name: event.name, attributes: rest }; + }), + })) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); +} + +type MappingSchema = { + properties: { + step_to_span: { default: Record }; + step_span_attributes: { properties: { required_attributes: { default: string[] } } }; + decision_failure_event: { properties: { attribute_values: { default: Record } } }; + }; +}; +const MAPPING = JSON.parse(readFileSync(MAPPING_PATH, "utf8")) as MappingSchema; +const STEP_BY_SPAN_NAME = Object.fromEntries( + Object.entries(MAPPING.properties.step_to_span.default).map(([method, entry]) => [entry.span_name, entry]), +); + +/** Required attributes a span reported by the host audit converter cannot + * carry, and why. Anything not named here is required of every span. */ +const CONVERTER_EXEMPT: Readonly> = { + "gen_ai.tool.name": "the host audit log records no tool name (issue #274)", +}; + +const SPANS = spansIn(SAMPLE_PATH); + +function spanFor(requestId: string): OtlpSpan | undefined { + return SPANS.find((span) => attributes(span)["acs.request_id"] === requestId); +} + +function failureOf(span: OtlpSpan | undefined): Record | undefined { + const event = span?.events?.find((e) => e.name === "acs.decision.failure"); + return event === undefined ? undefined : attributes(event); +} + +describe("the committed sample trace matches the mapping", () => { + it("names every span with a step_to_span value", () => { + expect(SPANS.length).toBeGreaterThan(0); + for (const span of SPANS) { + expect({ name: span.name, mapped: span.name in STEP_BY_SPAN_NAME }).toEqual({ name: span.name, mapped: true }); + } + }); + + it("labels converter spans as the converter and every other span as the Guardian", () => { + for (const span of SPANS) { + const fromConverter = failureOf(span)?.["acs.failure.reported_by"] === "observed_agent"; + const expected = fromConverter ? CONVERTER_SERVICE_NAME : OTEL_SERVICE_NAME; + expect({ span: span.name, service: span.service, scope: span.scope }).toEqual({ span: span.name, service: expected, scope: expected }); + } + }); + + it("carries every required attribute, apart from the named converter exemptions", () => { + const always = MAPPING.properties.step_span_attributes.properties.required_attributes.default; + for (const span of SPANS) { + const present = attributes(span); + const fromConverter = failureOf(span)?.["acs.failure.reported_by"] === "observed_agent"; + for (const attribute of [...always, ...STEP_BY_SPAN_NAME[span.name]!.required_attributes]) { + if (fromConverter && attribute in CONVERTER_EXEMPT) continue; + expect({ span: span.name, attribute, present: attribute in present }).toEqual({ span: span.name, attribute, present: true }); + } + } + }); + + it("carries a conditional attribute exactly when the request carried its source", () => { + for (const scenario of SCENARIOS) { + const present = attributes(spanFor(scenario.requestId)); + expect({ scenario: scenario.name, capability: "acs.capability" in present }).toEqual({ + scenario: scenario.name, + capability: scenario.capability !== undefined, + }); + expect({ scenario: scenario.name, ref: "acs.request_id_ref" in present }).toEqual({ + scenario: scenario.name, + ref: scenario.requestIdRef !== undefined, + }); + } + }); + + // A sample whose decisions are all policy failures would pass every check + // above and still show a reader nothing true, so each one is pinned. + it("records the decision each scenario was built to produce, and none from a policy failure", () => { + for (const scenario of SCENARIOS) { + const decision = attributes(spanFor(scenario.requestId)?.events?.find((e) => e.name === "acs.decision")); + expect({ scenario: scenario.name, decision: decision["acs.decision"] }).toEqual({ scenario: scenario.name, decision: scenario.decision }); + expect(String(decision["acs.reason_codes"] ?? "")).not.toContain("runtime_error"); + } + }); + + it("links the result to its request through acs.request_id_ref", () => { + const result = SCENARIOS.find((scenario) => scenario.requestIdRef !== undefined)!; + expect(attributes(spanFor(result.requestId))["acs.request_id_ref"]).toBe(result.requestIdRef); + expect(spanFor(result.requestIdRef!)).toBeDefined(); + }); + + // This Guardian always names a rule and never sends policy_version, so the + // padding it exercises is the version column: one "" per reference. + it("pads the policy and provenance columns to a common length", () => { + const denied = attributes(spanFor(SCENARIOS.find((s) => s.name === "denied")!.requestId)); + const decision = attributes(spanFor(SCENARIOS.find((s) => s.name === "denied")!.requestId)?.events?.find((e) => e.name === "acs.decision")); + const ids = decision["acs.policy.ids"] as string[]; + expect(ids.length).toBeGreaterThan(0); + expect((decision["acs.policy.versions"] as string[]).length).toBe(ids.length); + expect((decision["acs.policy.rule_ids"] as string[]).length).toBe(ids.length); + expect(decision["acs.policy.versions"]).toEqual(ids.map(() => "")); + expect((denied["acs.provenance.source_id"] as string[]).length).toBe((denied["acs.provenance.origin"] as string[]).length); + expect(denied["acs.provenance.source_id"]).toContain(""); + }); + + it("uses only failure classes, postures and reporters the mapping lists, and covers the four failures", () => { + const allowed = MAPPING.properties.decision_failure_event.properties.attribute_values.default; + const seen = new Set(); + for (const span of SPANS) { + const failure = failureOf(span); + if (failure === undefined) continue; + for (const key of ["acs.failure.class", "acs.failure.posture", "acs.failure.reported_by"]) { + expect({ key, value: failure[key], listed: allowed[key]!.includes(String(failure[key])) }).toEqual({ + key, + value: failure[key], + listed: true, + }); + } + seen.add(`${failure["acs.failure.class"]}/${failure["acs.failure.reported_by"]}`); + } + expect([...seen].sort()).toEqual(["guardian_error/guardian", "timeout/guardian", "unreachable/observed_agent"]); + }); +}); + +describe("the committed sample trace is current", () => { + it("is what the generator writes today, once ids and timestamps are set aside", async () => { + const dir = mkdtempSync(join(tmpdir(), "acs-sample-trace-")); + try { + const fresh = join(dir, "sample.jsonl"); + await generateSampleTrace(fresh); + expect(normalize(spansIn(fresh))).toEqual(normalize(SPANS)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 30_000); +}); diff --git a/specification/v0.1.0/provenance.json b/specification/v0.1.0/provenance.json index 04c61e85..73b335bd 100644 --- a/specification/v0.1.0/provenance.json +++ b/specification/v0.1.0/provenance.json @@ -25,6 +25,7 @@ }, "source_id": { "type": "string", + "minLength": 1, "description": "Identifier within the origin (tool name, URL, file path, retrieval source). Combined with origin, this is what Guardians key trust classification on in v0.1." }, "derived_from": { diff --git a/specification/v0.1.0/response-envelope.json b/specification/v0.1.0/response-envelope.json index 52ed0fa3..56a16a22 100644 --- a/specification/v0.1.0/response-envelope.json +++ b/specification/v0.1.0/response-envelope.json @@ -56,13 +56,14 @@ "items": { "type": "object", "properties": { - "policy_id": { "type": "string" }, + "policy_id": { "type": "string", "minLength": 1 }, "policy_version": { "type": "string", + "minLength": 1, "description": "Optional deployment-chosen version identifier for the referenced policy bundle or document. Lets audit replay reconstruct the exact historical policy state without prescribing a ledger or versioning scheme." }, - "policy_name": { "type": "string" }, - "rule_id": { "type": "string" } + "policy_name": { "type": "string", "minLength": 1 }, + "rule_id": { "type": "string", "minLength": 1 } } }, "description": "The policies/rules that fired. A single decision MAY cite multiple — when IBAC + FIDES + AARM all reject the same action, expect three entries here, one per paradigm. policy_version is optional and deployment-defined, but SHOULD be populated when the deployment supports replay or ledger-backed policy state. Audit replay walks this list to reconstruct which rules contributed." diff --git a/specification/v0.1.0/trace/ocsf-mapping.json b/specification/v0.1.0/trace/ocsf-mapping.json index 3856c8b5..879d4b29 100644 --- a/specification/v0.1.0/trace/ocsf-mapping.json +++ b/specification/v0.1.0/trace/ocsf-mapping.json @@ -2,29 +2,30 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/trace/ocsf-mapping.json", "title": "ACS Trace → OCSF event-class mapping", - "description": "Normative mapping from ACS hooks and decisions to OCSF (Open Cybersecurity Schema Framework) event classes. v0.1.0 conformance requires that a deployment emitting OCSF for the Trace pillar uses these class UIDs and severity rules verbatim. OCSF classes are referenced by their stable class_uid as of OCSF 1.5+.", + "description": "Normative mapping from ACS hooks and decisions to OCSF (Open Cybersecurity Schema Framework) event classes. v0.1.0 conformance requires that a deployment emitting OCSF for the Trace pillar uses these class UIDs and severity rules verbatim. Class UIDs, class names and activity IDs are those of OCSF 1.9.0.", "type": "object", "properties": { "step_to_class": { "type": "object", - "description": "Map ACS step methods to OCSF class UIDs.", + "description": "Map ACS step methods to OCSF class UIDs. Every step event carries the step's request_id at unmapped.acs.request_id and its session_id at unmapped.acs.session_id, the same values as the OTel attributes acs.request_id and acs.session.id. Every step event also sets metadata.correlation_uid to its own pair, formatted as :, because SIEM content reads native fields and not unmapped. A steps/toolCallResult event also carries request_id_ref at unmapped.acs.request_id_ref whenever the payload carries it.", "additionalProperties": { "type": "object", "required": ["class_uid", "class_name"], "properties": { "class_uid": { "type": "integer" }, "class_name": { "type": "string" }, - "activity_id": { "type": "integer", "description": "OCSF activity_id within the class (e.g., 1=Logon, 2=Logoff for Authentication)." } + "activity_id": { "type": "integer", "description": "OCSF activity_id within the class (e.g., 1=Logon, 2=Logoff for Authentication)." }, + "_note": { "type": "string", "description": "Explanation for readers." } } }, "default": { "steps/sessionStart": { "class_uid": 3002, "class_name": "Authentication", "activity_id": 1 }, "steps/sessionEnd": { "class_uid": 3002, "class_name": "Authentication", "activity_id": 2 }, - "steps/userMessage": { "class_uid": 6002, "class_name": "Application Activity" }, - "steps/agentResponse": { "class_uid": 6002, "class_name": "Application Activity" }, - "steps/agentTrigger": { "class_uid": 6002, "class_name": "Application Activity" }, - "steps/turnStart": { "class_uid": 6002, "class_name": "Application Activity" }, - "steps/turnEnd": { "class_uid": 6002, "class_name": "Application Activity" }, + "steps/userMessage": { "class_uid": 6002, "class_name": "Application Lifecycle" }, + "steps/agentResponse": { "class_uid": 6002, "class_name": "Application Lifecycle" }, + "steps/agentTrigger": { "class_uid": 6002, "class_name": "Application Lifecycle" }, + "steps/turnStart": { "class_uid": 6002, "class_name": "Application Lifecycle" }, + "steps/turnEnd": { "class_uid": 6002, "class_name": "Application Lifecycle" }, "steps/toolCallRequest": { "class_uid": 1007, "class_name": "Process Activity" }, "steps/toolCallResult": { "class_uid": 1007, "class_name": "Process Activity" }, "steps/knowledgeRetrieval": { "class_uid": 6005, "class_name": "Datastore Activity" }, @@ -34,20 +35,114 @@ "steps/postCompact": { "class_uid": 6005, "class_name": "Datastore Activity", "activity_id": 99 }, "steps/subagentStart": { "class_uid": 3002, "class_name": "Authentication", "activity_id": 1, "_note": "Subagent spawn opens a new session — modeled as Authentication/Logon for OCSF symmetry with sessionStart." }, "steps/subagentStop": { "class_uid": 3002, "class_name": "Authentication", "activity_id": 2 }, - "agbom/snapshot": { "class_uid": 5001, "class_name": "Inventory Info" }, - "agbom/changed": { "class_uid": 5001, "class_name": "Inventory Info" } + "steps/skillRegister": { "class_uid": 5001, "class_name": "Device Inventory Info", "activity_id": 2, "_note": "Registration admits a skill to the component set and persists its definition ref and digest to the AgBOM. It is modeled as Device Inventory Info, the class agbom/* uses, with activity_id 2 (Collect)." }, + "steps/skillLoad": { "class_uid": 1005, "class_name": "Module Activity", "activity_id": 1, "_note": "OCSF defines Module Activity as a process loading or unloading a module, and its required module object is the skill itself — not Process Activity, which needs a launched or terminated process that a skill load does not have; keeping loads off 1007 lets a reader tell a skill loading from the tool calls its actions raise." }, + "steps/skillUnload": { "class_uid": 1005, "class_name": "Module Activity", "activity_id": 2 }, + "agbom/snapshot": { "class_uid": 5001, "class_name": "Device Inventory Info", "activity_id": 2 }, + "agbom/changed": { "class_uid": 5001, "class_name": "Device Inventory Info", "activity_id": 2 } } }, "decision_class": { "type": "object", - "description": "Decisions (deny/modify/ask/defer) are emitted as Detection Finding events. 'allow' is normally emitted as Application Activity with informational severity rather than as a finding, unless the deployment elects to record allows as findings for full audit symmetry.", + "description": "Decisions (deny/modify/ask/defer) are emitted as Detection Finding events. 'allow' is normally emitted as Application Activity with informational severity rather than as a finding, unless the deployment elects to record allows as findings for full audit symmetry. action_id and disposition_id record the Guardian's verdict. Only the Observed Agent sees the enforced outcome (Specification §6.4), so an action_id of 2 (Denied) or a disposition_id of 2 (Blocked) never proves the action was blocked. When any acs.decision.failure event in either party's stream carries the same session_id and request_id, a reader MUST NOT count the decision event as an enforced outcome. A missing failure event does not prove the decision was enforced. A Guardian SHOULD NOT set verdict_id or the finding's status_id, because both belong to whoever triages the finding.", "properties": { "class_uid": { "type": "integer", "const": 2004 }, "class_name": { "type": "string", "const": "Detection Finding" }, "required_fields": { "type": "array", "items": { "type": "string" }, - "default": ["finding.title", "finding.uid", "evidences", "verdict_id"] + "description": "Fields every decision event carries. action and disposition are required beside their ids because ask and defer share 99 in both.", + "default": ["finding_info", "finding_info.title", "action_id", "action", "disposition_id", "disposition", "evidences", "metadata.correlation_uid"] + }, + "profiles": { + "type": "array", + "items": { "type": "string" }, + "description": "OCSF profiles a decision event lists in metadata.profiles. The Security Control profile defines disposition_id, which Detection Finding does not define on its own.", + "default": ["security_control"] + }, + "disposition_mapping": { + "type": "object", + "description": "OCSF disposition_id for decision events, keyed by ACS disposition. 99 (Other) means OCSF has no matching value. Defer takes 99, not 14 (Delayed), because an ACS defer holds the step and denies by default when it times out, where Delayed reads as an action that ran later. The disposition attribute carries the OCSF caption for 1 and 2, and the ACS disposition name for 99. The allow entry applies when the deployment records allows as Detection Findings.", + "additionalProperties": { "type": "integer" }, + "default": { + "allow": 1, + "deny": 2, + "modify": 99, + "ask": 99, + "defer": 99 + } + }, + "action_mapping": { + "type": "object", + "description": "OCSF action_id for decision events, keyed by ACS disposition. modify takes 4 (Modified), which gives it a code disposition_id does not have. The action attribute carries the OCSF caption for 1 (Allowed), 2 (Denied) and 4 (Modified), and the ACS disposition name for 99.", + "additionalProperties": { "type": "integer" }, + "default": { + "allow": 1, + "deny": 2, + "modify": 4, + "ask": 99, + "defer": 99 + } + }, + "alert_mapping": { + "type": "object", + "description": "OCSF is_alert for an on-time decision event, keyed by ACS disposition. It is true exactly when severity_id is 3 or higher, so the two fields never disagree. ask and defer alert because an ASK can time out to allow and a DEFER can fall back to that ASK, and v0.1.0 has no Trace event for how either one ends, so this event is the only signal a SOC gets. modify does not alert, because the action ran in the form the policy required. A MODIFY the client cannot apply becomes a DENY (Specification §6.5), which alerts.", + "additionalProperties": { "type": "boolean" }, + "default": { + "allow": false, + "deny": true, + "modify": false, + "ask": true, + "defer": true + } + }, + "evidence_fields": { + "type": "object", + "description": "What every decision event carries in evidences, the only native place on a Detection Finding for the agent and the action. Detection Finding has no ai_operation, and the class rejects a top-level ai_agent. api.operation is set only when the gated step is steps/toolCallRequest or steps/toolCallResult, to the tool name. For every other step, evidences carries ai_agent and actor.session only.", + "additionalProperties": { "type": "string" }, + "default": { + "ai_agent.uid": "metadata.agent_id", + "actor.session.uid": "metadata.session_id", + "api.operation": "payload.tool.name" + } + }, + "policy_fields": { + "type": "object", + "description": "When the response carries a non-empty policy_references, the event carries authorizations, one entry per policy_references entry in the same order, each with a policy object built from these fields. An entry with neither policy_id nor policy_name MUST be an authorizations entry with no policy object, so positions still line up: OCSF requires a policy to carry uid or name. policy_references stays under unmapped.acs as the exact copy.", + "additionalProperties": { "type": "string" }, + "default": { + "policy.uid": "policy_id", + "policy.version": "policy_version", + "policy.name": "policy_name" + } + }, + "late_decision": { + "type": "object", + "description": "A Guardian that finishes a decision after the negotiated timeout still emits the decision event. A late deny under fail-open means the policy said no and the action ran, so suppressing the event would leave OCSF silent exactly when someone slows the Guardian down. action_id and disposition_id stay the verdict, as on an on-time decision. finding_info.types includes late_decision_fail_open or late_decision_fail_closed, from the on_decision_failure posture the Guardian sent in its ServerHello. unmapped.acs.failure carries the failure facts, named as the OTel acs.failure.* attributes are. is_alert follows alert_mapping, and is also true for any verdict other than allow under fail-open, because the action ran without what the policy required. severity_id stays the value severity_mapping gives the verdict, so a late decision is the one case where is_alert can be true below severity 3. A reader MUST NOT count a decision event whose finding_info.types includes late_decision_fail_open or late_decision_fail_closed as an enforced outcome. The Guardian measures its own processing time, not network time, so a decision can still arrive late and look on time.", + "properties": { + "finding_types": { + "type": "array", + "items": { "type": "string" }, + "default": ["late_decision_fail_open", "late_decision_fail_closed"] + }, + "failure_fields": { + "type": "array", + "items": { "type": "string" }, + "default": ["class", "posture", "timeout_ms", "elapsed_ms", "payload_bytes"] + } + } + }, + "join_fields": { + "type": "array", + "items": { "type": "string" }, + "description": "Fields every decision event carries under unmapped.acs to join it to the step event it gated. request_id comes from the response, and session_id from the gated request's metadata.session_id. metadata.correlation_uid carries the same pair as :.", + "default": ["request_id", "session_id"] + }, + "acs_fields": { + "type": "array", + "items": { "type": "string" }, + "description": "Decision facts carried under unmapped.acs with the response field's name whenever the response carries it, so the OCSF event holds the same facts as the OTel acs.decision event. finding_info.desc SHOULD carry reasoning, and unmapped.acs.reasoning is the exact copy. policy_references keeps its object form, so it needs no padding rule. An empty source array counts as absent. OCSF does not validate unmapped, so each field takes the type and pattern of its definition in response-envelope.json.", + "default": ["request_id", "decision", "reasoning", "reason_codes", "policy_references", "cited_provenance_ids", "chain_hash", "metadata"] } } }, @@ -75,7 +170,7 @@ "optional_enrichments": { "type": "array", "items": { "type": "string" }, - "default": ["acs_provenance_source_id", "acs_provenance_lineage_depth"] + "default": ["acs_provenance_source_id"] } } } diff --git a/specification/v0.1.0/trace/otel-mapping.json b/specification/v0.1.0/trace/otel-mapping.json index d84846cb..aa76ebed 100644 --- a/specification/v0.1.0/trace/otel-mapping.json +++ b/specification/v0.1.0/trace/otel-mapping.json @@ -7,14 +7,24 @@ "properties": { "step_to_span": { "type": "object", - "description": "Map ACS step methods to OTel span names + required attributes.", + "description": "Map ACS step methods to OTel span names and attributes. A required attribute's source field is required on the wire. A conditional attribute's source field is optional on the wire.", "additionalProperties": { "type": "object", "required": ["span_name", "required_attributes"], "properties": { "span_name": { "type": "string" }, "required_attributes": { "type": "array", "items": { "type": "string" } }, - "optional_attributes": { "type": "array", "items": { "type": "string" } } + "conditional_attributes": { + "type": "array", + "items": { "type": "string" }, + "description": "Attributes populated whenever the hook payload carries the source field. An exporter MUST emit each one when its source field is present and MUST omit it when the source field is absent. An empty source array counts as absent." + }, + "optional_attributes": { + "type": "array", + "items": { "type": "string" }, + "description": "Attributes an exporter MAY emit when its source field is present." + }, + "_note": { "type": "string", "description": "Explanation for readers. The normative rules are in the lists and descriptions." } } }, "default": { @@ -37,25 +47,30 @@ }, "steps/toolCallRequest": { "span_name": "gen_ai.tool.call", - "required_attributes": ["gen_ai.tool.name", "acs.capability"], + "required_attributes": ["gen_ai.tool.name"], + "conditional_attributes": ["acs.capability"], "optional_attributes": ["acs.tool.provider", "acs.tool.version", "acs.operation"] }, "steps/toolCallResult": { "span_name": "gen_ai.tool.result", "required_attributes": ["gen_ai.tool.name", "acs.exit_status"], - "optional_attributes": ["acs.duration_ms", "acs.request_id_ref"] + "conditional_attributes": ["acs.request_id_ref"], + "optional_attributes": ["acs.duration_ms"] }, "steps/knowledgeRetrieval": { "span_name": "acs.knowledge.retrieval", - "required_attributes": ["acs.source.type", "acs.results.count"] + "required_attributes": ["acs.results.count"], + "conditional_attributes": ["acs.source.type"] }, "steps/memoryStore": { "span_name": "acs.memory.store", - "required_attributes": ["acs.memory_store.name", "acs.operation"] + "required_attributes": [], + "conditional_attributes": ["acs.memory_store.name", "acs.operation"] }, "steps/memoryContextRetrieval": { "span_name": "acs.memory.retrieval", - "required_attributes": ["acs.memory_store.name", "acs.results.count"] + "required_attributes": ["acs.results.count"], + "conditional_attributes": ["acs.memory_store.name"] }, "steps/sessionEnd": { "span_name": "acs.session.end", @@ -79,7 +94,7 @@ "steps/postCompact": { "span_name": "acs.compact.complete", "required_attributes": ["acs.compact.entry_count"], - "optional_attributes": ["acs.compact.lineage_depth_after", "acs.provenance.origins_compacted"] + "optional_attributes": ["acs.compact.lineage_depth_after"] }, "steps/subagentStart": { "span_name": "acs.subagent", @@ -87,38 +102,106 @@ }, "steps/subagentStop": { "span_name": "acs.subagent.end", - "required_attributes": ["acs.subagent.session_id", "acs.subagent.outcome", "acs.subagent.final_chain_hash"] + "required_attributes": ["acs.subagent.session_id", "acs.subagent.outcome"], + "conditional_attributes": ["acs.subagent.final_chain_hash"] + }, + "steps/skillRegister": { + "span_name": "acs.skill.register", + "required_attributes": ["acs.skill.id", "acs.skill.digest.algorithm", "acs.skill.digest.value", "acs.skill.declared_capabilities"] + }, + "steps/skillLoad": { + "span_name": "acs.skill.load", + "required_attributes": ["acs.skill.id", "acs.skill.digest.algorithm", "acs.skill.digest.value", "acs.skill.load_trigger", "acs.skill.load_path"], + "optional_attributes": ["acs.skill.digest_verified", "acs.skill.registration_ref"] + }, + "steps/skillUnload": { + "span_name": "acs.skill.unload", + "required_attributes": ["acs.skill.id", "acs.skill.reason"], + "optional_attributes": ["acs.skill.replaced_by"] }, "agbom/snapshot": { "span_name": "acs.agbom", - "required_attributes": ["acs.agbom.format", "acs.agbom.component_count"] + "required_attributes": ["acs.agbom.format", "acs.agbom.component_count"], + "_note": "acs.agbom.format names the AgBOM form the span records. No AgBOM payload carries a format field, because the wire form is always canonical. An exporter recording the payload sets canonical. A Guardian recording a serialization it rendered sets that serialization's agbom_serializations_supported value from the handshake." }, "agbom/changed": { "span_name": "acs.agbom", - "required_attributes": ["acs.agbom.format", "acs.agbom.change_reason"] + "required_attributes": ["acs.agbom.format"], + "conditional_attributes": ["acs.agbom.change_reason"], + "_note": "acs.agbom.format is sourced as on agbom/snapshot." + } + } + }, + "step_span_attributes": { + "type": "object", + "description": "Attributes every step_to_span span carries in addition to its own entry's lists. acs.request_id is the step's own request_id and acs.session.id its session_id, both required on every request envelope. acs.request_id_ref on a steps/toolCallResult span joins to the steps/toolCallRequest span it reports on. The Observed Agent chooses request_id, and it is unique only within a session (Specification §10.3), so a join keys on acs.session.id and acs.request_id together. The Observed Agent and the Guardian may each emit a span for one request, and a reader joins the two. When the Observed Agent retries a step with a new request_id, each attempt is its own request: the Observed Agent emits a step span for each attempt that reached the Guardian, carrying that attempt's request_id. A Guardian event whose pair matches no Observed Agent step span is a discrepancy worth investigating.", + "properties": { + "required_attributes": { + "type": "array", + "items": { "type": "string" }, + "default": ["acs.request_id", "acs.session.id"] } } }, "decision_event": { "type": "object", - "description": "Decisions are recorded as a span event named 'acs.decision' on the parent step span. The event MUST carry the disposition and evaluator; reasoning and confidence are required when present in the decision envelope.", + "description": "Decisions are recorded as a span event named 'acs.decision' on the parent step span. The event MUST carry the disposition. It MUST carry each conditional attribute whenever the response carries the source field, and MUST omit it when the source field is absent. An empty source array counts as absent. The event records the Guardian's verdict. Only the Observed Agent knows the applied outcome. When any acs.decision.failure event in either party's stream carries the same acs.session.id and acs.request_id, a reader MUST NOT count the acs.decision event as an enforced outcome. acs.reason_codes, acs.cited_provenance_ids and acs.chain_hash come from the response fields of the same name. acs.reason_codes is a set: its order carries no meaning, and it does not line up with the policy arrays. acs.policy.ids, acs.policy.versions and acs.policy.rule_ids come from policy_references as three parallel string arrays. Their source field is policy_references itself, so the conditional rule tests its presence, not the presence of the member fields. All three are emitted whenever the response carries a non-empty policy_references, and none is emitted otherwise. Each has the same length and order as policy_references, with an empty string where an entry lacks the field, so a reader can line up a policy, its version and its rule by position. An empty string on the wire could not be told from an absent field, so response-envelope.json rejects one in a policy_references entry. acs.chain_hash is a copy of the response field and proves nothing on its own. The trace carries neither the response signature nor the ContextEntries a verifier needs (Specification §8.2, §8.6), and a Guardian's own trace is no independent witness of its chain head. When the Guardian's and the Observed Agent's streams show different acs.chain_hash values for the same acs.session.id and acs.request_id, that is an integrity event (Specification §8.6).", "properties": { "event_name": { "type": "string", "const": "acs.decision" }, "required_attributes": { "type": "array", "items": { "type": "string" }, - "default": ["acs.decision", "acs.evaluator"] + "default": ["acs.decision"] + }, + "conditional_attributes": { + "type": "array", + "items": { "type": "string" }, + "default": [ + "acs.evaluator", + "acs.reasoning", + "acs.confidence", + "acs.evaluator_version", + "acs.model_id", + "acs.reason_codes", + "acs.policy.ids", + "acs.policy.versions", + "acs.policy.rule_ids", + "acs.cited_provenance_ids", + "acs.chain_hash" + ] + } + } + }, + "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.", + "properties": { + "event_name": { "type": "string", "const": "acs.decision.failure" }, + "required_attributes": { + "type": "array", + "items": { "type": "string" }, + "default": ["acs.failure.class", "acs.failure.posture", "acs.failure.reported_by"] }, "conditional_attributes": { "type": "array", "items": { "type": "string" }, - "default": ["acs.reasoning", "acs.confidence", "acs.evaluator_version", "acs.model_id"] + "default": ["acs.failure.detail", "acs.failure.error_code", "acs.failure.timeout_ms", "acs.failure.elapsed_ms", "acs.failure.payload_bytes"] + }, + "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.", + "additionalProperties": { "type": "array", "items": { "type": "string" } }, + "default": { + "acs.failure.class": ["timeout", "unreachable", "malformed_response", "guardian_error", "observed_agent_error", "signature_invalid", "handshake_failure", "other"], + "acs.failure.posture": ["fail_open", "fail_closed"], + "acs.failure.reported_by": ["observed_agent", "guardian"] + } } } }, "provenance_attributes": { "type": "object", - "description": "When Provenance is attached to a hook payload, the resulting span MUST carry provenance facts as attributes. Lineage edges MAY be linked via OTel span links keyed by provenance_id. v0.1 emits the factual provenance fields (origin, source_id, lineage_depth) on the wire and on the span; trust classification is computed by the Guardian against local policy and is not a v0.1 span attribute (see §7.1).", + "description": "When Provenance is attached to a hook payload, the resulting span MUST carry provenance facts as attributes. Lineage edges MAY be linked via OTel span links keyed by provenance_id. v0.1 emits the factual provenance fields (origin, source_id) on the wire and on the span. Both are string arrays, also for a single Provenance object. acs.provenance.source_id is emitted whenever at least one attached Provenance object has a source_id, and omitted otherwise. When emitted, it has the same length and order as acs.provenance.origin, with an empty string where an object has none. Trust classification is computed by the Guardian against local policy and is not a v0.1 span attribute (see §7.1).", "properties": { "required": { "type": "array", @@ -128,7 +211,7 @@ "optional": { "type": "array", "items": { "type": "string" }, - "default": ["acs.provenance.lineage_depth", "acs.provenance.source_id"] + "default": ["acs.provenance.source_id"] } } } diff --git a/tests/test_hook_taxonomy.py b/tests/test_hook_taxonomy.py index d21ace03..b40b674c 100644 --- a/tests/test_hook_taxonomy.py +++ b/tests/test_hook_taxonomy.py @@ -11,16 +11,66 @@ A reader treating the specification page as canonical never learned that skills are governable, and the omission propagated into implementations. The build stayed green throughout, so only a check that reads the schemas catches it. + +The same drift reached the Trace pillar. The OpenTelemetry and OCSF mapping +tables under `specification/v0.1.0/trace/`, the two docs pages that render +them, and the overview on the ACS page each restate the set, and none of them +gained the skill hooks. A deployment emitting OTel or OCSF from the mapping +files had no span name and no event class for a skill event, so the one hook +that vets a whole skill left no trace. The guards below therefore reach every +surface that restates the set: a file naming eight or more hooks as +`steps/` must name all of them, the mapping tables must key exactly the +schema set, and every hook count stated in the docs must match the schemas. + +Two kinds of page trip those rules on purpose: one that names a hook proposed +for a later version, and one that lists a deliberate subset of eight or more +hooks. Each failure message names its escape hatch, and the two allowlists +below record every exemption with a reason, so a deliberate subset stays +distinguishable from a stale copy in review. """ import json import re from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parent.parent HOOK_SCHEMAS = ROOT / "specification" / "v0.1.0" / "hooks" SPECIFICATION = ROOT / "docs" / "spec" / "instrument" / "specification.md" HOOKS_PAGE = ROOT / "docs" / "spec" / "instrument" / "hooks.md" +DOCS = ROOT / "docs" +TRACE_MAPPINGS = ROOT / "specification" / "v0.1.0" / "trace" +OTEL_MAPPING = TRACE_MAPPINGS / "otel-mapping.json" +OCSF_MAPPING = TRACE_MAPPINGS / "ocsf-mapping.json" + +# A file that names this many distinct hooks as `steps/` is restating +# the set rather than quoting an example. The Trace surfaces name fifteen or +# more; no example snippet in the docs names more than four. +RESTATING_THRESHOLD = 8 + +# Files that deliberately name a subset of RESTATING_THRESHOLD or more hooks as +# `steps/`, such as a table of the hooks ACS-Core gates. Path relative to +# the repo root -> why the subset is intended. Empty by default: an entry lands +# in the same PR as the page that needs it, where a reviewer can judge it. +SUBSET_SURFACES = {} + +# `steps/` references that deliberately name no schema, such as a hook +# proposed for a later version. Path -> {name: reason}. Keyed by file so one +# page's exemption does not hide the same name on another page. +PROPOSED_HOOKS = {} + +# Every place a new hook has to reach. Only some of them are checked below, so +# the omission messages print the whole list rather than only what failed. +NEW_HOOK_CHECKLIST = """ +A new hook reaches all of these in the same PR: + docs/spec/instrument/specification.md section 5 taxonomy row + docs/spec/instrument/hooks.md overview row, `## ` section, count + docs/spec/trace/events.md OTel and OCSF tables + docs/spec/trace/extend_ocsf.md OCSF class table + specification/v0.1.0/trace/*.json step_to_span and step_to_class keys + docs/spec/conformance.md, docs/acs.md ACS-Core or additional-hooks list + README.md native hook count""" # `steps/skillRegister payload` -> "skillRegister". The profile variants title # themselves `... payload (ACS-Provenance profile)` and are the same hook, so @@ -34,6 +84,31 @@ OVERVIEW_ROW = re.compile(r"^\|\s*\[?`([A-Za-z][A-Za-z0-9]*)`") # `ACS v0.1.0 defines 19 native `steps/*` hooks` -> "19" STATED_COUNT = re.compile(r"defines (\d+) native `steps/\*` hooks") +# `| `steps/skillRegister` | `acs.skill.register` |` -> "skillRegister". +# `steps/*` carries no name, so the wildcard on the Hooks page does not match. +# A glob (`steps/toolCall*`) names no single hook, and a path or anchor +# (`/docs/steps/x`, `#steps/x`) is not a method, so the lookarounds skip both. +STEPS_REFERENCE = re.compile(r"(? "16"; `sixteen native lifecycle hooks` -> +# "sixteen"; `19 native `steps/*` hooks` -> "19". "Native" is required, so the +# skill proposal's historical "Sixteen `steps/*` hooks" does not match. +STATED_COUNT_ANYWHERE = re.compile( + r"\b(\d+|" + "|".join(NUMBER_WORDS) + r") native (?:lifecycle |`steps/\*` )?hooks", + re.IGNORECASE, +) +# Where a native hook count may be stated. The proposals under +# `specification/proposals/` record the set as it stood and are left out. +COUNT_SURFACES = sorted(DOCS.rglob("*.md")) + [ROOT / "README.md"] def native_hook_methods(): @@ -47,10 +122,42 @@ def native_hook_methods(): return methods +def restating_surfaces(): + """Every file that may restate the hook set: docs pages and Trace mappings.""" + return sorted(DOCS.rglob("*.md")) + sorted(TRACE_MAPPINGS.glob("*.json")) + + +def steps_references(path): + """Distinct hook names a file references as `steps/`.""" + return set(STEPS_REFERENCE.findall(path.read_text(encoding="utf-8"))) + + +def mapping_keys(path, table): + """The `steps/*` keys of a Trace mapping's default table, prefix stripped. + + The tables also key `agbom/snapshot` and `agbom/changed`. Those are + Inspect-pillar methods that share the request envelope, not hooks, so they + sit outside the set the hook schemas define and are left out here. + """ + mapping = json.loads(path.read_text(encoding="utf-8")) + keys = mapping["properties"][table]["default"] + return {key[len("steps/") :] for key in keys if key.startswith("steps/")} + + +def relative(path): + return path.relative_to(ROOT).as_posix() + + def section(path, heading): """Lines under a `## ` heading, stopping at the next one.""" lines = path.read_text(encoding="utf-8").splitlines() - start = next(i for i, line in enumerate(lines) if line.startswith(heading)) + # A bare next() raises StopIteration, which pytest prints with no hint of + # which heading moved. + start = next((i for i, line in enumerate(lines) if line.startswith(heading)), None) + assert start is not None, ( + f"{relative(path)} has no heading starting {heading!r}. If it was renamed, " + "update the heading this test reads." + ) for offset, line in enumerate(lines[start + 1 :], start=start + 1): if line.startswith("## "): return lines[start + 1 : offset] @@ -70,9 +177,12 @@ def test_specification_taxonomy_lists_every_native_hook(): assert not missing, ( "Specification section 5 omits hooks the schemas define: " + ", ".join(missing) + + NEW_HOOK_CHECKLIST ) assert not unknown, ( - "Specification section 5 lists hooks with no schema: " + ", ".join(unknown) + "Specification section 5 lists hooks with no schema: " + + ", ".join(unknown) + + ". Add the schema under specification/v0.1.0/hooks/ or drop the row." ) @@ -95,10 +205,14 @@ def test_hooks_page_overview_lists_every_native_hook(): missing = sorted(expected - listed) unknown = sorted(listed - expected) assert not missing, ( - "Hooks page overview omits hooks the schemas define: " + ", ".join(missing) + "Hooks page overview omits hooks the schemas define: " + + ", ".join(missing) + + NEW_HOOK_CHECKLIST ) assert not unknown, ( - "Hooks page overview lists hooks with no schema: " + ", ".join(unknown) + "Hooks page overview lists hooks with no schema: " + + ", ".join(unknown) + + ". Add the schema under specification/v0.1.0/hooks/ or drop the row." ) @@ -107,5 +221,107 @@ def test_hooks_page_states_the_real_hook_count(): assert match, "Hooks page no longer states a native `steps/*` hook count" expected = len(native_hook_methods()) assert int(match.group(1)) == expected, ( - f"Hooks page claims {match.group(1)} native hooks; the schemas define {expected}" + f"Hooks page claims {match.group(1)} native hooks; the schemas define " + f"{expected}. Update the count on line 3 and in the diagram." + ) + + +def test_every_steps_reference_names_a_schema_hook(): + expected = native_hook_methods() + unknown = {} + for path in restating_surfaces(): + proposed = PROPOSED_HOOKS.get(relative(path), {}) + stray = sorted(steps_references(path) - expected - proposed.keys()) + if stray: + unknown[relative(path)] = stray + assert not unknown, ( + "Files reference `steps/` methods no schema defines:\n" + + "\n".join(f" {file}: {', '.join(hooks)}" for file, hooks in unknown.items()) + + "\nFix the typo, add the schema, or, for a hook proposed for a later " + "version, add it to PROPOSED_HOOKS in this file with a reason." + ) + + +def test_restating_surfaces_list_every_native_hook(): + expected = native_hook_methods() + missing = {} + for path in restating_surfaces(): + if relative(path) in SUBSET_SURFACES: + continue + listed = steps_references(path) + if len(listed) >= RESTATING_THRESHOLD and expected - listed: + missing[relative(path)] = sorted(expected - listed) + assert not missing, ( + f"Files naming {RESTATING_THRESHOLD} or more hooks as `steps/` are " + "treated as restating the set, and these omit hooks the schemas define:\n" + + "\n".join(f" {file}: {', '.join(hooks)}" for file, hooks in missing.items()) + + "\nAdd the missing hooks. If the file lists a subset on purpose, add it " + "to SUBSET_SURFACES in this file with a reason, or write the subset " + "without the `steps/` prefix." + + NEW_HOOK_CHECKLIST + ) + + +def test_allowlist_entries_carry_a_reason_and_are_still_needed(): + expected = native_hook_methods() + stale = [] + for file, reason in SUBSET_SURFACES.items(): + if not (ROOT / file).is_file() or not reason.strip(): + stale.append(f"SUBSET_SURFACES {file}: missing file or empty reason") + for file, names in PROPOSED_HOOKS.items(): + path = ROOT / file + for name, reason in names.items(): + if not path.is_file() or not reason.strip(): + stale.append(f"PROPOSED_HOOKS {file} {name}: missing file or empty reason") + elif name in expected or name not in steps_references(path): + # Once the hook ships, or the page drops it, the exemption only + # hides the next mistake. + stale.append(f"PROPOSED_HOOKS {file} {name}: no longer needed") + assert not stale, "Remove or fix these allowlist entries:\n " + "\n ".join(stale) + + +@pytest.mark.parametrize( + "path, table", + [(OTEL_MAPPING, "step_to_span"), (OCSF_MAPPING, "step_to_class")], + ids=["otel", "ocsf"], +) +def test_trace_mapping_keys_exactly_the_native_hooks(path, table): + keyed = mapping_keys(path, table) + expected = native_hook_methods() + + missing = sorted(expected - keyed) + unknown = sorted(keyed - expected) + assert not missing, ( + f"{path.name} {table} omits hooks the schemas define: " + + ", ".join(missing) + + NEW_HOOK_CHECKLIST + ) + assert not unknown, ( + f"{path.name} {table} keys hooks with no schema: " + + ", ".join(unknown) + + ". Add the schema under specification/v0.1.0/hooks/ or drop the key." + ) + + +def test_every_docs_page_states_the_real_hook_count(): + expected = len(native_hook_methods()) + stated = 0 + wrong = {} + for path in COUNT_SURFACES: + for match in STATED_COUNT_ANYWHERE.finditer(path.read_text(encoding="utf-8")): + word = match.group(1).lower() + count = NUMBER_WORDS.get(word) or int(word) + # "These 2 native hooks" counts a subset, never the whole set, which + # has always held more than RESTATING_THRESHOLD hooks. + if count < RESTATING_THRESHOLD: + continue + stated += 1 + if count != expected: + wrong.setdefault(relative(path), []).append(match.group(1)) + assert stated, "No docs page states a native hook count; the prose has moved" + assert not wrong, ( + f"Pages claim a native hook count other than the {expected} the schemas define:\n" + + "\n".join(f" {file}: {', '.join(counts)}" for file, counts in wrong.items()) + + f"\nUpdate the count. To count a subset, write it as \"9 of the {expected} " + "native hooks\" so the total stays checked." ) diff --git a/tests/test_trace_docs_example.py b/tests/test_trace_docs_example.py new file mode 100644 index 00000000..fe7e075b --- /dev/null +++ b/tests/test_trace_docs_example.py @@ -0,0 +1,56 @@ +"""The example span on the Trace with OpenTelemetry page is real exporter output. + +The page shows one span from the committed sample trace +(reference-implementations/agt/samples/trace/acs-trace-sample.jsonl), so a +reader sees what the reference Guardian actually emits. An example copied by +hand drifts the first time the exporter or the mapping changes, so this test +requires it to match the sample's span with the same acs.request_id, once +the values that differ between runs (ids and timestamps) are set aside. When +the sample is regenerated, the example has to be updated with it. +""" + +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PAGE = ROOT / "docs" / "spec" / "trace" / "extend_opentelemetry.md" +SAMPLE = ROOT / "reference-implementations" / "agt" / "samples" / "trace" / "acs-trace-sample.jsonl" +HEADING = "## Example: a denied tool call" +VOLATILE = {"traceId", "spanId", "parentSpanId", "startTimeUnixNano", "endTimeUnixNano", "timeUnixNano"} + + +def _strip(value): + if isinstance(value, dict): + return {k: _strip(v) for k, v in value.items() if k not in VOLATILE} + if isinstance(value, list): + return [_strip(v) for v in value] + return value + + +def _request_id(span): + for attribute in span.get("attributes", []): + if attribute["key"] == "acs.request_id": + return attribute["value"]["stringValue"] + return None + + +def _sample_spans(): + for line in SAMPLE.read_text(encoding="utf-8").splitlines(): + if line.strip(): + for resource in json.loads(line)["resourceSpans"]: + for scope in resource["scopeSpans"]: + yield from scope["spans"] + + +def test_docs_example_span_matches_the_committed_sample(): + text = PAGE.read_text(encoding="utf-8") + assert HEADING in text, f"{PAGE.name} has no '{HEADING}' section" + block = re.search(r"```json\n(.*?)```", text[text.index(HEADING):], re.S) + assert block, f"the '{HEADING}' section has no JSON example" + example = json.loads(block.group(1)) + + request_id = _request_id(example) + matches = [span for span in _sample_spans() if _request_id(span) == request_id] + assert len(matches) == 1, f"the sample has no single span with acs.request_id {request_id}" + assert _strip(example) == _strip(matches[0]), "the docs example no longer matches the sample; copy the span from the regenerated sample" diff --git a/tests/test_trace_mapping_defaults.py b/tests/test_trace_mapping_defaults.py new file mode 100644 index 00000000..b99314dc --- /dev/null +++ b/tests/test_trace_mapping_defaults.py @@ -0,0 +1,174 @@ +"""The Trace mappings keep their data in `default` keys, and nothing checked it. + +`otel-mapping.json` and `ocsf-mapping.json` are JSON Schemas whose normative +tables sit in `default`. `test_publish_schemas.py` checks each file against the +JSON Schema metaschema, which never looks inside `default`. A misspelled list +name such as `conditonal_attributes` therefore passed every check, and both +readers (the conformance checker and the trace exporter) skip keys they do not +know, so the attribute it held stopped being emitted with nothing turning red. + +These tests validate every `default` against the schema it sits in, reject +keys an entry schema does not declare, and hold each `step_to_span` entry to +one list per attribute. +""" +import json +from pathlib import Path + +import pytest +from jsonschema import Draft202012Validator + +TRACE = Path(__file__).resolve().parents[1] / "specification" / "v0.1.0" / "trace" +MAPPINGS = sorted(TRACE.glob("*.json")) +ATTRIBUTE_LISTS = ("required_attributes", "conditional_attributes", "optional_attributes") + + +def nodes_with_defaults(node, path="#"): + """Every schema node under `node` that carries a `default`, with its path.""" + if isinstance(node, dict): + if "default" in node: + yield path, node + for key, child in node.items(): + if key != "default": + yield from nodes_with_defaults(child, f"{path}/{key}") + + +@pytest.mark.parametrize("path", MAPPINGS, ids=lambda p: p.name) +def test_every_default_validates_against_its_own_schema(path): + mapping = json.loads(path.read_text(encoding="utf-8")) + checked = 0 + for pointer, node in nodes_with_defaults(mapping): + schema = {key: value for key, value in node.items() if key != "default"} + errors = sorted(Draft202012Validator(schema).iter_errors(node["default"]), key=str) + assert not errors, f"{path.name} {pointer}: " + "; ".join(error.message for error in errors) + checked += 1 + # A refactor that moves the tables out of `default` would otherwise turn + # this into a test of nothing. + assert checked >= 4, f"{path.name}: found only {checked} defaults to check" + + +def test_every_step_span_entry_uses_only_known_keys(): + # Checked here, not by closing the published schema with + # additionalProperties: false, which would change what a deployment may + # publish. A misspelled list name fails this instead. + mapping = json.loads((TRACE / "otel-mapping.json").read_text(encoding="utf-8")) + entry_schema = mapping["properties"]["step_to_span"]["additionalProperties"] + known = set(entry_schema["properties"]) + unknown = { + method: sorted(set(entry) - known) + for method, entry in mapping["properties"]["step_to_span"]["default"].items() + if set(entry) - known + } + assert not unknown, f"step_to_span entries with keys the entry schema does not declare: {unknown}" + + +def test_no_attribute_sits_in_two_lists_of_one_step_span(): + mapping = json.loads((TRACE / "otel-mapping.json").read_text(encoding="utf-8")) + overlaps = {} + for method, entry in mapping["properties"]["step_to_span"]["default"].items(): + seen = {} + for name in ATTRIBUTE_LISTS: + for attribute in entry.get(name, []): + if attribute in seen: + overlaps.setdefault(method, []).append(f"{attribute} in {seen[attribute]} and {name}") + seen[attribute] = name + assert not overlaps, f"attributes listed twice: {overlaps}" + + +# Every conditional attribute and the wire field it comes from. The +# conformance checker's "every required row is emittable" test covers only the +# six methods the reference Guardian evaluates, so this one holds the other +# fifteen: moving an attribute back to required, or a schema making its +# source required, fails here. +CONDITIONAL_SOURCES = { + ("steps/toolCallRequest", "acs.capability"): ("tool-call-request.json", "capability"), + ("steps/toolCallResult", "acs.request_id_ref"): ("tool-call-result.json", "request_id_ref"), + ("steps/knowledgeRetrieval", "acs.source.type"): ("knowledge-retrieval.json", "source"), + ("steps/memoryStore", "acs.memory_store.name"): ("memory-store.json", "memory_store"), + ("steps/memoryStore", "acs.operation"): ("memory-store.json", "operation"), + ("steps/memoryContextRetrieval", "acs.memory_store.name"): ("memory-context-retrieval.json", "memory_store"), + ("steps/subagentStop", "acs.subagent.final_chain_hash"): ("subagent-stop.json", "final_chain_hash"), + ("agbom/changed", "acs.agbom.change_reason"): ("agbom-changed.json", "reason"), +} + + +def test_every_conditional_attribute_stays_conditional_and_its_source_stays_optional(): + table = json.loads((TRACE / "otel-mapping.json").read_text(encoding="utf-8"))["properties"]["step_to_span"]["default"] + listed = {(method, attribute) for method, entry in table.items() for attribute in entry.get("conditional_attributes", [])} + assert listed == set(CONDITIONAL_SOURCES), f"conditional lists drifted: {sorted(listed ^ set(CONDITIONAL_SOURCES))}" + for (method, attribute), (hook, field) in CONDITIONAL_SOURCES.items(): + payload = json.loads((TRACE.parent / "hooks" / hook).read_text(encoding="utf-8")) + assert field in payload["properties"], f"{hook} has no {field}, so {attribute} on {method} has no source" + assert field not in payload.get("required", []), ( + f"{hook} now requires {field}, so {attribute} on {method} belongs in required_attributes" + ) + + +def test_ocsf_decision_tables_match_the_wire(): + """The disposition and severity tables key exactly the five dispositions, + acs_fields names only real response fields, and every enumerated failure + attribute is one the failure event lists. A typo in any of them used to + pass every check.""" + acs_result = json.loads((TRACE.parent / "response-envelope.json").read_text(encoding="utf-8"))["$defs"]["AcsResult"]["properties"] + ocsf = json.loads((TRACE / "ocsf-mapping.json").read_text(encoding="utf-8"))["properties"] + dispositions = set(acs_result["decision"]["enum"]) + assert set(ocsf["decision_class"]["properties"]["disposition_mapping"]["default"]) == dispositions + assert set(ocsf["severity_mapping"]["default"]) == dispositions + assert set(ocsf["decision_class"]["properties"]["acs_fields"]["default"]) <= set(acs_result) + assert ocsf["decision_class"]["properties"]["join_fields"]["default"] == ["request_id", "session_id"] + failure = json.loads((TRACE / "otel-mapping.json").read_text(encoding="utf-8"))["properties"]["decision_failure_event"]["properties"] + listed = set(failure["required_attributes"]["default"]) | set(failure["conditional_attributes"]["default"]) + assert set(failure["attribute_values"]["default"]) <= listed + + +def test_ocsf_decision_codes_match_the_answered_proposal(): + # The Spec leads fixed these values on issue #228. Defer is 99, not 14 + # (Delayed), because an ACS defer holds the step and denies on timeout; + # modify gets action_id 4 because disposition_id has no value for it. + decision = json.loads((TRACE / "ocsf-mapping.json").read_text(encoding="utf-8"))["properties"]["decision_class"]["properties"] + assert decision["disposition_mapping"]["default"] == {"allow": 1, "deny": 2, "modify": 99, "ask": 99, "defer": 99} + assert decision["action_mapping"]["default"] == {"allow": 1, "deny": 2, "modify": 4, "ask": 99, "defer": 99} + assert decision["alert_mapping"]["default"] == {"allow": False, "deny": True, "modify": False, "ask": True, "defer": True} + severity = json.loads((TRACE / "ocsf-mapping.json").read_text(encoding="utf-8"))["properties"]["severity_mapping"]["default"] + assert {d: severity[d] >= 3 for d in severity} == decision["alert_mapping"]["default"], "is_alert must be true exactly when severity_id is 3 or higher" + assert decision["required_fields"]["default"] == [ + "finding_info", "finding_info.title", "action_id", "action", "disposition_id", "disposition", "evidences", "metadata.correlation_uid", + ] + assert decision["late_decision"]["properties"]["finding_types"]["default"] == [ + "late_decision_fail_open", "late_decision_fail_closed", + ] + failure = json.loads((TRACE / "otel-mapping.json").read_text(encoding="utf-8"))["properties"]["decision_failure_event"]["properties"] + failure_fields = {name.removeprefix("acs.failure.") for name in failure["required_attributes"]["default"] + failure["conditional_attributes"]["default"]} + assert set(decision["late_decision"]["properties"]["failure_fields"]["default"]) <= failure_fields + + +def test_padded_source_fields_reject_the_empty_string(): + # Trace pads the policy and provenance arrays with "" where a value is + # absent, so "" must never be a legal wire value for these fields (#263). + spec = TRACE.parent + policy = json.loads((spec / "response-envelope.json").read_text(encoding="utf-8"))["$defs"]["AcsResult"]["properties"]["policy_references"]["items"]["properties"] + for field in ("policy_id", "policy_version", "policy_name", "rule_id"): + assert policy[field].get("minLength") == 1, f"policy_references.{field} accepts an empty string" + provenance = json.loads((spec / "provenance.json").read_text(encoding="utf-8"))["properties"] + assert provenance["source_id"].get("minLength") == 1, "provenance source_id accepts an empty string" + + +def test_every_step_to_class_entry_uses_only_known_keys(): + # The OCSF twin of the step_to_span key check: a typo such as activty_id + # would otherwise drop the activity and change type_uid with every check + # green. Checked here so the published schema stays open. + ocsf = json.loads((TRACE / "ocsf-mapping.json").read_text(encoding="utf-8"))["properties"]["step_to_class"] + known = set(ocsf["additionalProperties"]["properties"]) + unknown = {method: sorted(set(entry) - known) for method, entry in ocsf["default"].items() if set(entry) - known} + assert not unknown, f"step_to_class entries with keys the entry schema does not declare: {unknown}" + + +def test_failure_classes_are_the_ones_decided_on_issue_37(): + # Bar's answer on issue #37 (2026-10-13) added signature_invalid, so a + # tampered response stands out from organic noise, and handshake_failure, + # so a session that starts unguarded is counted. PR #273's sessionEnd + # count keys on this exact set. + failure = json.loads((TRACE / "otel-mapping.json").read_text(encoding="utf-8"))["properties"]["decision_failure_event"]["properties"] + assert set(failure["attribute_values"]["default"]["acs.failure.class"]) == { + "timeout", "unreachable", "malformed_response", "guardian_error", + "observed_agent_error", "signature_invalid", "handshake_failure", "other", + } diff --git a/uv.lock b/uv.lock index 9b2bdb68..dd6b15db 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "acs" -version = "0.1.3" +version = "0.1.4" source = { virtual = "." } dependencies = [ { name = "mike" }, @@ -760,11 +760,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] diff --git a/version.txt b/version.txt index b1e80bb2..845639ee 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.1.3 +0.1.4