feat(attestation): structured sidecar identity_field + domain-aware MISS policy (lr-f1bfe8, lr-2ca216) - #23
Conversation
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.
|
PEACHES — clean Code Structure & Modularity
Fail-Closed Semantics
Path Safety
Documentation Accuracy
Roster-Agnostic Contract
Structured-Record Tests
No Regressions
Zero findings. ✓ |
|
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. |
|
Merged via clagentic-loadout v0.1.0
|
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-entryIdentityField. 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.TestSidecarProvider_IdentityFieldUnset_WholeFileBackCompat, plus the originalTestSidecarProvider_FilePresent_Resolvesstill passes unmodified).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.*attestation.MalformedSidecarError, never ErrNoIdentity) on: non-parseable file, identity_field absent, or the named field empty/non-string.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}.DomainLocaldelegates 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).DomainA2Arequires a per-spawn-scoped*Resolverto resolve; a MISS there returnsErrPerSpawnRequiredand never falls through to Chain's remaining providers (session sidecar, built-in fallback).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
Identitystruct (attestation.go) rather than as a separate wrapper type, since every provider already returns bareIdentityand 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.