Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 49 additions & 7 deletions cmd/gatekeeper/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
127 changes: 127 additions & 0 deletions cmd/gatekeeper/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
22 changes: 17 additions & 5 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
90 changes: 64 additions & 26 deletions internal/attestation/domain_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
}

Expand Down
Loading
Loading