From a953e4bcc093fcbbf5e1ef666b888cd41afb7914 Mon Sep 17 00:00:00 2001 From: clagentic Date: Fri, 24 Jul 2026 12:17:31 -0400 Subject: [PATCH 1/4] fix(attestation): add DomainLocalSubagent per-spawn-required MISS policy (lr-2a8653) Reuses lr-2ca216's DomainResolver substrate for the local (GitHub) mint path: a per-spawn attestation MISS on a subagent invocation must fail closed like DomainA2A already does, not fall through to a lower-priority provider. DomainLocal (lead/director session, no per-spawn source by design) is unaffected. --- internal/attestation/domain_policy.go | 90 +++++++++++++++++++-------- 1 file changed, 64 insertions(+), 26 deletions(-) diff --git a/internal/attestation/domain_policy.go b/internal/attestation/domain_policy.go index a688fd9..e370d1e 100644 --- a/internal/attestation/domain_policy.go +++ b/internal/attestation/domain_policy.go @@ -16,13 +16,19 @@ import ( // // WHY THIS IS NOT A GLOBAL CHAIN CHANGE (MILLER lr-2ca216 comment #2, conf // 0.9): a per-spawn attestation MISS falling through to the session -// sidecar is CORRECT for local GitHub/reader mints (lr-86779f) — a director -// session legitimately has no per-spawn sidecar and must resolve via its -// own session sidecar. The same fallthrough is a confused-deputy hole for -// a REMOTE-facing (A2A) mint: the minted token crosses a trust boundary, -// so a wrong-identity mint silently over-grants the parent lead's role to a -// peer that never earned it. The discriminator is the MINT DOMAIN, not the -// provider order — so the fix is a domain-scoped wrapper, not a reorder. +// sidecar is CORRECT for local GitHub/reader mints made by an invocation +// that legitimately has no per-spawn sidecar of its own (lr-86779f) — e.g. a +// director/lead session. The same fallthrough is a confused-deputy hole +// both for a REMOTE-facing (A2A) mint, where the minted token crosses a +// trust boundary to a peer with no way to detect a parent/spawn +// substitution, AND for a LOCAL mint requested by an invocation that DOES +// expect its own per-spawn source — a spawned subagent whose per-spawn +// sidecar read misses and, absent this policy, falls through to the +// session-keyed sidecar and silently mints its PARENT's role instead +// (lr-2a8653). The discriminator is the MINT DOMAIN — specifically, whether +// THIS invocation was expected to have a per-spawn source — not the +// provider order, so the fix is a domain-scoped wrapper (DomainA2A, +// DomainLocalSubagent), not a chain reorder. // Domain identifies which mint-request context a Resolve call is being made // for, so the correct MISS policy can be applied. Domain is roster-agnostic @@ -31,12 +37,31 @@ import ( type Domain string const ( - // DomainLocal is the default mint domain: local GitHub/reader-style - // mints. A per-spawn attestation MISS falls through to the next - // provider in the shared chain exactly as today (lr-86779f) — this - // domain applies NO additional constraint. + // DomainLocal is the default mint domain: a local GitHub/reader-style + // mint requested by an invocation that has no per-spawn attestation + // source by design — a long-lived lead/director session, which + // legitimately has no per-spawn sidecar of its own (lr-86779f). A + // per-spawn attestation MISS falls through to the next provider in the + // shared chain exactly as today — this domain applies NO additional + // constraint. DomainLocal Domain = "local" + // DomainLocalSubagent is a local GitHub/reader-style mint requested by + // an invocation that DOES expect a per-spawn attestation source — a + // spawned subagent whose harness is supposed to have written its own + // per-spawn sidecar file. Unlike DomainLocal, a per-spawn MISS in this + // domain must fail closed rather than fall through to a lower-priority + // provider such as the session sidecar: that fallthrough is exactly the + // confused-deputy mechanism (lr-2a8653) where a subagent's per-spawn + // read MISS resolves to its PARENT session's identity via the + // session-keyed sidecar, silently minting the parent's role for the + // subagent's request. The trust boundary here is narrower than DomainA2A + // (a local over-grant, not a credential crossing to a remote peer), but + // the read-miss mechanism and the required fix are identical, so this + // domain reuses the same PerSpawn-required policy DomainA2A already + // established (lr-2ca216). + DomainLocalSubagent Domain = "local-subagent" + // DomainA2A is the remote-facing, agent-to-agent mint domain. A // per-spawn attestation MISS in this domain must fail closed and must // never resolve to a lower-priority provider such as the session @@ -46,6 +71,14 @@ const ( DomainA2A Domain = "a2a" ) +// requiresPerSpawn reports whether d's MISS policy requires the PerSpawn +// resolver to succeed rather than allowing DomainResolver.Resolve to fall +// through to d.Chain's remaining (lower-priority) providers. DomainLocal (and +// any unrecognized/empty Domain) is the only pass-through case. +func (d Domain) requiresPerSpawn() bool { + return d == DomainA2A || d == DomainLocalSubagent +} + // ErrPerSpawnRequired is returned by DomainResolver.Resolve when domain // requires a per-spawn (subagent-namespace) provider to resolve the // identity, and no such provider is configured, or none of the configured @@ -55,10 +88,10 @@ const ( var ErrPerSpawnRequired = fmt.Errorf("attestation: mint domain requires per-spawn attestation; refusing rather than falling through to a lower-priority provider") // RequiredSourceConstraint names the Provider.Resolve-time Source value -// that must be present for a given mint Domain. Only DomainA2A currently -// carries a constraint; DomainLocal (and any unrecognized/empty Domain) -// carries none, so it is a pass-through to the shared chain's ordinary -// first-match-wins behavior. +// that must be present for a given mint Domain. DomainA2A and +// DomainLocalSubagent carry a constraint (Domain.requiresPerSpawn()); +// DomainLocal (and any unrecognized/empty Domain) carries none, so it is a +// pass-through to the shared chain's ordinary first-match-wins behavior. // // today the only per-spawn-namespace provider is the sidecar adapter // (Source == "sidecar"), and there is currently no way to distinguish a @@ -78,7 +111,8 @@ type RequiredSourceConstraint struct { // DomainResolver applies a per-mint-domain MISS policy on top of a shared // attestation.Resolver. It never reorders or duplicates the shared chain — -// Chain is the same *Resolver every mint domain uses. For DomainA2A, it +// Chain is the same *Resolver every mint domain uses. For a domain where +// Domain.requiresPerSpawn() is true (DomainA2A, DomainLocalSubagent), it // additionally requires that PerSpawn (a Resolver built from ONLY the // per-spawn-namespace provider(s), a subset of Chain's own providers) find // an identity; if PerSpawn declines, DomainResolver refuses fail-closed @@ -99,17 +133,21 @@ type DomainResolver struct { // Resolve applies domain's MISS policy and returns the attested identity. // -// - DomainLocal (or any Domain other than DomainA2A): delegates straight -// to d.Chain.Resolve — no change from today's behavior (lr-86779f's -// session-sidecar fallback for a per-spawn miss keeps working). -// - DomainA2A: requires d.PerSpawn.Resolve to succeed. If PerSpawn -// declines (ErrNoIdentity) or is nil, Resolve returns -// ErrPerSpawnRequired — a definite refusal, never a fallthrough to -// d.Chain's remaining (lower-priority) providers such as the session -// sidecar. Any hard error from PerSpawn is returned as-is (fail closed, -// consistent with Resolver.Resolve's own hard-error semantics). +// - DomainLocal (or any Domain whose requiresPerSpawn() is false): +// delegates straight to d.Chain.Resolve — no change from today's +// behavior (lr-86779f's session-sidecar fallback for a per-spawn miss +// keeps working, for an invocation that legitimately has no per-spawn +// source by design, e.g. a lead/director session). +// - DomainA2A / DomainLocalSubagent (domain.requiresPerSpawn()): requires +// d.PerSpawn.Resolve to succeed. If PerSpawn declines (ErrNoIdentity) or +// is nil, Resolve returns ErrPerSpawnRequired — a definite refusal, +// never a fallthrough to d.Chain's remaining (lower-priority) providers +// such as the session sidecar (lr-2a8653's confused-deputy fix reuses +// this exact policy for the local-subagent case). Any hard error from +// PerSpawn is returned as-is (fail closed, consistent with +// Resolver.Resolve's own hard-error semantics). func (d *DomainResolver) Resolve(ctx context.Context, domain Domain) (Identity, error) { - if domain != DomainA2A { + if !domain.requiresPerSpawn() { return d.Chain.Resolve(ctx) } From a8bc703e59e0672c081abe3c0ca27f2eba197bca Mon Sep 17 00:00:00 2001 From: clagentic Date: Fri, 24 Jul 2026 12:17:35 -0400 Subject: [PATCH 2/4] fix(mint): wire MintForDomain through DomainResolver, keep Mint DomainLocal (lr-2a8653) Service gains DomainResolver (preferred) alongside the legacy AttestationResolver field. MintForDomain resolves identity under the caller-supplied Domain's MISS policy; Mint is now MintForDomain scoped to DomainLocal, so every existing caller keeps today's exact behavior. Adds the T+/T- regression pair at the Service level: a subagent per-spawn MISS refuses and never mints under the parent's identity; a lead/director per-spawn MISS still resolves via the session sidecar unchanged. --- internal/mint/mint.go | 77 +++++++++++++++++++++---- internal/mint/mint_test.go | 114 +++++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 12 deletions(-) diff --git a/internal/mint/mint.go b/internal/mint/mint.go index b634f83..fd150c7 100644 --- a/internal/mint/mint.go +++ b/internal/mint/mint.go @@ -79,11 +79,33 @@ type Service struct { Bindings map[string]RoleBinding // role name -> broker paths + verification config // AttestationResolver resolves the ATTESTED invoking identity - // (internal/attestation) for the entitlement check. Required: Mint fails - // closed with no broker reads and no token minted if this is nil, since - // a nil resolver means no identity can ever be attested. + // (internal/attestation) for the entitlement check, for an invocation + // whose Domain applies no additional MISS constraint (attestation. + // DomainLocal semantics). Required: Mint fails closed with no broker + // reads and no token minted if both this and DomainResolver are nil, + // since no resolver at all means no identity can ever be attested. + // + // Deprecated in favor of DomainResolver + Domain for any caller that can + // distinguish a per-spawn-expected invocation (attestation. + // DomainLocalSubagent) from a lead/director session with none by design + // (attestation.DomainLocal) — see lr-2a8653. Retained so an existing + // caller that only ever sets AttestationResolver keeps compiling and + // behaving exactly as before: when DomainResolver is nil, Mint wraps + // AttestationResolver in a DomainResolver whose Chain is + // AttestationResolver itself, which is behaviorally identical to calling + // AttestationResolver.Resolve directly for DomainLocal. AttestationResolver *attestation.Resolver + // DomainResolver resolves the ATTESTED invoking identity via the + // domain-aware MISS policy (internal/attestation.DomainResolver, + // lr-2ca216/lr-2a8653): a per-spawn attestation MISS on an invocation + // whose Domain is DomainA2A or DomainLocalSubagent fails closed rather + // than falling through to a lower-priority provider such as the session + // sidecar, while DomainLocal preserves today's session-sidecar fallback + // for a lead/director session with no per-spawn source by design + // (lr-86779f). When set, this takes precedence over AttestationResolver. + DomainResolver *attestation.DomainResolver + // Renderer translates a role's permission set into the provider's expected // format. When nil, roles.DefaultGitHubRenderer is used, which preserves // the existing GitHub installation-token behaviour for all callers that do @@ -98,16 +120,34 @@ type Service struct { MintFunc func(context.Context, githubapp.MintRequest) (githubapp.Token, error) } -// Mint resolves the attested invoking identity, verifies it is entitled to -// mint roleName, reads the role's App credentials from the broker, verifies -// the resolved App's slug matches the role's configured binding, and returns -// a short-lived installation token narrowed to the role's permissions and the -// requested repositories. The App private key never leaves this call. +// Mint is MintForDomain scoped to attestation.DomainLocal — the default mint +// domain for an invocation with no per-spawn attestation expectation of its +// own (e.g. a lead/director session, lr-86779f). Existing callers that only +// need today's behavior (a per-spawn MISS falls through to the session +// sidecar exactly as before) keep using this method unchanged. +func (s *Service) Mint(ctx context.Context, roleName string, repos []string) (githubapp.Token, error) { + return s.MintForDomain(ctx, attestation.DomainLocal, roleName, repos) +} + +// MintForDomain resolves the attested invoking identity under domain's MISS +// policy, verifies it is entitled to mint roleName, reads the role's App +// credentials from the broker, verifies the resolved App's slug matches the +// role's configured binding, and returns a short-lived installation token +// narrowed to the role's permissions and the requested repositories. The App +// private key never leaves this call. +// +// domain selects the attestation.DomainResolver MISS policy applied to this +// invocation (lr-2a8653): pass attestation.DomainLocalSubagent for a spawned +// subagent invocation that expects its own per-spawn attestation source, so +// a per-spawn MISS fails closed rather than silently resolving to the +// PARENT session's identity via the session sidecar — the confused-deputy +// class this method exists to close. Pass attestation.DomainLocal (or use +// Mint) for an invocation with no per-spawn source by design. // // Every failure mode here is fail-closed: an unresolvable identity, an // identity not entitled to the role, or a missing/mismatched App-slug // binding all return an error with no token minted. -func (s *Service) Mint(ctx context.Context, roleName string, repos []string) (githubapp.Token, error) { +func (s *Service) MintForDomain(ctx context.Context, domain attestation.Domain, roleName string, repos []string) (githubapp.Token, error) { role, err := s.Roles.Resolve(roleName) if err != nil { return githubapp.Token{}, err @@ -120,10 +160,23 @@ func (s *Service) Mint(ctx context.Context, roleName string, repos []string) (gi // Gap 1: entitlement — attested identity -> role. Resolved and checked // before any broker read, so an unentitled caller never touches secrets. - if s.AttestationResolver == nil { - return githubapp.Token{}, fmt.Errorf("mint role %q: no attestation resolver configured; cannot verify entitlement", roleName) + // + // DomainResolver takes precedence when set (lr-2a8653): it applies + // domain's MISS policy, which for DomainLocalSubagent/DomainA2A refuses + // rather than falling through to a lower-priority provider such as the + // session sidecar on a per-spawn MISS. A caller that only ever sets the + // legacy AttestationResolver keeps its exact prior behavior — resolved + // via the shared chain with no domain constraint, regardless of domain — + // since it never gets a DomainLocalSubagent-capable resolver in the + // first place. + domainResolver := s.DomainResolver + if domainResolver == nil { + if s.AttestationResolver == nil { + return githubapp.Token{}, fmt.Errorf("mint role %q: no attestation resolver configured; cannot verify entitlement", roleName) + } + domainResolver = &attestation.DomainResolver{Chain: s.AttestationResolver} } - identity, err := s.AttestationResolver.Resolve(ctx) + identity, err := domainResolver.Resolve(ctx, domain) if err != nil { return githubapp.Token{}, fmt.Errorf("mint role %q: resolve attested identity: %w", roleName, err) } diff --git a/internal/mint/mint_test.go b/internal/mint/mint_test.go index bcab966..be6f081 100644 --- a/internal/mint/mint_test.go +++ b/internal/mint/mint_test.go @@ -650,6 +650,120 @@ func TestMintAppSlugBindingGate(t *testing.T) { } } +// --------------------------------------------------------------------------- +// lr-2a8653: domain-aware per-spawn MISS policy at the mint boundary. A +// subagent invocation whose per-spawn attestation source misses must never +// resolve to its parent session's identity via a lower-priority provider in +// the shared chain (the confused-deputy class); a lead/director session with +// no per-spawn source by design must keep resolving via the session sidecar +// exactly as before (lr-86779f) — DomainResolver + Domain is the mechanism, +// reused unmodified from lr-2ca216's DomainA2A substrate. +// --------------------------------------------------------------------------- + +// spawnThenSessionIdentityProvider is a stub attestation.Provider standing in +// for the per-spawn sidecar entry: it resolves when spawnHit is true, and +// declines (ErrNoIdentity) otherwise — modeling a per-spawn sidecar file +// that is present (hit) or absent (miss) for the current invocation. +type spawnThenSessionIdentityProvider struct { + hit bool + identity string +} + +func (p spawnThenSessionIdentityProvider) Resolve(_ context.Context) (attestation.Identity, error) { + if !p.hit { + return attestation.Identity{}, attestation.ErrNoIdentity + } + return attestation.Identity{Subject: p.identity, Source: "sidecar"}, nil +} + +// buildDomainMintService returns a mint.Service wired the way cmd/gatekeeper +// wires it (lr-2a8653): DomainResolver.Chain is the full ordered chain +// [per-spawn provider, session provider], and DomainResolver.PerSpawn is +// scoped to ONLY the per-spawn provider — mirroring main.go's +// chainSidecars[0]-scoped PerSpawn resolver. +func buildDomainMintService(t *testing.T, spawnHit bool, sessionIdentity string) *mint.Service { + t.Helper() + + spawnProvider := spawnThenSessionIdentityProvider{hit: spawnHit, identity: "subagent-self"} + sessionProvider := spawnThenSessionIdentityProvider{hit: true, identity: sessionIdentity} + + chain := attestation.NewResolver(spawnProvider, sessionProvider) + domainResolver := &attestation.DomainResolver{ + Chain: chain, + PerSpawn: attestation.NewResolver(spawnProvider), + } + + broker := &fakeBroker{vals: fullBrokerVals()} + binding := builderBinding() + binding.EntitledIdentities = []string{sessionIdentity, "subagent-self"} + + return &mint.Service{ + APIBase: "https://api.github.com", + TTL: 5 * time.Minute, + Roles: roles.NewRegistry(), + Broker: broker, + DomainResolver: domainResolver, + Bindings: map[string]mint.RoleBinding{ + "builder": binding, + }, + MintFunc: func(_ context.Context, _ githubapp.MintRequest) (githubapp.Token, error) { + return fakeToken, nil + }, + } +} + +// TestMintForDomain_Subagent_PerSpawnMiss_RefusesNeverParentIdentity is +// direction (T+) of the mandatory lr-2a8653 regression test: a subagent +// invocation (DomainLocalSubagent) whose per-spawn attestation source MISSES, +// with the session sidecar present and holding the PARENT identity, must +// refuse fail-closed — MintFunc must never be called, and the mint must never +// succeed as the parent's identity. +func TestMintForDomain_Subagent_PerSpawnMiss_RefusesNeverParentIdentity(t *testing.T) { + const parentIdentity = "holden" + svc := buildDomainMintService(t, false /* spawnHit */, parentIdentity) + + mintCalled := false + svc.MintFunc = func(_ context.Context, _ githubapp.MintRequest) (githubapp.Token, error) { + mintCalled = true + return fakeToken, nil + } + + _, err := svc.MintForDomain(context.Background(), attestation.DomainLocalSubagent, "builder", nil) + if err == nil { + t.Fatal("MintForDomain(DomainLocalSubagent) succeeded on a per-spawn MISS; want a fail-closed refusal") + } + if !errors.Is(err, attestation.ErrPerSpawnRequired) { + t.Errorf("MintForDomain(DomainLocalSubagent) error = %v, want it to wrap attestation.ErrPerSpawnRequired", err) + } + if mintCalled { + t.Error("MintFunc was called on a subagent per-spawn MISS — the confused-deputy regression lr-2a8653 exists to close") + } +} + +// TestMintForDomain_Lead_PerSpawnMiss_StillResolvesViaSession is direction +// (T-) of the mandatory lr-2a8653 regression test: the SAME per-spawn MISS, +// for a lead/director invocation (DomainLocal, no per-spawn source by +// design), must still resolve via the session sidecar and mint successfully +// — no regression of lr-86779f. +func TestMintForDomain_Lead_PerSpawnMiss_StillResolvesViaSession(t *testing.T) { + const leadIdentity = "holden" + svc := buildDomainMintService(t, false /* spawnHit */, leadIdentity) + + mintCalled := false + svc.MintFunc = func(_ context.Context, _ githubapp.MintRequest) (githubapp.Token, error) { + mintCalled = true + return fakeToken, nil + } + + _, err := svc.MintForDomain(context.Background(), attestation.DomainLocal, "builder", nil) + if err != nil { + t.Fatalf("MintForDomain(DomainLocal) unexpected error: %v", err) + } + if !mintCalled { + t.Error("MintFunc was not called on the lead-session DomainLocal path; session-sidecar fallback must still work (lr-86779f)") + } +} + // TestMintBareInstallFailsClosed asserts the combined bare-install case: a // Service constructed with zero-value RoleBinding verification fields (no // EntitledIdentities, no AppSlug/AppSlugPath) — the state a config with no From 572a7cdaf0efa2ef8f7775388fc545c014423b5d Mon Sep 17 00:00:00 2001 From: clagentic Date: Fri, 24 Jul 2026 12:17:44 -0400 Subject: [PATCH 3/4] fix(gatekeeper): select DomainLocalSubagent from the per-spawn sidecar's own env var (lr-2a8653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runMint now builds a DomainResolver with PerSpawn scoped to attestation.sidecars[0] (the deployment's per-spawn namespace, per the documented spawn-first convention) and picks DomainLocalSubagent when that entry's session_id_env is set in the process environment — the same signal sidecarProvider.Resolve itself checks for its own MISS. No new config or CLI flag: presence of the per-spawn harness's own env var IS the "a per-spawn source was expected here" signal. Closes the confused-deputy hole: a subagent spawn whose per-spawn sidecar file is missing (env set, file absent) now refuses instead of silently resolving to the parent lead's identity via the session-keyed sidecar adapter. A lead/director session (per-spawn env unset by design) is unaffected — still resolves via the session sidecar exactly as before (lr-86779f). Adds the CLI-level T+/T- regression pair mirroring the deployed two-sidecar config shape (subagent-/CLAGENTIC_SUBAGENT_ID then lore-agent-name-/CLAUDE_CODE_SESSION_ID). Test status: go build ./... and go test ./... both pass, all packages, including the pre-existing DomainA2A substrate tests (lr-2ca216) unchanged. REF lr-2ca216, lr-86779f, lr-3028e8, clagentic-loadout lr-1e16a4/PR #125. --- cmd/gatekeeper/main.go | 56 ++++++++++++++-- cmd/gatekeeper/main_test.go | 127 ++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 7 deletions(-) diff --git a/cmd/gatekeeper/main.go b/cmd/gatekeeper/main.go index d1b6139..fb67105 100644 --- a/cmd/gatekeeper/main.go +++ b/cmd/gatekeeper/main.go @@ -149,13 +149,55 @@ func runMint(args []string) error { os.Exit(2) } + // Domain-aware MISS policy (lr-2a8653): the deployment's documented + // convention (docs/SIDECAR-READ-CONTRACT.md section 2, + // docs/SETUP.md#3-multiple-sidecar-namespaces-in-one-deployment) is + // spawn-first — the FIRST entry of attestation.sidecars is the + // per-spawn namespace, checked before any session namespace. That first + // entry is scoped into its own Resolver as DomainResolver.PerSpawn, so a + // per-spawn attestation MISS can be required to fail closed rather than + // falling through to a later (e.g. session) entry in chainSidecars, + // without reordering or duplicating the shared chain itself. + domainResolver := &attestation.DomainResolver{Chain: resolver} + if len(chainSidecars) > 0 { + perSpawnProvider, err := attestation.NewSidecarProvider(chainSidecars[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "attestation: build per-spawn resolver: %v\n", err) + os.Exit(2) + } + if perSpawnProvider != nil { + domainResolver.PerSpawn = attestation.NewResolver(perSpawnProvider) + } + } + + // mintDomain names which MISS policy applies to THIS invocation + // (lr-2a8653): if the per-spawn namespace's own session-id env var is + // set in this process's environment, a per-spawn harness is active and + // this invocation is expected to have its own per-spawn sidecar file — + // so a MISS there must fail closed (DomainLocalSubagent) rather than + // silently resolving to whatever a lower-priority provider (e.g. the + // session sidecar) attests, which — inside a spawned subagent process — + // is the PARENT session's identity, not the subagent's own. When the + // per-spawn env var is unset, no per-spawn harness is active for this + // invocation (the common case for a lead/director session, which has no + // per-spawn sidecar of its own by design, lr-86779f) and DomainLocal + // preserves today's session-sidecar fallback behavior unchanged. This + // reads the same env var sidecarProvider.Resolve itself checks for its + // own MISS — no new config, no new CLI flag, no new source of truth. + mintDomain := attestation.DomainLocal + if len(chainSidecars) > 0 && chainSidecars[0].SessionIDEnv != "" { + if os.Getenv(chainSidecars[0].SessionIDEnv) != "" { + mintDomain = attestation.DomainLocalSubagent + } + } + svc := mint.Service{ - APIBase: cfg.GitHub.APIBase, - TTL: time.Duration(cfg.Token.TTLMinutes) * time.Minute, - Roles: registry, - Broker: br, - Bindings: bindings, - AttestationResolver: resolver, + APIBase: cfg.GitHub.APIBase, + TTL: time.Duration(cfg.Token.TTLMinutes) * time.Minute, + Roles: registry, + Broker: br, + Bindings: bindings, + DomainResolver: domainResolver, } var repos []string @@ -167,7 +209,7 @@ func runMint(args []string) error { repos = []string{bare} } - token, err := svc.Mint(context.Background(), *roleName, repos) + token, err := svc.MintForDomain(context.Background(), mintDomain, *roleName, repos) if err != nil { return fmt.Errorf("mint: %w", err) } diff --git a/cmd/gatekeeper/main_test.go b/cmd/gatekeeper/main_test.go index 27a15d1..d3b722b 100644 --- a/cmd/gatekeeper/main_test.go +++ b/cmd/gatekeeper/main_test.go @@ -368,6 +368,133 @@ attestation: // attestation/entitlement was not the failure mode. } +// --------------------------------------------------------------------------- +// lr-2a8653: runMint's domain-aware per-spawn MISS wiring. The deployed +// config shape (subagent-/CLAGENTIC_SUBAGENT_ID as sidecars[0], then +// lore-agent-name-/CLAUDE_CODE_SESSION_ID as sidecars[1]) is exactly the +// shape that let a subagent's per-spawn MISS silently fall through to the +// session sidecar and mint the PARENT lead's identity — the confused-deputy +// hole this task closes. Both directions are exercised through runMint +// itself, not just the lower internal/attestation and internal/mint layers, +// so the CLI's env-var-driven domain selection is covered end to end. +// --------------------------------------------------------------------------- + +// writeSpawnFirstConfig writes a config.yaml with the deployed two-sidecar +// shape (spawn-scoped entry first, session-scoped entry second) plus a +// single role entitled only to "subagent-self" — never the session/parent +// identity "holden" — so a wrongly-resolved parent identity fails +// entitlement (belt) in addition to whatever attestation-layer refusal is +// under test (suspenders). +func writeSpawnFirstConfig(t *testing.T, spawnDir, sessionDir, spawnEnv, sessionEnv string) string { + t.Helper() + return writeTempConfig(t, ` +github: + owner: testorg + +broker: + type: env + +roles: + builder: + app_id_path: secret/gk/builder/app-id + installation_id_path: secret/gk/builder/install-id + private_key_path: secret/gk/builder/key + entitled_identities: + - subagent-self + +attestation: + sidecars: + - dir: `+spawnDir+` + file_prefix: subagent- + session_id_env: `+spawnEnv+` + - dir: `+sessionDir+` + file_prefix: lore-agent-name- + session_id_env: `+sessionEnv+` +`) +} + +// TestRunMint_SubagentPerSpawnMiss_RefusesNeverParentIdentity is direction +// (T+): CLAGENTIC_SUBAGENT_ID (the per-spawn env) IS set for this process — +// signaling a per-spawn harness is active and this invocation is a subagent +// — but its sidecar file is absent (the MISS). The session sidecar file IS +// present and holds the PARENT identity "holden". runMint must refuse +// fail-closed and must NEVER reach the broker/mint path as "holden". +func TestRunMint_SubagentPerSpawnMiss_RefusesNeverParentIdentity(t *testing.T) { + spawnDir := t.TempDir() + sessionDir := t.TempDir() + + const spawnEnv = "GATEKEEPER_TEST_MAIN_LR2A8653_SUBAGENT_SPAWN" + const sessionEnv = "GATEKEEPER_TEST_MAIN_LR2A8653_SUBAGENT_SESSION" + + // Per-spawn env IS set (this is a subagent invocation) but its sidecar + // file is never written — the MISS. + t.Setenv(spawnEnv, "spawn-lr2a8653-1") + + // Session sidecar IS present and resolves to the parent lead identity — + // exactly what a subagent process inherits from its parent's + // environment (CLAUDE_CODE_SESSION_ID stays the parent's session id + // inside a subagent). + t.Setenv(sessionEnv, "session-lead-lr2a8653-1") + sessionPath := filepath.Join(sessionDir, "lore-agent-name-session-lead-lr2a8653-1") + if err := os.WriteFile(sessionPath, []byte("holden"), 0o600); err != nil { + t.Fatalf("setup: write session sidecar file: %v", err) + } + + path := writeSpawnFirstConfig(t, spawnDir, sessionDir, spawnEnv, sessionEnv) + + err := runMint([]string{"--role", "builder", "--config", path}) + if err == nil { + t.Fatal("runMint: expected a fail-closed refusal for a subagent per-spawn MISS, got nil") + } + if strings.Contains(err.Error(), "not entitled") { + t.Fatalf("runMint refused via entitlement (%q) rather than the attestation-layer fail-closed refusal — the subagent must never even resolve to the parent identity to reach the entitlement gate", err.Error()) + } + if strings.Contains(err.Error(), "config error") { + t.Fatalf("runMint returned a config-validation error, not the expected attestation refusal: %v", err) + } +} + +// TestRunMint_LeadSession_PerSpawnMiss_StillResolvesViaSession is direction +// (T-): the per-spawn env var is UNSET (no per-spawn harness active — this +// is a lead/director session invocation with no per-spawn sidecar of its own +// by design). The session sidecar IS present. runMint must still resolve via +// the session sidecar exactly as before lr-2a8653 (lr-86779f) — reaching the +// broker/mint path as the session identity, not refusing. +func TestRunMint_LeadSession_PerSpawnMiss_StillResolvesViaSession(t *testing.T) { + spawnDir := t.TempDir() + sessionDir := t.TempDir() + + const spawnEnv = "GATEKEEPER_TEST_MAIN_LR2A8653_LEAD_SPAWN_UNSET" + const sessionEnv = "GATEKEEPER_TEST_MAIN_LR2A8653_LEAD_SESSION" + + // Per-spawn env is deliberately never set: no per-spawn harness is + // active for this invocation. + os.Unsetenv(spawnEnv) + + t.Setenv(sessionEnv, "session-lead-lr2a8653-2") + sessionPath := filepath.Join(sessionDir, "lore-agent-name-session-lead-lr2a8653-2") + if err := os.WriteFile(sessionPath, []byte("subagent-self"), 0o600); err != nil { + t.Fatalf("setup: write session sidecar file: %v", err) + } + + path := writeSpawnFirstConfig(t, spawnDir, sessionDir, spawnEnv, sessionEnv) + + err := runMint([]string{"--role", "builder", "--config", path}) + // The env broker returns "" for unknown paths, which causes a downstream + // error reading app-id — expected and fine. We only assert the failure + // mode is NOT an attestation/entitlement refusal, proving resolution + // reached the broker read using the session-resolved identity. + if err != nil && strings.Contains(err.Error(), "not entitled") { + t.Fatalf("runMint returned an entitlement refusal; session-sidecar fallback did not resolve (lr-86779f regression): %v", err) + } + if err != nil && strings.Contains(err.Error(), "resolve attested identity") { + t.Fatalf("runMint returned an attestation refusal for a lead-session invocation with no per-spawn source by design: %v", err) + } + if err != nil && strings.Contains(err.Error(), "config error") { + t.Fatalf("runMint returned a config-validation error: %v", err) + } +} + // TestMintWithoutRepoSendsEmptyRepos verifies that omitting --repo results in // an empty repositories[] field (GitHub interprets absence as all repos). func TestMintWithoutRepoSendsEmptyRepos(t *testing.T) { From 69adb6840eb92be33418db6a625b5e306aa39799 Mon Sep 17 00:00:00 2001 From: clagentic Date: Fri, 24 Jul 2026 12:17:48 -0400 Subject: [PATCH 4/4] docs(setup): document DomainLocalSubagent wiring in gatekeeper mint (lr-2a8653) Section 5 previously stated the domain-aware policy shipped as substrate only, unwired into gatekeeper mint. That's no longer accurate for the local-subagent case now that cmd/gatekeeper constructs a DomainResolver and selects DomainLocalSubagent from the per-spawn sidecar's own env var. --- docs/SETUP.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index f199582..c560888 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -306,11 +306,23 @@ domain uses — it does not reorder or duplicate that chain. `DomainA2A` requires a per-spawn-scoped resolver to succeed; a MISS there is a definite refusal (`ErrPerSpawnRequired`), never a softened fallback. -**Status in this repository:** this PR ships the attestation substrate -only. No A2A mint command exists yet in `cmd/gatekeeper` — `DomainResolver` -is available for the A2A mint path (lr-a850d0, gated on a separate -substrate-ratification decision) to consume once it lands; it is not -wired into `gatekeeper mint` today. +A third domain, `DomainLocalSubagent` (lr-2a8653), applies the identical +PerSpawn-required policy to the *local* GitHub-domain mint (the one +`gatekeeper mint` performs today) when the invocation is itself a spawned +subagent expecting its own per-spawn sidecar — closing the confused-deputy +gap where a subagent's per-spawn MISS silently fell through to the session +sidecar and minted its PARENT session's identity. `DomainLocal` remains the +default for an invocation with no per-spawn source by design (a +lead/director session, lr-86779f); it is unaffected. + +**Status in this repository:** `gatekeeper mint` (`cmd/gatekeeper/main.go`) +now constructs a `DomainResolver` for every invocation. It selects +`DomainLocalSubagent` when the configured per-spawn sidecar namespace's own +`session_id_env` is set in the process environment (a per-spawn harness is +active for this invocation) and `DomainLocal` otherwise. No A2A mint command +exists yet in `cmd/gatekeeper` — `DomainA2A` is available for the A2A mint +path (lr-a850d0, gated on a separate substrate-ratification decision) to +consume once it lands, and remains otherwise unused today. ## The A2A caller-attestation contract (required fields)