Skip to content

feat(identity): present per-agent workload token on platform callouts (#444 item 1) - #445

Merged
initializ-mk merged 2 commits into
mainfrom
feat/workload-token-presentation
Sep 6, 2026
Merged

feat(identity): present per-agent workload token on platform callouts (#444 item 1)#445
initializ-mk merged 2 commits into
mainfrom
feat/workload-token-presentation

Conversation

@initializ-mk

@initializ-mk initializ-mk commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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 as X-Workload-Token on every platform-authenticated callout, so the platform can bind the call to the agent's workload identity — e.g. an agent bound to svc-runbooks can no longer fetch svc-security's token. Once this ships, agent-builder flips WORKLOAD_ENTITLEMENT_ENFORCE on.

How

  • New forge-core/runtime helper WorkloadToken() / StampWorkloadToken(h):
    • Reads the token fresh from INITIALIZ_WORKLOAD_TOKEN_PATH (default /var/run/secrets/initializ.ai/workload/token) on every requestnever cached. The kubelet rotates the file in place; a cached value goes stale and the platform's TokenReview rejects it (the no_token failure class the L1 contract warns about).
    • Gated on WORKLOAD_IDENTITY_MODE=k8s_sa; header omitted entirely (never sent empty) when inactive — matching the Org-Id/Workspace-Id tenancy-header contract. The empty case is the normal path for self-hosted / non-Kubernetes deploys; presentation is additive and never blocks a callout.
  • Stamped at all five platform-callout sites (there's no shared stamper today, but they all reach forge-core/runtime): MCP platform token resolver, MCP authorize-URL resolver, remote session store, admission checker, PDP caller.
  • Forge does not mint — the audience (initializ:platform-token-endpoint) is baked into the projected token by the kubelet; forge only reads and forwards.

Tests

  • Helper unit tests: fresh-read (no caching, rewrite-between-calls observed), mode gate (empty / non-k8s_sa → no read), missing/empty/whitespace file → "", StampWorkloadToken sets vs omits.
  • Wire-level: the platform token endpoint receives X-Workload-Token when active, and the header is absent when inactive.
  • gofmt + golangci-lint clean (0 issues); forge-core/runtime, forge-core/mcp, forge-cli/runtime suites pass.

Docs

docs/security/tenancy.md (platform-callouts section + a dedicated X-Workload-Token subsection) and docs/reference/environment-variables.md (the two new env vars).

Scope

Item 1 only. Remaining #444 items: 2 (PDP/audit identity fields), 5 (RFC 8725 typ discipline), 3 (native ChainContext), 4 (egress-proxy chain propagation), 6 (SPIRE attested:workload), 7 (release). Items 2 and 5 are the natural next slices.

…#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 initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: changes requested. The core is correct and security-sound (verified below), but two items should be addressed before merge.

Changes requested

  1. Add per-site wire-level header assertions. Only platform_token.go has a wire test asserting X-Workload-Token is present-when-active / absent-when-inactive. Admission, PDP, remote-session, and the authorize-URL path rely solely on the shared StampWorkloadToken + its unit tests. Because this feature is a five-site N-of-N surface, a future refactor could silently drop the StampWorkloadToken(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.
  2. 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 an io.LimitReader at a few KB, or Stat-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, admission c.url, PDP p.endpoint, session r.baseURL), never a per-request user- or MCP-server-controlled URL. FetchAuthorizeURL POSTs 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 cachedos.ReadFile per 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"" unless WORKLOAD_IDENTITY_MODE=k8s_sa; header set only when non-empty, matching the Org-Id/Workspace-Id tenancy contract.
  • All five sites covered incl. shared helpers — the delegated-token path reuses the stamped doPlatformTokenRequest; remote-session's setHeaders is shared across Load/Save/Delete; the localhost audit sink is correctly excluded (no platform auth). No Bearer-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.

Comment thread forge-core/runtime/workload_token.go Outdated
if path == "" {
path = DefaultWorkloadTokenPath
}
b, err := os.ReadFile(path)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@initializ-mk

Copy link
Copy Markdown
Contributor Author

Both change requests addressed in a986bb4:

1. Bounded the token read. os.ReadFileos.Open + io.ReadAll(io.LimitReader(f, cap+1)) with an 8 KiB cap (a projected JWT is ~1 KB). An oversized/misconfigured file now fails closed to "" rather than loading it wholesale into the header. Tests: oversized file → ""; a token exactly at the cap still reads (off-by-one guard).

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 (TestPlatformAdmissionChecker_PresentsWorkloadToken), PDP (TestPDPResolver_PresentsWorkloadToken), remote session store (TestRemoteSessionStore_PresentsWorkloadToken, and since setHeaders is shared across Load/Save/Delete this guards all three), and the authorize-URL resolver (new subtest in TestFetchAuthorizeURL). Now each of the five callouts fails independently if a StampWorkloadToken call is dropped in a refactor.

golangci-lint clean; runtime / mcp / cli-runtime suites pass. Thanks for the thorough review — both were real hardening gaps.

@initializ-mk initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both requested changes resolved — verified against the branch source. ✅ All 10 CI checks green.

Change 1 — bound the token read ✅

os.ReadFileos.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 → shared setHeaders, 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.

@initializ-mk
initializ-mk merged commit 57fbb87 into main Sep 6, 2026
10 checks passed
initializ-mk added a commit that referenced this pull request Sep 6, 2026
#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
initializ-mk added a commit that referenced this pull request Sep 7, 2026
#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.
initializ-mk added a commit that referenced this pull request Sep 7, 2026
#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.
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.

1 participant