feat(identity): present per-agent workload token on platform callouts (#444 item 1) - #445
Conversation
…#444 item 1) Agent-identity L1: when an agent runs with WORKLOAD_IDENTITY_MODE=k8s_sa, agent-builder projects an audience-bound, kubelet-rotated ServiceAccount token into a file. Forge now presents it as the X-Workload-Token header on every platform-authenticated callout, so the platform's §19.13 per-agent entitlement check can bind the call to the agent's workload identity (an agent bound to svc-runbooks can no longer fetch svc-security's token). This unblocks agent-builder flipping WORKLOAD_ENTITLEMENT_ENFORCE on. - New forge-core/runtime helper WorkloadToken() / StampWorkloadToken(): reads the token FRESH per request from INITIALIZ_WORKLOAD_TOKEN_PATH (default /var/run/secrets/initializ.ai/workload/token), NEVER cached (kubelet rotates in place; caching is the no_token failure class). Gated on WORKLOAD_IDENTITY_MODE=k8s_sa; header omitted (not sent empty) when inactive, matching the Org-Id/Workspace-Id tenancy contract. - Stamped at all five platform-callout sites: MCP platform token + authorize resolvers, remote session store, admission checker, PDP. - forge does not mint — the audience (initializ:platform-token-endpoint) is baked into the projected token by the kubelet. Tests: helper unit tests (fresh-read/no-cache, mode gate, missing/empty file, omit-when-inactive) + wire-level assertions on the platform token endpoint (header present when active, absent when inactive). Docs: tenancy.md platform-callouts section + environment-variables.md. Scope: item 1 of the #444 epic (workload-token presentation). Items 2-7 (PDP/audit identity fields, typ discipline, ChainContext, egress-proxy chain, SPIRE, release) remain. Claude-Session: https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ
initializ-mk
left a comment
There was a problem hiding this comment.
Verdict: changes requested. The core is correct and security-sound (verified below), but two items should be addressed before merge.
Changes requested
- Add per-site wire-level header assertions. Only
platform_token.gohas a wire test assertingX-Workload-Tokenis present-when-active / absent-when-inactive. Admission, PDP, remote-session, and the authorize-URL path rely solely on the sharedStampWorkloadToken+ its unit tests. Because this feature is a five-site N-of-N surface, a future refactor could silently drop theStampWorkloadToken(req.Header)call at one site with no test failing. Add a one-line "header present when active" assertion at each of the five callouts (or a table test over them) so each site is independently pinned. - Bound the token file read.
os.ReadFile(path)has no size limit. The path is operator-controlled and a projected JWT is ~1 KB, but a misconfigured/oversized file is read wholesale into the header value. Cap it (e.g. read with anio.LimitReaderat a few KB, orStat-then-reject) so a bad path fails closed to""rather than loading an arbitrarily large header.
Verified correct (no leak, complete coverage)
- No credential leak — every one of the five stamps targets an operator/platform-configured endpoint (
TokenEndpoint,authorize_endpoint, admissionc.url, PDPp.endpoint, sessionr.baseURL), never a per-request user- or MCP-server-controlled URL.FetchAuthorizeURLPOSTs to the platform and only receives the third-party consent URL back (with https-validation defense-in-depth) — the SA token goes to the platform, not the MCP server. - Fresh read, never cached —
os.ReadFileper call, trimmed; correct for in-place kubelet rotation (a cache would go stale →no_token). Read cost is negligible vs these calls' network RTT. - Mode-gated, omit-not-empty —
""unlessWORKLOAD_IDENTITY_MODE=k8s_sa; header set only when non-empty, matching theOrg-Id/Workspace-Idtenancy contract. - All five sites covered incl. shared helpers — the delegated-token path reuses the stamped
doPlatformTokenRequest; remote-session'ssetHeadersis shared across Load/Save/Delete; the localhost audit sink is correctly excluded (no platform auth). NoBearer-authed platform callout is missed. - Forge does not mint — audience baked in by the kubelet projection; forge reads/forwards only. Correct trust model.
All 10 CI checks green. Docs (tenancy.md + environment-variables.md) updated in lockstep. Solid feature — the two items above are about locking it down against regression + a misconfig edge, not correctness of the happy path.
| if path == "" { | ||
| path = DefaultWorkloadTokenPath | ||
| } | ||
| b, err := os.ReadFile(path) |
There was a problem hiding this comment.
Change requested: bound this read. os.ReadFile has no size limit, so a misconfigured/oversized INITIALIZ_WORKLOAD_TOKEN_PATH is loaded wholesale into the header value. A projected JWT is ~1KB — read via an io.LimitReader (a few KB) or Stat-then-reject so a bad path fails closed to "" instead of stamping an arbitrarily large X-Workload-Token.
| } | ||
| // Present the per-agent workload token (agent-identity L1, #444); read | ||
| // fresh per request, omitted when workload identity is not active. | ||
| coreruntime.StampWorkloadToken(req.Header) |
There was a problem hiding this comment.
Change requested (representative of all five sites): this stamp has no wire-level test asserting the header is present here. Only platform_token.go has one; admission/PDP/remote-session/authorize rely on the shared helper's unit tests. Since this is a five-site N-of-N surface, a refactor could silently drop this call with nothing failing. Add a per-site "header present when active" assertion (or a table test across the five callouts).
Addresses the #445 review's two change requests: 1. Bound the projected-token read: os.Open + io.LimitReader capped at 8 KiB (a JWT is ~1 KB). An oversized/misconfigured file now fails closed to "" instead of loading an arbitrarily large header value. Tests: oversized file → "", token exactly at the cap still reads. 2. Per-site wire pins so the N-of-N stamp surface can't silently lose a site in a refactor. Added "header present when active" assertions at the four sites that lacked one: admission + PDP (forge-cli), remote session store, and the authorize-URL resolver (forge-core). The platform-token endpoint already had present/absent wire tests. golangci-lint clean; runtime/mcp/cli-runtime suites pass. Claude-Session: https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ
|
Both change requests addressed in a986bb4: 1. Bounded the token read. 2. Per-site wire pins. Added a 'header present when active' wire assertion at each of the four sites that only had shared-helper coverage — admission (
|
initializ-mk
left a comment
There was a problem hiding this comment.
Both requested changes resolved — verified against the branch source. ✅ All 10 CI checks green.
Change 1 — bound the token read ✅
os.ReadFile → os.Open + io.ReadAll(io.LimitReader(f, maxWorkloadTokenBytes+1)) with an 8 KiB cap, defer f.Close(), and fail-closed to "" on err != nil || len(b) > cap. The read-one-past-the-cap design is exactly right — it distinguishes "exactly at cap" (accepted) from "over cap" (rejected) instead of silently truncating, which a naive LimitReader(f, cap) would do. Two tests pin the boundary: oversized (cap+1) → "", and exactly-at-cap → full read. 8 KiB is generous headroom over a ~1 KB JWT.
Change 2 — per-site wire assertions ✅
All five platform callouts now have an independent wire-level test asserting X-Workload-Token reaches the server, driving the real code paths against an httptest server (not mocking the stamp):
- platform token — pre-existing in
platform_token_test.go(original commit) - authorize-URL — new
FetchAuthorizeURL"presents workload token when active" subtest, with a comment reaffirming the token goes to the platform, not the MCP server - admission — new
TestPlatformAdmissionChecker_PresentsWorkloadToken - PDP — new
TestPDPResolver_PresentsWorkloadToken - remote session — new
TestRemoteSessionStore_PresentsWorkloadToken(Load → sharedsetHeaders, covering Save/Delete too)
So the five-site N-of-N surface is now individually pinned: a future refactor dropping StampWorkloadToken at any one site fails a test there, not just the shared-helper unit tests. The delegated-token path stays covered transitively via doPlatformTokenRequest.
No new issues. The core (no-leak destinations, fresh-read/no-cache, omit-not-empty, complete site coverage) was already sound and is unchanged. LGTM for item 1 of the #444 epic — clean, responsive iteration.
#444 item 2) Adds the security-next#41 promoted identity columns to every audit event and the PDP request, populating what forge can source correctly today and plumbing the rest for items 3 / L2. Populated now (process-static, no phantom-principal risk): - actor_agent_id — the agent's own id. - attestation_level — attested:placement in WORKLOAD_IDENTITY_MODE=k8s_sa, else omitted (SPIRE attested:workload is item 6). - delegation_mode — agent_own: the agent acts as its own principal, which is forge's actual PDP posture (caller.subject = agent:<id>, no end-user subject threaded into the decision). Plumb-only (declared, omitempty, populated as sources land): - principal_sub / principal_iss — accompany a delegated delegation_mode (items 3 / L2); MUST stay empty under agent_own or the platform's phantom-principal guard trips. - actor_workload_id — needs the SA-ref source (not parsed from the token). - mandate_id / grant_ref — L2 delegation flows. - chain_id / chain_hop — item 3's ChainContext (X-Agent-Chain-Token). Implementation: - New forge-core/runtime/agent_identity.go: the `del` enum + attestation constants (exact security-next strings) + AttestationLevelForMode(). - AuditEvent gains the 10 columns (omitempty); AuditLogger.WithAgentIdentity static-stamps actor_agent_id/attestation_level/delegation_mode on every event (mirrors WithEntity), wired at runner startup. - pdpCaller gains the identity fields, pdpRequest gains chain_id/chain_hop; Resolve stamps the three now-known values. Tests: attestation derivation, audit stamp on every event, unpopulated columns omitted (incl. no phantom principal_sub), explicit-event override, PDP caller shape. golangci-lint clean; runtime + cli-runtime suites pass. Stacked on #445 (item 1) — depends on its workload_token.go mode constants. Claude-Session: https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ
#444 item 2) Adds the security-next#41 promoted identity columns to every audit event and the PDP request, populating what forge can source correctly today and plumbing the rest for items 3 / L2. Populated now (process-static, no phantom-principal risk): - actor_agent_id — the agent's own id. - attestation_level — attested:placement in WORKLOAD_IDENTITY_MODE=k8s_sa, else omitted (SPIRE attested:workload is item 6). - delegation_mode — agent_own: the agent acts as its own principal, which is forge's actual PDP posture (caller.subject = agent:<id>, no end-user subject threaded into the decision). Plumb-only (declared, omitempty, populated as sources land): - principal_sub / principal_iss — accompany a delegated delegation_mode (items 3 / L2); MUST stay empty under agent_own or the platform's phantom-principal guard trips. - actor_workload_id — needs the SA-ref source (not parsed from the token). - mandate_id / grant_ref — L2 delegation flows. - chain_id / chain_hop — item 3's ChainContext (X-Agent-Chain-Token). Implementation: - New forge-core/runtime/agent_identity.go: the `del` enum + attestation constants (exact security-next strings) + AttestationLevelForMode(). - AuditEvent gains the 10 columns (omitempty); AuditLogger.WithAgentIdentity static-stamps actor_agent_id/attestation_level/delegation_mode on every event (mirrors WithEntity), wired at runner startup. - pdpCaller gains the identity fields, pdpRequest gains chain_id/chain_hop; Resolve stamps the three now-known values. Tests: attestation derivation, audit stamp on every event, unpopulated columns omitted (incl. no phantom principal_sub), explicit-event override, PDP caller shape. golangci-lint clean; runtime + cli-runtime suites pass. Stacked on #445 (item 1) — depends on its workload_token.go mode constants.
#444 item 2) Adds the security-next#41 promoted identity columns to every audit event and the PDP request, populating what forge can source correctly today and plumbing the rest for items 3 / L2. Populated now (process-static, no phantom-principal risk): - actor_agent_id — the agent's own id. - attestation_level — attested:placement in WORKLOAD_IDENTITY_MODE=k8s_sa, else omitted (SPIRE attested:workload is item 6). - delegation_mode — agent_own: the agent acts as its own principal, which is forge's actual PDP posture (caller.subject = agent:<id>, no end-user subject threaded into the decision). Plumb-only (declared, omitempty, populated as sources land): - principal_sub / principal_iss — accompany a delegated delegation_mode (items 3 / L2); MUST stay empty under agent_own or the platform's phantom-principal guard trips. - actor_workload_id — needs the SA-ref source (not parsed from the token). - mandate_id / grant_ref — L2 delegation flows. - chain_id / chain_hop — item 3's ChainContext (X-Agent-Chain-Token). Implementation: - New forge-core/runtime/agent_identity.go: the `del` enum + attestation constants (exact security-next strings) + AttestationLevelForMode(). - AuditEvent gains the 10 columns (omitempty); AuditLogger.WithAgentIdentity static-stamps actor_agent_id/attestation_level/delegation_mode on every event (mirrors WithEntity), wired at runner startup. - pdpCaller gains the identity fields, pdpRequest gains chain_id/chain_hop; Resolve stamps the three now-known values. Tests: attestation derivation, audit stamp on every event, unpopulated columns omitted (incl. no phantom principal_sub), explicit-event override, PDP caller shape. golangci-lint clean; runtime + cli-runtime suites pass. Stacked on #445 (item 1) — depends on its workload_token.go mode constants.
Part of #444 (agent identity L1–L4 forge runtime work). This is item 1 — present the per-agent workload token — the "smallest, highest value" slice that unblocks the platform's §19.13 per-agent entitlement enforcement.
What
When an agent is deployed with
WORKLOAD_IDENTITY_MODE=k8s_sa, agent-builder provisions a per-agent k8s ServiceAccount and projects an audience-bound, kubelet-rotated SA token into a file. Forge now sends that token asX-Workload-Tokenon every platform-authenticated callout, so the platform can bind the call to the agent's workload identity — e.g. an agent bound tosvc-runbookscan no longer fetchsvc-security's token. Once this ships, agent-builder flipsWORKLOAD_ENTITLEMENT_ENFORCEon.How
forge-core/runtimehelperWorkloadToken()/StampWorkloadToken(h):INITIALIZ_WORKLOAD_TOKEN_PATH(default/var/run/secrets/initializ.ai/workload/token) on every request — never cached. The kubelet rotates the file in place; a cached value goes stale and the platform's TokenReview rejects it (theno_tokenfailure class the L1 contract warns about).WORKLOAD_IDENTITY_MODE=k8s_sa; header omitted entirely (never sent empty) when inactive — matching theOrg-Id/Workspace-Idtenancy-header contract. The empty case is the normal path for self-hosted / non-Kubernetes deploys; presentation is additive and never blocks a callout.forge-core/runtime): MCP platform token resolver, MCP authorize-URL resolver, remote session store, admission checker, PDP caller.initializ:platform-token-endpoint) is baked into the projected token by the kubelet; forge only reads and forwards.Tests
k8s_sa→ no read), missing/empty/whitespace file →"",StampWorkloadTokensets vs omits.X-Workload-Tokenwhen active, and the header is absent when inactive.gofmt+golangci-lintclean (0 issues);forge-core/runtime,forge-core/mcp,forge-cli/runtimesuites pass.Docs
docs/security/tenancy.md(platform-callouts section + a dedicatedX-Workload-Tokensubsection) anddocs/reference/environment-variables.md(the two new env vars).Scope
Item 1 only. Remaining #444 items: 2 (PDP/audit identity fields), 5 (RFC 8725
typdiscipline), 3 (nativeChainContext), 4 (egress-proxy chain propagation), 6 (SPIREattested:workload), 7 (release). Items 2 and 5 are the natural next slices.