Skip to content

feat(attestation): structured sidecar identity_field + domain-aware MISS policy (lr-f1bfe8, lr-2ca216) - #23

Merged
clagentic-merger[bot] merged 5 commits into
mainfrom
feat/lr-f1bfe8-structured-sidecar-identity-field
Jul 23, 2026
Merged

feat(attestation): structured sidecar identity_field + domain-aware MISS policy (lr-f1bfe8, lr-2ca216)#23
clagentic-merger[bot] merged 5 commits into
mainfrom
feat/lr-f1bfe8-structured-sidecar-identity-field

Conversation

@clagentic-builder

Copy link
Copy Markdown
Contributor

Implements the attestation substrate for the A2A caller-attestation epic (unblocks lr-a850d0). Three coupled units, one PR because they share internal/attestation/sidecar.go and the same docs surface.

Unit 1 — lr-f1bfe8 (P3): structured sidecar identity_field

SidecarConfig (internal/attestation/sidecar.go) gains an optional, per-entry IdentityField. Structured-record parsing lives in its own new file, internal/attestation/structured_sidecar.go, kept separate from sidecar.go's whole-file/path-safety concern per the operator's modularity constraint.

  • IdentityField unset: unchanged whole-file-as-Identity.Subject behavior (regression test TestSidecarProvider_IdentityFieldUnset_WholeFileBackCompat, plus the original TestSidecarProvider_FilePresent_Resolves still passes unmodified).
  • IdentityField set: parses the file as JSON or YAML and reads the named field as Identity.Subject. Identity (attestation.go) gains four attribution fields (ParentSessionID, SpawnID, AgentType, SpawnedAt) captured from the record when present — none of them required, none part of the trust decision.
  • Fails closed (a new *attestation.MalformedSidecarError, never ErrNoIdentity) on: non-parseable file, identity_field absent, or the named field empty/non-string.
  • All existing path-safety guarantees (Lstat, requireContained, isSafePathSegment, regular-file check) untouched — the branch happens after the existing read, only when IdentityField is set.
  • config.example.yaml documents identity_field and adds a structured-sidecar example entry alongside the existing whole-file entry; config_schema_version bumped to 2 (additive/back-compat — Config.SchemaVersion is new, informational only, not yet enforced).

Trade-off named: JSON AND YAML are both supported (not JSON-only), because gopkg.in/yaml.v3 is already a go.mod dependency (config.go uses it) — accepting both costs no new dependency. See the comment at the top of structured_sidecar.go.

Unit 2 — lr-2ca216 (P2): domain-aware fail-closed MISS

Follows MILLER's recommendation (comment #2, conf 0.9): a PER-MINT-DOMAIN policy, not a global reorder of the shared first-match-wins chain. New file internal/attestation/domain_policy.go (its own file, not bolted onto sidecar.go or chain.go per the modularity constraint):

  • Domain (DomainLocal | DomainA2A) + DomainResolver{Chain, PerSpawn}.
  • DomainLocal delegates straight to the existing *Resolver — attestation.go and chain.go are untouched, and a per-spawn MISS still falls through to the session sidecar exactly as today (lr-86779f director-session resolution keeps working).
  • DomainA2A requires a per-spawn-scoped *Resolver to resolve; a MISS there returns ErrPerSpawnRequired and never falls through to Chain's remaining providers (session sidecar, built-in fallback).
  • Mandatory regression test, both directions (domain_policy_test.go): (a) A2A-domain mint with a per-spawn MISS refuses fail-closed and never resolves to the session identity, even though the session sidecar file is present and would resolve; (b) local-domain mint with the identical sidecar state still resolves via session as today.

Trade-off named: DomainResolver is attestation substrate only — no A2A mint command exists in cmd/gatekeeper yet, and lr-a850d0 (the A2A mint path this unblocks) remains gated on a separate substrate-ratification decision. DomainResolver is unused by any caller in this repo until that mint path lands and invokes it with DomainA2A.

Identity struct extension shape trade-off: attribution fields (ParentSessionID/SpawnID/AgentType/SpawnedAt) were added directly onto the shared Identity struct (attestation.go) rather than as a separate wrapper type, since every provider already returns bare Identity and mint's entitlement check only reads Subject/Source — adding optional fields is non-breaking for every existing caller.

Unit 3 — A2A doc reflection (operator directive)

README.md, docs/DESIGN.md, docs/SETUP.md, docs/SIDECAR-READ-CONTRACT.md updated to reflect exactly what this PR ships (the attestation substrate), explicitly NOT documenting lr-a850d0's unimplemented mint path as done. docs/SETUP.md publishes the required-fields contract (attested caller identity via identity_field, role source via Identity.Source, parent session id via parent_session_id) as the normative contract the crew-manifest producer implements, with no crew-specific knowledge added to gatekeeper source. docs/SIDECAR-READ-CONTRACT.md gets two new normative sections (8: structured sidecar records, 9: trust-boundary-dependent fail-closed MISS) generalized independent of gatekeeper's own naming, per that doc's existing convention.

Contract migration note

Both changes are backward-compatible schema/policy additions: identity_field is optional and per-entry (a config.yaml with no identity_field set behaves identically to before), and the domain-aware MISS policy is a new opt-in wrapper (DomainLocal preserves today's behavior; nothing in cmd/gatekeeper currently constructs DomainA2A). config_schema_version bumped 1 -> 2 in config.example.yaml to mark the schema addition.

Modularity (operator constraint)

Per an explicit operator instruction mid-task: split by responsibility rather than growing sidecar.go into a monolith.

  • internal/attestation/structured_sidecar.go — structured-record parsing/field-selection concern (NEW).
  • internal/attestation/domain_policy.go — per-domain MISS/required-source policy, its own file, not bolted onto sidecar.go or chain.go (NEW).
  • internal/attestation/attestation.go — Identity attribution carry-through added directly to the existing shared type (minimal addition, not a new file, since Identity is already the one shared core type every provider returns).
  • internal/attestation/sidecar.go — gains only the IdentityField config field + a one-line branch into structured_sidecar.go; the whole-file/path-safety logic is unchanged.

Tests

go test ./... — all green. New test files: structured_sidecar_test.go (happy path JSON+YAML, field-missing/non-parseable/empty/non-string fail-closed, whole-file back-compat unchanged), domain_policy_test.go (both-direction MISS regression + A2A happy path + nil-PerSpawn fail-closed). Extended config_test.go (per-entry identity_field parsing, config.example.yaml itself parses and matches the documented shape) and sidecar tests are otherwise unmodified.

Unblocks

lr-a850d0 (A2A caller attestation contract, gated epic child) — this PR ships the structured-record substrate and domain-aware MISS policy it hard-depends on. lr-a850d0's own mint path is NOT implemented here (it remains GATED per its own task).

Tasks: lr-f1bfe8, lr-2ca216.

Add optional per-entry SidecarConfig.IdentityField. Unset preserves the
whole-file-as-subject behavior exactly (back-compat, unchanged test).
Set: parse the sidecar file as JSON or YAML and read the named field as
Identity.Subject; capture parent_session_id/spawn_id/agent_type/spawned_at
onto the resolved Identity for attribution/audit. Fail closed (a structured
*MalformedSidecarError, never ErrNoIdentity) on: non-parseable file,
identity_field absent, or the named field empty/non-string.

Structured-record parsing lives in its own file (structured_sidecar.go),
kept separate from sidecar.go's whole-file/path-safety concern per
modularity constraint.
…f1bfe8)

AttestationSidecarConfig gains identity_field (yaml, optional). Per-entry:
coordinates with the multi-sidecar list (lr-86779f) so each sidecar
namespace can independently opt into structured parsing. Threaded through
cmd/gatekeeper/main.go's chain build into attestation.SidecarConfig.
…a216)

Add DomainResolver (domain_policy.go, its own file per the modularity
constraint — not bolted onto sidecar.go or chain.go) implementing MILLER's
recommendation (lr-2ca216 comment #2, conf 0.9): the MISS policy is
per-mint-domain, not a global reorder of the shared first-match-wins chain.

DomainLocal delegates straight to the existing Resolver (attestation.go/
chain.go untouched) — a per-spawn MISS still falls through to the session
sidecar exactly as today (lr-86779f, director-session resolution keeps
working). DomainA2A requires a per-spawn-scoped Resolver to resolve; a MISS
there returns ErrPerSpawnRequired and never falls through to Chain's
lower-priority providers (session sidecar, built-in fallback) — closing the
confused-deputy hole MILLER identified for a remote-facing credential.

Regression test covers both directions: A2A-domain MISS refuses and never
resolves to the session identity; local-domain MISS with the identical
sidecar state still resolves via session as today.

This is attestation substrate only — no A2A mint path is implemented here
(lr-a850d0 remains gated); DomainResolver is unused by cmd/gatekeeper until
an A2A-domain mint caller exists to invoke it with DomainA2A.
…hema v2

config.example.yaml: annotate identity_field's optional/per-entry semantics
and add a structured-sidecar entry alongside the existing whole-file entry.
Bump config_schema_version to 2 (additive/back-compat change: identity_field
+ the domain-aware MISS resolution policy) and add Config.SchemaVersion so
a future breaking schema change has one place to add a version gate.

Regression test loads config.example.yaml itself so the shipped reference
file cannot silently drift out of sync with the schema.
…8, lr-2ca216)

Document the two units that unblock the A2A epic (lr-a850d0), scoped
accurately to what THIS PR ships (substrate only, no A2A mint path):

- README.md: new "Attestation substrate for agent-to-agent (A2A) callers"
  section summarizing structured sidecar records + domain-aware
  fail-closed MISS, explicit about what is and is not implemented yet.
- docs/DESIGN.md: cite structured_sidecar.go and domain_policy.go as their
  own module-boundary entries under internal/attestation.
- docs/SETUP.md: new "Structured sidecar records (identity_field)" and
  "Domain-aware fail-closed MISS" sections, plus the published
  required-fields contract (attested identity, role source via
  Identity.Source, parent session id) the crew-manifest producer
  implements.
- docs/SIDECAR-READ-CONTRACT.md: new normative sections 8 (structured
  sidecar records) and 9 (trust-boundary-dependent fail-closed MISS
  semantics), generalized independent of gatekeeper's own naming, citing
  gatekeeper's implementation as the worked example per this doc's existing
  convention.
@clagentic-reviewer

Copy link
Copy Markdown

PEACHES — clean

Code Structure & Modularity

  • structured_sidecar.go and domain_policy.go are properly separated, one concern per file. The structured-record parsing owns its own module; domain-policy wraps the shared chain without reordering.
  • Identity struct extended with attribution fields (ParentSessionID, SpawnID, AgentType, SpawnedAt) only when structured sidecar resolves. Back-compat preserved: unset IdentityField path returns bare Identity.Subject as before.
  • NewChain (chain.go) unchanged, confirming no regression in shared provider order (configured → sidecars → builtin).

Fail-Closed Semantics

  • MalformedSidecarError properly distinguishes present-but-broken from absent. Hard failure on: unparseable JSON/YAML, missing identity_field, non-string value, empty value after trim.
  • DomainResolver.Resolve enforces mandatory regression tests: direction (a) DomainA2A per-spawn miss refuses (ErrPerSpawnRequired), never falls through to session sidecar. Direction (b) DomainLocal per-spawn miss still resolves via session sidecar (lr-86779f unchanged).
  • Tests confirm: TestDomainResolver_A2A_PerSpawnMiss_FailsClosed_NeverSessionFallback, TestDomainResolver_Local_PerSpawnMiss_StillResolvesViaSession, TestSidecarProvider_IdentityFieldUnset_WholeFileBackCompat.

Path Safety

  • isSafePathSegment and requireContained unchanged in sidecar.go. Lstat used (not Stat) to reject symlinks. Path traversal guards remain intact.

Documentation Accuracy

  • README.md L73–83: correctly states "What it does NOT yet ship: an actual A2A token-minting command in gatekeeper itself." No false claims about lr-a850d0 mint path.
  • docs/SETUP.md: sections 4–5 correctly scope to attestation substrate only, no A2A mint wiring claimed.
  • docs/DESIGN.md: structured_sidecar.go and domain_policy.go cited as separate entries under internal/attestation; identity_field defaults to unset (unchanged whole-file), noted explicitly.
  • docs/SIDECAR-READ-CONTRACT.md: new sections 8–9 generalize sidecar contract independent of gatekeeper naming; reference implementation cited without confusing narrative.
  • config.example.yaml: IdentityField fully documented with worked examples (JSON/YAML). Schema version bumped to 2. No hardcoded agent names.

Roster-Agnostic Contract

  • Field names (parent_session_id, spawn_id, agent_type, spawned_at) are generic. Config key identity_field is generic, not tool-specific. No agent names in source or config.
  • config.example.yaml.rst comment: "agent_type: 'builder'" is a generic classification example, not a hardcoded roster entry.

Structured-Record Tests

  • JSON and YAML both tested (TestSidecarProvider_StructuredJSON_HappyPath, TestSidecarProvider_StructuredYAML_HappyPath).
  • Attribution field capture verified; missing fields never fail (stringField returns "" safely).
  • Fail-closed cases: missing field, non-parseable, non-string, empty — all return MalformedSidecarError, not ErrNoIdentity (direction tests ensure no fallback to weaker provider).

No Regressions

  • Back-compat test (TestSidecarProvider_IdentityFieldUnset_WholeFileBackCompat) confirms: unset IdentityField → whole-file behavior unchanged, attribution fields remain empty.
  • Local domain resolves via session on per-spawn miss; only A2A domain enforces per-spawn requirement.

Zero findings.

{"reviewer": "peaches", "review_status": "clean", "head_sha": "fb67e0dd252b165a16ea9a060aa3a6833db1addf", "pr_number": 23}

@clagentic-security

Copy link
Copy Markdown

BOBBIE -- clean

Reviewed PR #23 (structured sidecar identity_field + domain-aware fail-closed MISS for A2A mints) against base..head (14 files, +1194/-9).

Structured sidecar parser (internal/attestation/structured_sidecar.go): parses JSON then YAML into map[string]any, no unsafe deserialization or eval. Fails closed with *MalformedSidecarError (never ErrNoIdentity) on parse failure, missing identity_field, non-string value, and empty-after-trim value -- all four negative paths covered by dedicated tests.

Path safety (internal/attestation/sidecar.go): unchanged, pre-existing hardening reused unmodified by the new structured path -- isSafePathSegment rejects separators/traversal in the session-id path segment, requireContained enforces direct-child-of-dir, Lstat + IsRegular check blocks symlink/device/socket redirection before any read.

Domain policy (internal/attestation/domain_policy.go): DomainA2A requires PerSpawn.Resolve to succeed; a per-spawn ErrNoIdentity or nil PerSpawn returns ErrPerSpawnRequired, never a fallthrough to Chain's remaining providers (session sidecar). Confirmed by TestDomainResolver_A2A_PerSpawnMiss_FailsClosed_NeverSessionFallback (asserts the resolved identity is NOT the parent session's) and TestDomainResolver_A2A_NilPerSpawn_FailsClosed. DomainLocal fallthrough behavior is unchanged and regression-tested.

config.example.yaml: only placeholder values (your--agent-identity, secret/gatekeeper/ broker paths, /tmp dirs, a fictional harness env var) -- no live credentials.

Scanners: gitleaks (0 leaks), semgrep --config auto on internal/attestation, internal/config, cmd/gatekeeper (0 findings), osv-scanner on go.mod (25 Go-stdlib advisories, all pre-existing -- go.mod/go.sum untouched by this PR, not attributable to this diff). trufflehog unavailable in this environment (scanner_status: unavailable).

No findings.

{"reviewer": "bobbie", "review_status": "clean", "head_sha": "fb67e0dd252b165a16ea9a060aa3a6833db1addf", "pr_number": 23}

@clagentic-merger
clagentic-merger Bot merged commit d1378d5 into main Jul 23, 2026
1 check passed
@clagentic-merger

Copy link
Copy Markdown
Contributor

Merged via clagentic-loadout v0.1.0

Field Value
Gated HEAD SHA fb67e0dd252b165a16ea9a060aa3a6833db1addf
Merged SHA fb67e0dd252b165a16ea9a060aa3a6833db1addf
Reviews clagentic-reviewer[bot], clagentic-security[bot]
CI status no-runner-by-design (0 commit-status entries at HEAD)
task_id lr-f1bfe8

@clagentic-merger
clagentic-merger Bot deleted the feat/lr-f1bfe8-structured-sidecar-identity-field branch July 23, 2026 14:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants