Skip to content

About

Shared Go contract (Agent Passport + agent-event envelope) for the TAIPANBOX agent-governance stack

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

agent-stack-go - Shared Go Contract

One Go module for Agent Passport identity and the agent-event NDJSON envelope, so every service in the stack speaks the same wire language instead of reimplementing it.

CI Go Reference Go tests License Status

agent-stack-go: the passport, event and chain packages compose one shared contract, imported by tag by Idryx, Wardryx, Mockryx, Qryx, heraldyx, scopyx, terraform-provider-taipan, vouchryx and CostCrew

agent-stack-go is the public, importable home of the wire types every Go service in the TAIPANBOX agent-governance stack (Wardryx, Mockryx, and future siblings) needs to speak the same identity and event language as TokenFuse, Idryx, Qryx, and Engram. Idryx's equivalents live under internal/ and cannot be imported outside that repo, which is why this module exists: one shared source, not six drifting copies.

The stack this module supports is a defensive, self-protection system: it exists so an organization running AI agents can govern and audit its own agents, never to attack, surveil, or act against anyone else.


Where this fits in the stack

agent-stack-go is the shared-contract plane of the TAIPANBOX agent-governance stack: the Go bindings for Agent Passport identity and the agent-event NDJSON envelope that the stack's other Go services import instead of reimplementing.

flowchart TB
  Agent["AI agent (any framework)"] -->|"LLM call (base-URL swap)"| TF["TokenFuse proxy: spend + enforcement"]
  TF -->|"POST /v1/decide (PEP)"| WX["Wardryx: policy PDP"]
  WX -.->|"allow / deny / hold"| TF
  TF -->|"cheapest model, budget OK"| LLM[("LLM provider")]
  TF -->|"CallRecords"| CL["TokenFuse Cloud: control plane, incidents, replay, evidence, kill-switch"]
  VCX["Vouchryx: delegation proved, and endable"] -->|"short-lived token: act + cnf"| TF
  TF -.->|"polls /v1/revocations"| VCX
  VCX ==>|"delegation_issued / denied / revoked"| BUS
  TF ==>|"agent-event NDJSON"| BUS{{"agent-event bus + Agent Passport"}}
  WX ==> BUS
  Agent -->|"web fetch"| SCX["Scopyx: governed web egress"]
  SCX -->|"POST /v1/decide"| WX
  SCX ==>|"web_fetch / web_blocked"| BUS
  ENG["Engram: memory"] -->|"reflect via base_url"| TF
  ENG ==> BUS
  BUS ==> IDX["Idryx: identity graph, detectors, Agent-BOM"]
  IDX ==>|"identity_finding"| BUS
  BUS ==> QX["Qryx: crypto / PQC, passport + hash-chain scan"]
  QX ==>|"crypto events"| BUS
  BUS ==> VX["Verdryx: quality / drift"]
  VX ==>|"quality events"| BUS
  TF -->|"outcome-tagged traces"| VX
  MX["Mockryx: pre-prod safety rehearsal"] -->|"hostile scenarios"| TF
  MX ==>|"sim events"| BUS
  BILL[("cloud, SaaS and model bills")] --> CC["CostCrew: the bill, worked by a crew of agents"]
  CC ==>|"spend_spike / budget_threshold / crew moves"| BUS
  BUS ==> TRX["Trailryx: the record plane, sealed and packed"]
  BUS ==> HX["reads the log, mails you (heraldyx)"]
  HX -->|"one mail, a view and never an action"| OPS["your mailbox"]
  HX ==>|"alert_sent"| HJ[("heraldyx's own hash-chained journal, not this bus")]
  YOU(["you, in a browser over your own tunnel"]) --> GX[["Genaryx: the console over all of it"]]
  GX -->|"signed commands: the kill, an approval, a policy"| CL
  GX -->|"signed commands"| WX
  GX ==>|"console_command"| BUS
  GX -.->|"reads it"| IDX
  GX -.->|"reads it"| QX
  GX -.->|"reads it"| VX
  GX -.->|"reads it"| MX
  GX -.->|"reads it"| ENG
  GX -.->|"reads it"| SCX
  GX -.->|"reads it"| CC
  TFP["terraform-provider-taipan"] -->|"budgets + passports as code"| CL
  ASG[["agent-stack-go: shared Go contract"]] -.->|imported by| IDX
  ASG -.->|imported by| WX
  ASG -.->|imported by| MX
  ASG -.->|imported by| TFP
  ASG -.->|imported by| HX
  ASG -.->|imported by| QX
  SPEC[["agent-passport: the spec"]] -.->|governs| BUS
Loading
  • Consumes: the agent-passport spec, which its passport and event packages conform to (each checked by its own schema conformance test, against the canonical schema rather than against a copy of our own beliefs).
  • Produces: shared Go types for the Agent Passport document, the agent-event NDJSON envelope, and delegation-chain validation.
  • Talks to: imported by Idryx, Wardryx, Mockryx, Qryx, heraldyx, scopyx, terraform-provider-taipan, vouchryx and CostCrew, so all nine speak the same identity and event language as TokenFuse and Engram, which reach it through their own languages.

The full stack is TokenFuse (spend), Wardryx (policy), Vouchryx (delegation), Engram (memory), Idryx (access), Qryx (crypto), Verdryx (quality), Mockryx (pre-prod), scopyx (governed web egress), CostCrew (the bill), Trailryx (the record) and heraldyx (the mail out), on the shared Agent Passport + agent-event contract (agent-stack-go / agent-passport), configured via terraform-provider-taipan and driven from Genaryx, the console over all of it.

Run the whole open stack locally with one command via stack-up; the stack's home on the web is it-rat.com.


The shared contract

The event envelope's required and optional fields, the Passport identity and runtime fields, the Attestation binding, and the chain package's delegation helpers

Four packages, one contract, stdlib plus two direct dependencies: github.com/gowebpki/jcs (the RFC 8785/JCS canonicalization the prev_hash integrity chain hashes over in event; canonical JSON is precisely the kind of wheel not to hand-roll) and github.com/santhosh-tekuri/jsonschema/v6 (JSON Schema validation, used by the agent-conform command and the conformance tests, never by a library package):

Package Wire schema What it defines
passport taipanbox.dev/agent-passport/v0.1 and /v1.0 (v1.0 closes the document's top level, SPEC 6.4.1; Parse accepts both) the Agent Passport document: identity, owner, runtime, provisioning parent, attestation posture
event taipanbox.dev/agent-event/v1.0 (v0.1, v0.2 and v0.3 still accepted, SPEC 6.4.1) the agent-event NDJSON envelope, plus an append-only Writer, tolerant Scan/ReadFile readers, and the ChainedWriter/VerifyChain SPEC 6.5 prev_hash integrity chain (Canonicalize/ChainHash)
chain n/a (a v0.2 normative rule) delegation-chain helpers: acyclic, root-first, capped at chain.MaxDepth (32) entries
delegation RFC 8693 + RFC 9449 + RFC 9068 the delegation TOKEN: signing, offline verification against a held key set, DPoP sender-constraint, and the act chain. And an agent's OWN access token (typ at+jwt): VerifyAccess checks it and its proof with tokenfuse's rules (exact aud required, 30 s leeway, ath), VerifyRelayedAccess checks one relayed without its proof, and ReplayCache makes a proof single use across the doors of a process; the two languages run one shared table of cases, delegation/testdata/agent-token-vectors.json. The DPoP replay cache is in memory, bounded by twice the proof freshness window in the worst case, and a restart forgets it, so a captured proof could be replayed once in the window right after. vouchryx issues with it, and Revocations is the revocation cache an enforcement point fills Options.Revoked from. No enforcement point checks with it yet: nothing in the estate calls Verify on a request path, and wiring the doors is its own wave. Standard library only

event.Event - the agent-event envelope

Field JSON key Type Required Notes
Schema schema string yes one of SchemaV01, SchemaV02, SchemaV03 or SchemaV10
TS ts string yes timestamp, not shape-validated by Unmarshal
Source source string yes the emitting service
Type type string yes the event type
AgentID agent_id string yes agent:// URI of the acting agent
Severity severity string no info · low · medium · high · critical
RunID run_id string no correlates events within one run
OnBehalfOf on_behalf_of []string no the delegation chain (see package chain)
Data data map[string]any no the event payload
PrevHash prev_hash string no the SPEC §6.5 integrity-chain link; stamped by ChainedWriter, verified by VerifyChain

Unmarshal returns a sentinel error (ErrMissingSchema, ErrMissingTS, ErrMissingSource, ErrMissingType, ErrMissingAgentID) for any missing required field, checkable with errors.Is. Struct fields are declared in wire order, so json.Marshal's output matches the Rust (tokenfuse-core::agent_event) and Python (engram.events) exporters shipping elsewhere in the stack.

The prev_hash integrity chain (SPEC §6.5)

ChainedWriter is Writer plus the chain: every appended event carries sha256: + hex(sha256(C)) of the PREVIOUS event, where C is its RFC 8785 (JCS) canonical serialization with the prev_hash field removed. One file is one chain, whoever writes it: every Write takes an exclusive flock(2) on the file, reads the last line actually on disk, stamps prev_hash from that line, appends and only then unlocks. So a process restart resumes the chain, and so do writers that share one file at the same time: a pod and its replacement during a rollout, a service and its CronJob, several goroutines. A writer killed mid-line leaves a fragment the next event is not glued onto (the fragment is one malformed line and the event starts a fresh chain, a restart and not a break), and an unreadable or malformed tail starts fresh, fail-open, with a verifier showing the restart honestly. On a Linux NFS share (a Kubernetes RWX volume) the kernel arbitrates the lock at the server, so it holds across nodes; on Windows there is no cross-process lock, only the re-read. Canonicalize/ChainHash are the exported primitives; VerifyChain walks a stream and reports genuine breaks separately from legal restarts and unverifiable links (a rotated segment's first line, or the line after a malformed one). agent-conform -chain <file> runs the same verification from the CLI, alongside the on_behalf_of delegation check described under agent-conform: two different chains, reported apart. The chain is tamper-EVIDENCE, not tamper-proof: whole-file rewrites can re-chain; partial edits, truncation and reordering no longer pass silently. Cross-language pinned vectors live in event/testdata/chain-vectors.json; the Rust (tokenfuse) and Python (engram, verdryx) emitters pin the same bytes.

passport.Passport - the Agent Passport document

Field JSON key Type Required Notes
Schema schema string yes must equal RequiredSchema
ID id string yes agent:// URI, checked by ValidateAgentURI
Owner owner string yes the owning team or human
DisplayName display_name string no
Runtime runtime string no
Parent parent string no static provisioning parent
Attestation attestation *Attestation no how the id is bound to a workload
Labels labels map[string]string no
CreatedAt created_at string no

Attestation.Method is one of none · oidc · spiffe-svid · enclave-key · mtls-cert · dpop-key; Attestation.Detail is a method-specific reference (a SPIFFE ID, an issuer URL, an RFC 7638 key thumbprint, …). The enum itself lives in the vendored schema, which scripts/schemas-in-sync.sh holds byte-identical to agent-passport's copy; this line is a mirror of it and the schema is what validates.

Function Signature Behavior
LoadDir LoadDir[T any](dirOrGlob string, parse func([]byte) (T, error), id func(T) string) ([]T, Report, error) reads every file under a directory/glob/literal path in sorted order, decoding each with parse; a file that fails parse is counted in Report.Malformed and skipped, never fatal; duplicate id keys keep the first occurrence in sorted-path order

LoadDir is generic over the parsed type, not tied to Passport: pass passport.Parse and an ID-extractor for a batch of Passport documents (the shape Wardryx's internal/passports and Idryx's internal/ingest/passport both need), or any other func([]byte) (T, error) for a different kind of file batch.

chain - delegation-chain helpers

Function Signature Behavior
Append Append(chain []string, principal string) ([]string, error) returns a new chain with principal appended; never mutates the input; ErrCycle if already present, ErrTooDeep past MaxDepth
Validate Validate(chain []string) error checks acyclic, ≤ MaxDepth (32), every entry an agent:// or user:// URI

A nil or empty chain is valid: per the spec, it means the agent acts autonomously.

The cap counts entries, and the root is one of them. SPEC 5.1 reads "Maximum chain depth is 32 entries", so a chain that starts with a human has room for 31 agents after them. Package delegation builds the chain out of an RFC 8693 token, where the subject is deliberately not an actor, so it holds the same number as delegation.MaxActorsWithSubject and derives it rather than retyping it. A producer that applies the 32 to the actor list alone emits a 33-entry chain that Validate, both envelope schemas and agent-conform all refuse.


Install

go get github.com/TAIPANBOX/agent-stack-go/v2@v2.0.0

Pin to a tagged release, not to @latest and never to a local replace (see Versioning).

The agent-conform binary, without a Go toolchain

That line above serves anybody already writing Go. It served nobody who simply wanted to check a payload once, which is the more common reason to touch this repository at all: agent-conform decides whether a passport or an event stream conforms, and needing to install a compiler to run a checker is backwards.

Prebuilt binaries for Linux and macOS on x86_64 and arm64, and for Windows on x86_64, are published on the Releases page for every v* tag, with a SHA256SUMS beside them.

The asset names carry no version, so releases/latest/download/<name> is a permanent address for the current build. You never look up a version number, and a link to one of these does not rot.

P=$(uname -s | tr A-Z a-z)_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
U=https://github.com/TAIPANBOX/agent-stack-go/releases/latest/download

curl -fsSLO $U/agent-conform_$P.tar.gz
curl -fsSLO $U/SHA256SUMS
sha256sum -c SHA256SUMS --ignore-missing

tar -xzf agent-conform_$P.tar.gz
./agent-conform_$P/agent-conform -version
./agent-conform_$P/agent-conform passport.json
./agent-conform_$P/agent-conform -chain events.ndjson

The version is still there, in the binary rather than in the filename: -version prints the tag it was built from. That is the harder of the two places to fake, since anything between us and you can rename a file.

The two paths give the same bytes, and you can check that

Downloading the binary and building it yourself are not a choice between trust and effort. They produce an identical file, so you can take the fast path and still have somebody verify it afterwards. That matters more here than in most places: this tool's output is a verdict about your system, and a verdict is worth what its checker is worth.

# ONE tag on both sides. `agent-conform -version` prints the one you have.
V=v0.5.0

# ours, unpacked ANYWHERE EXCEPT the checkout (see the warning below)
mkdir -p /tmp/verify && cd /tmp/verify
curl -fsSLO https://github.com/TAIPANBOX/agent-stack-go/releases/download/$V/agent-conform_darwin_arm64.tar.gz
tar -xzf agent-conform_darwin_arm64.tar.gz

# yours, from a clean tree
cd /path/to/your/agent-stack-go
git checkout $V
git status --porcelain      # must print nothing at all
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath \
  -ldflags "-s -w -X main.version=$V" -o /tmp/verify/mine ./cmd/agent-conform

sha256sum /tmp/verify/mine \
  /tmp/verify/agent-conform_darwin_arm64/agent-conform
# macOS ships shasum -a 256 rather than sha256sum, and this example builds for
# darwin, so that is probably the one you want

Two identical digests. Measured on 5 August 2026 from a fresh clone against the real v0.5.0 assets, cross-compiled on an Ubuntu runner and rebuilt on macOS, and rebuilt again on 6 August 2026 with Go 1.26.5 to the same value: fe8ccb5e1606129818981bfd6a7369a5a9dbc640bbec40640762d8d01c888ff5.

Both sides name one tag, and that is why $V is in the recipe rather than latest. The download line used to say releases/latest/download, which was true for about an hour: v0.5.0 and v0.5.1 were tagged the same day, latest moved, and the recipe went on telling you to rebuild v0.5.0 and compare it against the v0.5.1 archive. Those two binaries differ by construction, since -X main.version stamps the tag into the bytes, so anybody following it got a mismatch and a good reason to think we were lying. latest is the right address for INSTALLING, where you want whatever is current; it is the wrong one for VERIFYING, where the whole job is comparing like with like. The digest above is v0.5.0 because that is the pair that was measured end to end. Set V to the tag you actually installed and the recipe holds; the number it prints is then yours to compare, not one to look up here.

Build from a clean tree, and do not unpack our archive inside it. Go stamps vcs.modified into the binary, and one untracked file anywhere in the checkout flips it to true, which changes the bytes. The release is built from a clean CI checkout, so it carries vcs.modified=false. Unpacking the download into the repository before building is enough to break the comparison on its own: measured, fe8ccb5e… clean versus ae64cf9b… with a single untracked file beside it. git status --porcelain is in the recipe for that reason and is not decoration.

The same trap has a second door. Building from a git archive extraction or a detached git worktree leaves Go unable to read the VCS at all, so it stamps no revision and records the module as (devel). Both doors lead to a binary that legitimately differs from the release, and the difference looks enormous because a version string one byte shorter shifts everything after it. It is one field, not a different program.

Compare the binaries, not the archives. SHA256SUMS on the release page lists the .tar.gz and .zip files, and it answers a different question: did your download arrive intact. It cannot answer this one, because tar and gzip stamp times into the archive, so the archive is not reproducible even when every byte of the binary inside it is. An earlier version of this recipe said to compare mine against SHA256SUMS directly. Anybody who followed it got two digests that could never match and a good reason to think we were lying.

Three flags make that work, CGO_ENABLED=0, -trimpath and -s -w, and losing any one would break it silently: the build would still succeed and only somebody trying to verify us would find out. So on every push CI does two things (scripts/reproducible-build.sh): it reads the build command out of the release workflow and refuses if any of the three flags is missing there, and it builds the same source in two directories of different lengths and refuses if a byte differs. The first of those exists because the flags have to agree in two files, and until 5 August 2026 only one of the two was ever checked. The same three flags were measured against real published artifacts in the sibling repositories qryx and idryx on 5 August 2026, each rebuilding to its release byte for byte from a different host OS.

A different Go version will not reproduce these bytes either. go.mod pins the toolchain, and a digest is only meaningful beside the compiler that made it.

Verify a download

Every release is signed keyless with Sigstore and carries a build-provenance attestation and an SBOM. With cosign and gh installed:

tag=<tag>
cosign verify-blob --bundle SHA256SUMS.sigstore.json \
  --certificate-identity "/TAIPANBOX/agent-stack-go/.github/workflows/release.yml@refs/tags/${tag}" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com SHA256SUMS
sha256sum -c SHA256SUMS
gh attestation verify agent-conform_darwin_arm64.tar.gz -R TAIPANBOX/agent-stack-go

Releases through v1.0.0 have none of this; the tags say so.

Usage

package main

import (
	"fmt"

	"github.com/TAIPANBOX/agent-stack-go/v2/event"
	"github.com/TAIPANBOX/agent-stack-go/v2/passport"
)

func main() {
	e := event.Event{
		Schema:   event.SchemaV02,
		TS:       "2026-07-09T03:12:44.100Z",
		Source:   "wardryx",
		Type:     "policy_deny",
		AgentID:  "agent://acme-bank.example/support/tier1-bot",
		Severity: event.SeverityHigh,
		RunID:    "run-8842",
		Data:     map[string]any{"reason": "budget_exceeded"},
	}
	line, err := event.Marshal(e)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(line))

	p, err := passport.Parse([]byte(`{
		"schema": "taipanbox.dev/agent-passport/v0.1",
		"id": "agent://acme-bank.example/support/tier1-bot",
		"owner": "user://acme-bank.example/support-team"
	}`))
	if err != nil {
		panic(err)
	}
	fmt.Println(p.ID, p.Owner)
}

Command-line tool: agent-conform

go install github.com/TAIPANBOX/agent-stack-go/v2/cmd/agent-conform@v2.0.0
agent-conform passport.json events.ndjson

(It landed after v0.2.0 and shipped in v0.4.0, so a tag is the right way to install it now, per Versioning.)

The standalone conformance checker: validates Passport documents and agent-event NDJSON streams against the canonical JSON Schemas, the check agent-passport's own README names as not existing yet ("conformance is verified per-repo... by hand"). Stricter than passport.Parse/ event.Unmarshal (which only enforce required-field presence) -- full schema validation, including patterns like the agent:// URI grammar and prev_hash's exact 64-hex-char form. Each file is classified by its own schema field, not extension, mirroring the same convention every connector in the stack already uses. Exit code 0 means every file (and every line within an event stream) conforms; 1 means at least one did not.

-chain checks the two things "chain" means in this spec, and reports them apart because they answer different questions:

Check Rule What a failure means
prev_hash integrity chain SPEC 6.5, via event.VerifyChain the file was altered, truncated or reordered after it was written
on_behalf_of delegation chain SPEC 5.1, via chain.Validate: acyclic, at most 32 entries, every entry an agent:// or user:// URI the identity claim inside the event never made sense, which points at the emitter rather than at whoever has held the file since

A stream can pass either one and fail the other, which is the reason for two verdicts rather than one. Root-first ordering is deliberately not claimed: it is a property of how a chain was built, one entry appended per hop, and a list of URIs after the fact carries nothing that could distinguish a root-first chain from a reversed one.

Running the chain verifier on a box: agent-conform watch-dir

agent-conform -chain <file> names a broken prev_hash chain, but only to whoever ran it. On the 2026-09-17 appliance run one byte flipped on a sealed line of the shared events bus was seen by nothing, because the tool existed as source and archives and nothing on the box ran it. watch-dir is the same verification (event.VerifyChain, the library's own) as a mode that runs on a schedule and alerts: a break becomes an event on the bus, where the notifier and the console already look.

agent-conform watch-dir [-out PATH] [-state PATH] [-every 60s] [-checkpoints DIR -checkpoint-keys FILE] <dir>

It walks every *.ndjson in ONE flat directory (the bus is flat; a subdirectory is not part of it, and a symlink is skipped, never followed) and verifies each file's chain from its first line. Per file it reports at most one thing, as one agent-event (schema v1.0, source agent-conform) appended to its own stream with the library's own ChainedWriter:

Event Severity When data
chain_broken high a prev_hash does not match the line before it file, line (the first such line), kind (prev_hash_mismatch), breaks (how many), restarts, malformed_lines, unverifiable_links, expected/found (clipped), verifier
chain_unchained low two or more events and not one carries a prev_hash file, kind (no_prev_hash), events, malformed_lines, unverifiable_links, verifier
chain_restarted medium, or high when no resume checkpoint explains it a head line after the first: the chain began again file, kind (restart), line, after_line (the last well-formed event before it, 0 for none), explained, malformed_lines, unverifiable_links, verifier

A restart is not a break (SPEC 6.5 and invariant 7 of the library): the chain began again, legally, after a process that could not resume. It is medium when a valid resume checkpoint names the line before it, and high when none does, because then nothing says why the chain began again.

With -checkpoints DIR -checkpoint-keys FILE the verifier also checks each stream's checkpoint sidecar (SPEC 6.5.1, <stream>.ckpt.ndjson in DIR). A record that does not hold is a refusal: the stream was rewritten before its count (its prefix no longer matches), or it was truncated below its count. A refusal is printed, makes the run exit 1 the first time it is seen, and is remembered in the state file. It is not put on the bus: SPEC 6.2 names no event type for it, and the keyryx witness types are where that is designed. FILE holds the public keys that verify the signatures, each kid its RFC 7638 thumbprint. -checkpoints without -checkpoint-keys, or the reverse, is a usage error, because a signature nobody can check is not a pass.

An unchained stream is not a broken one: the field is optional by SPEC 6.5. It is reported once so the operator knows which streams nobody can verify. A stream with a single event is not judged, since the first line of every chain has no prev_hash by design.

Each finding is written once. The (file, kind, line) is remembered in a small state file (-state, default agent-conform.state.json beside the output), so a schedule that runs every minute does not repeat an alert every minute. A state file that exists and cannot be read is an error, never a fresh start. The event is written before the state is saved, so a crash costs one duplicate at worst and never a lost alert.

One pass, then exit, by default; a CronJob or a loop is the schedule. -every <duration> (at least 10s) repeats the pass inside one process, for an image with no shell to loop in and for a compose service: it exits 0 when told to stop, and exits 2 at once when a pass cannot do its job, so a supervisor's restart count shows a verifier that is not verifying.

Exit Meaning
0 every stream verified, nothing NEW to report (an unchained stream, or a break already reported, does not change that)
1 at least one NEW chain_broken was reported, or a NEW checkpoint refusal
2 usage error, or something could not be done: the directory is missing or holds no stream, a file could not be read to its end (over a cap, an oversize line), the output or state could not be written. 2 wins over 1: the alerts are on the bus either way, and a run that could not finish must not look like one that did

What it will not do:

  • It writes nothing but its own output and its own state. Streams are opened read-only, -out must be a file named agent-conform.ndjson (its name is the source its events claim, so it cannot be pointed at another writer's stream), and -state may not look like a stream. Mount the bus read-only and give -out a path on a writable volume of its own.
  • It never reports a bus it did not read as clean. A missing or empty directory is exit 2, and so is a file larger than -max-file-bytes (default 256 MiB) or holding a line longer than -max-line-bytes (default 1 MiB, at most 4 MiB). A break found before the oversize line is still alerted.
  • Nothing from inside a file is copied into an alert but two hash strings, clipped to 96 bytes, so a forged prev_hash does not choose how big the next line on the bus is.
  • Its own output is verified like any other stream.

The image is ghcr.io/taipanbox/agent-conform:<tag> (amd64 and arm64, static, distroless, non-root 65532, signed by digest and attested; there is no latest, pin a released tag). Verify it the way the archives are verified:

cosign verify ghcr.io/taipanbox/agent-conform:<tag> \
  --certificate-identity-regexp '^https://github.com/TAIPANBOX/agent-stack-go/\.github/workflows/release\.yml@refs/tags/v' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify oci://ghcr.io/taipanbox/agent-conform:<tag> -R TAIPANBOX/agent-stack-go

A compose service, with the bus mounted read-only and the verifier's own stream and state on a writable volume. Run it as the uid that owns that volume's files and give it read access to the bus through the bus's group:

services:
  agent-conform:
    image: ghcr.io/taipanbox/agent-conform:<tag>   # a released tag, never a moving one
    command: ["watch-dir", "-every", "60s", "-out", "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/out/agent-conform.ndjson", "/bus"]
    user: "10002:10002"
    group_add: ["10001"]            # the group that can read the bus
    read_only: true
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    restart: unless-stopped
    volumes:
      - events:/bus:ro              # every writer's stream, read-only
      - conform-out:/out            # its own stream and state: the only writable path

A Kubernetes CronJob. A new break fails the Job (exit 1) on purpose, a second signal beside the event; the next run exits 0 because the finding is remembered, which is why the output volume must persist:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: agent-conform
spec:
  schedule: "*/5 * * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 0
      template:
        spec:
          restartPolicy: Never
          securityContext:
            runAsNonRoot: true
            runAsUser: 10002
            runAsGroup: 10002
            supplementalGroups: [10001]   # the group that can read the bus
          containers:
            - name: agent-conform
              image: ghcr.io/taipanbox/agent-conform:<tag>
              args: ["watch-dir", "-out", "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/out/agent-conform.ndjson", "/bus"]
              securityContext:
                readOnlyRootFilesystem: true
                allowPrivilegeEscalation: false
                capabilities: {drop: [ALL]}
              volumeMounts:
                - {name: events, mountPath: /bus, readOnly: true}
                - {name: out, mountPath: /out}
          volumes:
            - name: events
              persistentVolumeClaim: {claimName: events, readOnly: true}
            - name: out
              persistentVolumeClaim: {claimName: agent-conform-out}

These manifests are starting points: the image was built and run against a read-only bus with a separate output, and the snippets were not applied to a cluster in the change that wrote them. Wiring the verifier into a stack's own launcher (its uid, the file it owns on the bus) is the launcher's change.

Limits, stated so nobody reads more into an alert or its absence than is there:

  • prev_hash is tamper-evidence, not tamper-proof. A writer compromised in its own uid can forge its own stream with a valid chain, and so can anybody who can rewrite a whole file. This catches a partial edit, a dropped line, a reordered shipment.
  • Only a genuine mismatch is a break. A line whose prev_hash was stripped reads as a chain restart, and a line replaced by garbage makes the next one unverifiable; both are counted in the alert's data and neither raises one, because a crash in the middle of a write produces exactly the second shape.
  • Only the first break per file is announced; a later one is not until the first is gone (the count rides in the alert).
  • Cutting a stream short from the end changes no hash in what remains.

The custody client: keyclient

keyclient is the consumer side of Keyryx, the estate's key custody service. It is a separate package that imports only the standard library and delegation, and nothing in delegation imports it, so a token is still checked with the keys the process already holds. It does three things, none of them on a request path:

  • Delegated keys. Client.Delegated declares an ES256 or ES384 key with a life of at least ten minutes. RenewIfDue generates the key in memory, shows custody that it holds it, and asks for the next statement at half the life. Sign signs locally with the key whose statement is valid at the clock's time, and returns ErrNoValidKey once no statement is valid, so an expired key is never used.
  • Held keys. Client.Sign asks custody to sign with a key it holds, naming the purpose the signature serves in custody's use log.
  • Key sets. Client.JWKS polls a name's published keys into a cache. Set feeds delegation.Options.Keys, and the cache keeps verifying when custody is unreachable. A set with one malformed key is refused whole, and a key id the cache never held is refused, whether custody is up or down.

A consumer proves which it is by one of three credentials, named in its configuration and never guessed between: a unix socket of its own (no secret is sent, custody names the consumer by the socket), a key file, or a Kubernetes projected service-account token. The file is read on every request, so a rotated token is used at once. A redirect is not followed, so the credential never reaches a host that was not named.

NOT PROVEN by this package: it does not check the delegation root's signatures on a statement. Trust in a statement and in a key set comes from the channel to custody, so the socket's permissions, or the cluster network with its policies, are the control. A cached key is valid until the cache is refreshed; a revoked key is dropped at the next successful poll, not at the moment of revocation. Over plain HTTP inside a cluster the bearer credential is readable by anything on that network.

Design notes

  • Stdlib only at runtime for passport and chain: no third-party dependency is ever required to import and use them. event additionally requires github.com/gowebpki/jcs (RFC 8785/JCS canonicalization) at runtime, for the prev_hash integrity chain (ChainedWriter/ VerifyChain, see above). github.com/santhosh-tekuri/jsonschema/v6 is used by event's conformance test and, as a real (non-test) dependency, by cmd/agent-conform -- a consumer importing only the library packages never pulls in jsonschema; only building the standalone tool does. The same holds for passport's conformance test, which is why scripts/deps-layering.sh reads non-test imports only.
  • Each package mirrors an existing internal implementation elsewhere in the stack (Idryx's internal/ingest/passport and internal/ingest/tokenfuse, TokenFuse's tokenfuse-core::agent_event, Engram's engram.events) rather than inventing new semantics, so adopting it is a rename, not a rewrite.
  • Errors are sentinel values, checkable with errors.Is, not opaque strings, so callers can branch on failure kind without string matching.
  • Malformed input is tolerated the same way the existing Idryx connectors tolerate it: a bad NDJSON line or passport document is skipped and counted, never fatal to the rest of a batch.

The canonical JSON Schemas live in the TAIPANBOX/agent-passport repo. event/testdata/agent-event.v0.2.schema.json and passport/testdata/schema/agent-passport.schema.json are local copies used only by those packages' conformance tests, so the Go bindings can never silently drift out of lockstep with the schemas that define the wire contract. cmd/agent-conform/schemas/*.json are separate local copies of all six embedded schemas (Passport v0.1 and v1.0, event v0.1, v0.2, v0.3 and v1.0), embedded into that tool via go:embed for the same reason.

Copies drift, so on every push CI checks out agent-passport beside this repo and compares every one of them against the file it was copied from, byte for byte (scripts/schemas-in-sync.sh). That check exists because the vendored Passport schema had drifted: it was missing the filesystem (SPEC 4.4) and models (SPEC 4.5) declarations, and a Passport document allows additionalProperties, so a property the schema does not declare is not checked loosely, it is not looked at at all. A passport whose filesystem entry said "mode": "delete", a mode the spec does not have, passed with OK. The schema is synced and both fields are now enforced; the gate is there so the next divergence is caught by CI rather than by a reader.

Versioning

This module follows SemVer, starting at v0.1.0. Breaking the wire contract (the passport or event schema) is a spec version bump, never a silent change; the Go types version alongside the module itself. Consumers pin it by tag (go get github.com/TAIPANBOX/agent-stack-go/v2@v2.0.0), never a local replace.


Status

  • passport: Parse, ValidateAgentURI, ValidateUserURI, sentinel errors
  • event: Marshal, Unmarshal, append-only Writer, Scan/ReadFile NDJSON readers, ChainedWriter/VerifyChain SPEC 6.5 prev_hash integrity chain, Canonicalize/ChainHash
  • chain: Append, Validate, MaxDepth = 32, acyclic + root-first
  • conformance tests against the canonical JSON Schemas, one per bound type: event against agent-event v0.2 and v1.0, passport against agent-passport v0.1 and v1.0, including a both-directions check that the struct's json tags and the schema's properties name the same set (a mistyped tag validates fine, since additionalProperties is true, and declares nothing)
  • passport.LoadDir: shared batch loader (resolve dir/glob/file, sorted, tolerant, first-seen-id dedup), extracted out of Wardryx's and Idryx's independent copies
  • v0.5.0 tagged, the first release to publish agent-conform as a binary, then v0.5.1 the same day: it moved the version out of the release asset names and into the binary, so releases/latest/download/<name> is a permanent address; CI green on gofmt, go vet, staticcheck, go test -race, go build, govulncheck
  • v0.6.0 tagged, which the install lines above pinned to at the time (they now pin the newest tag, see Versioning). The library packages are byte-identical to v0.5.1, so an importer gets no behaviour change; the checker does. -chain said it checked delegation chains and never looked, the vendored Passport schema had stopped being the Passport schema, and the event schemas gained SPEC 5.1's depth bound, so a stream with a chain over 32 entries now fails where it used to pass. That is a minor rather than a patch for exactly that reason. It also drops a darwin/arm64 build of agent-conform that had been committed at the module root and shipped inside the v0.5.1 module zip, against this repository's own .gitignore
  • cmd/agent-conform: standalone conformance-check CLI, full JSON Schema validation (Passport documents v0.1 and v1.0, events v0.1/v0.2/v0.3/v1.0, each against its own version's schema and never another) against embedded copies of the canonical schemas; live-verified against real fixtures elsewhere in the stack, catching a real 63-vs-64-hex-char prev_hash defect
  • 1.0 (2026-09-12): passport.SchemaV10, event.SchemaV10 and passport.AcceptedSchemas; Parse refuses a v1.0 document carrying a key the schema never named (ErrUnknownField) and keeps tolerating it under v0.1; the exported surface of chain, delegation, event and passport is a file, api/surface.txt, and scripts/api-surface.sh fails when it loses or changes a line (a new major is cut with --major); eleven scenarios in features/contract-1.0.feature, each bound to its test
  • ChainedWriter locks the file and chains from the line on disk, so writers sharing one stream keep one chain (scenarios in features/chained-writer-lock.feature)
  • agent-conform watch-dir (agent-stack-go#64): the chain verifier for a box, with its scenarios in features/watch-dir.feature, and the ghcr.io/taipanbox/agent-conform:<tag> image built by release.yml on a v* tag (see Running the chain verifier on a box)

This module's package set (passport, event, chain, delegation) covers everything the stack's current Go consumers need; it is not a fixed, closed list, and grows opportunistically the same way passport.LoadDir did (extracted once two independent copies existed, not speculatively).

Validation

What has actually been checked about this contract, what a real cluster run confirmed, and what is explicitly NOT validated: VALIDATION.md. The short version is that agent-conform caught a real 63-versus-64 hex character prev_hash defect in another service's output, which is the whole reason this tooling exists.

License

Apache-2.0.

About

Shared Go contract (Agent Passport + agent-event envelope) for the TAIPANBOX agent-governance stack

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages