From 5b62a715c6d946c7dd39539f1fa37b4892599d82 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 6 Sep 2026 16:24:49 -0400 Subject: [PATCH 1/2] feat(identity): populate agentic-identity fields on PDP + audit events (#444 item 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:, 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. --- docs/security/audit-logging.md | 2 + forge-cli/runtime/pdp_resolver.go | 66 ++++++++++---- forge-cli/runtime/pdp_resolver_test.go | 26 ++++-- forge-cli/runtime/runner.go | 5 ++ forge-core/runtime/agent_identity.go | 55 ++++++++++++ forge-core/runtime/agent_identity_test.go | 101 ++++++++++++++++++++++ forge-core/runtime/audit.go | 81 +++++++++++++++++ 7 files changed, 314 insertions(+), 22 deletions(-) create mode 100644 forge-core/runtime/agent_identity.go create mode 100644 forge-core/runtime/agent_identity_test.go diff --git a/docs/security/audit-logging.md b/docs/security/audit-logging.md index 0bfb2f0e..b16b6d2c 100644 --- a/docs/security/audit-logging.md +++ b/docs/security/audit-logging.md @@ -527,6 +527,8 @@ Every emitted event carries: | `correlation_id` | string | request-scoped only | Per-invocation ID; groups all events for one A2A invocation | | `task_id` | string | request-scoped only | A2A task identifier (`params.id` on `tasks/send`) | | `workflow_id` / `workflow_execution_id` / `stage_id` / `step_id` / `invocation_caller` | string | optional | Populated when the request carried `X-Workflow-*` headers (FWS-2). `workflow_id` is the workflow definition (stable across runs); `workflow_execution_id` is the per-run instance (FORGE-2 / #185 split). | +| `actor_agent_id` / `attestation_level` / `delegation_mode` | string | optional | Agentic-identity promoted columns (agent-identity L1–L4, #444). `actor_agent_id` = the agent's own id; `attestation_level` = `attested:placement` in `WORKLOAD_IDENTITY_MODE=k8s_sa` (else omitted); `delegation_mode` = `agent_own` — the agent acts as its own principal. | +| `principal_sub` / `principal_iss` / `actor_workload_id` / `mandate_id` / `grant_ref` / `chain_id` / `chain_hop` | string / int | optional | Agentic-identity columns declared now, populated as their source flows land (chain fields via `X-Agent-Chain-Token`; delegated-principal + mandate/grant via L2). `principal_sub` MUST stay empty under `delegation_mode: agent_own` (a principal there is a phantom). Omitted until populated. | | `model` / `provider` | string | optional | LLM call attribution (FWS-3) | | `input_tokens` / `output_tokens` / `tokens_unavailable` | int / bool | optional | LLM call usage (FWS-3) | | `total_input_tokens` | int | optional | True input = `input_tokens` + cache read + cache creation; the bill-from field. Present on every LLM call (#431) | diff --git a/forge-cli/runtime/pdp_resolver.go b/forge-cli/runtime/pdp_resolver.go index a629732b..ef9f8d7e 100644 --- a/forge-cli/runtime/pdp_resolver.go +++ b/forge-cli/runtime/pdp_resolver.go @@ -75,6 +75,13 @@ type pdpRequest struct { // invocation timeline (otherwise the platform verdict is "unattributed"). InvocationID string `json:"invocation_id,omitempty"` Context map[string]any `json:"context,omitempty"` + + // Agent-to-agent chain correlation (#444 item 2 + item 3). Populated by + // item 3's ChainContext (X-Agent-Chain-Token); omitted until then. + // ChainHop is a pointer so hop 0 (chain origin) is distinguishable from + // "no chain". + ChainID string `json:"chain_id,omitempty"` + ChainHop *int `json:"chain_hop,omitempty"` } type pdpCaller struct { @@ -82,6 +89,22 @@ type pdpCaller struct { // EntitledAccounts is RESERVED for the relational rule (deferred): Forge has // no end-user subject at BeforeToolExec, so it is always null in v0.0.1. EntitledAccounts []string `json:"entitled_accounts,omitempty"` + + // Agentic-identity fields (agent-identity L1–L4, #444 item 2). Stamped now: + // ActorAgentID, AttestationLevel, DelegationMode (agent_own — the agent + // acts as its own principal). Populated by later flows: PrincipalSub / + // PrincipalIss accompany a delegated DelegationMode (items 3 / L2) and MUST + // stay empty under agent_own (else the platform's phantom-principal guard + // trips); ActorWorkloadID needs the SA-ref source; MandateID / GrantRef come + // from the L2 delegation flows. All omitempty → pre-#444 wire shape when unset. + PrincipalSub string `json:"principal_sub,omitempty"` + PrincipalIss string `json:"principal_iss,omitempty"` + DelegationMode string `json:"delegation_mode,omitempty"` + ActorAgentID string `json:"actor_agent_id,omitempty"` + ActorWorkloadID string `json:"actor_workload_id,omitempty"` + AttestationLevel string `json:"attestation_level,omitempty"` + MandateID string `json:"mandate_id,omitempty"` + GrantRef string `json:"grant_ref,omitempty"` } type pdpResponse struct { @@ -110,9 +133,13 @@ type pdpResolver struct { orgID string workspaceID string agentID string - timeout time.Duration - client *http.Client - logger pdpLogger + // attestationLevel + delegationMode are the process-static agentic-identity + // values sent on every PDP caller (#444 item 2), resolved once at build. + attestationLevel string + delegationMode string + timeout time.Duration + client *http.Client + logger pdpLogger } // BuildPDPResolver constructs the managed resolver from config + the platform @@ -123,14 +150,16 @@ type pdpResolver struct { func BuildPDPResolver(cfg *types.ForgeConfig, logger pdpLogger) *pdpResolver { pc := cfg.Security.Pdp return &pdpResolver{ - endpoint: pc.Endpoint, - token: os.Getenv(EnvPlatformToken), - orgID: os.Getenv(EnvOrgID), - workspaceID: os.Getenv(EnvWorkspaceID), - agentID: cfg.AgentID, - timeout: pc.Timeout, - client: &http.Client{}, - logger: logger, + endpoint: pc.Endpoint, + token: os.Getenv(EnvPlatformToken), + orgID: os.Getenv(EnvOrgID), + workspaceID: os.Getenv(EnvWorkspaceID), + agentID: cfg.AgentID, + attestationLevel: coreruntime.AttestationLevelForMode(), + delegationMode: coreruntime.DelegationAgentOwn, + timeout: pc.Timeout, + client: &http.Client{}, + logger: logger, } } @@ -153,10 +182,17 @@ func (p *pdpResolver) Resolve(ctx context.Context, hctx *coreruntime.HookContext } reqBody := pdpRequest{ - Tool: hctx.ToolName, - Op: op, - Args: args, - Caller: pdpCaller{Subject: "agent:" + p.agentID}, + Tool: hctx.ToolName, + Op: op, + Args: args, + Caller: pdpCaller{ + Subject: "agent:" + p.agentID, + // Agentic-identity (#444 item 2): the agent acts as its own + // principal, so no principal_sub accompanies agent_own. + ActorAgentID: p.agentID, + AttestationLevel: p.attestationLevel, + DelegationMode: p.delegationMode, + }, Agent: p.agentID, Session: hctx.TaskID, // Same source as the sibling pdp_decision event (emitPDPDecision uses diff --git a/forge-cli/runtime/pdp_resolver_test.go b/forge-cli/runtime/pdp_resolver_test.go index 621f72e7..5ed3e677 100644 --- a/forge-cli/runtime/pdp_resolver_test.go +++ b/forge-cli/runtime/pdp_resolver_test.go @@ -13,13 +13,14 @@ import ( func testResolver(endpoint string) *pdpResolver { return &pdpResolver{ - endpoint: endpoint, - token: "tok", - orgID: "org_x", - workspaceID: "ws_1", - agentID: "member-service", - timeout: 2 * time.Second, - client: &http.Client{}, + endpoint: endpoint, + token: "tok", + orgID: "org_x", + workspaceID: "ws_1", + agentID: "member-service", + delegationMode: coreruntime.DelegationAgentOwn, + timeout: 2 * time.Second, + client: &http.Client{}, } } @@ -58,6 +59,17 @@ func TestPDPResolver_Allow(t *testing.T) { if req.Caller.Subject != "agent:member-service" || req.Caller.EntitledAccounts != nil { t.Errorf("caller = %+v, want subject agent:member-service, no entitled_accounts", req.Caller) } + // Agentic-identity (#444 item 2): the caller carries actor_agent_id + + // delegation_mode (agent_own). No principal_sub under agent_own. + if req.Caller.ActorAgentID != "member-service" { + t.Errorf("caller.actor_agent_id = %q, want member-service", req.Caller.ActorAgentID) + } + if req.Caller.DelegationMode != coreruntime.DelegationAgentOwn { + t.Errorf("caller.delegation_mode = %q, want %q", req.Caller.DelegationMode, coreruntime.DelegationAgentOwn) + } + if req.Caller.PrincipalSub != "" { + t.Errorf("caller.principal_sub = %q, want empty under agent_own", req.Caller.PrincipalSub) + } // The turn's correlation id + task id must be sent so the platform-written // tool_call_decided event attributes to the same invocation as this agent's // pdp_decision (else it lands in "unattributed events"). diff --git a/forge-cli/runtime/runner.go b/forge-cli/runtime/runner.go index d1c60284..c40c88ed 100644 --- a/forge-cli/runtime/runner.go +++ b/forge-cli/runtime/runner.go @@ -407,6 +407,11 @@ func (r *Runner) Run(ctx context.Context) error { agentID = r.cfg.Config.AgentID } auditLogger.WithEntity("agent", agentID) + // Agentic-identity stamp (#444 item 2): actor_agent_id + attestation_level + // (attested:placement in k8s_sa mode) + delegation_mode. agent_own reflects + // the current PDP posture — the agent acts as its own principal; delegated + // principals + chain fields are layered on by items 3 / L2. + auditLogger.WithAgentIdentity(agentID, coreruntime.AttestationLevelForMode(), coreruntime.DelegationAgentOwn) // Ed25519 event signing (#213). Signing is opt-in via env: // FORGE_AUDIT_SIGNING_KEY_B64 (PKCS#8 DER base64, or PEM inline) diff --git a/forge-core/runtime/agent_identity.go b/forge-core/runtime/agent_identity.go new file mode 100644 index 00000000..28e6c6ba --- /dev/null +++ b/forge-core/runtime/agent_identity.go @@ -0,0 +1,55 @@ +package runtime + +import "os" + +// Agentic-identity vocabulary + derivation (agent-identity L1–L4, #444 item 2). +// +// These values are promoted onto audit events (security-next#41) and the PDP +// request so the platform's L2 delegation floors, the phantom-principal guard, +// mandate evaluation, and the L4 actor-breakdown / foreign-agent reports run on +// real data instead of nulls. + +// Delegation modes (`del`). The string values MUST match security-next#42 +// exactly — the PDP branches on them. +const ( + // DelegationChained — the action rode an agent-to-agent chain token. + DelegationChained = "chained" + // DelegationConnectedAccountUser — a per-user connected-account credential. + DelegationConnectedAccountUser = "connected_account:user" + // DelegationConnectedAccountWorkspace — a workspace-level connected account. + DelegationConnectedAccountWorkspace = "connected_account:workspace" + // DelegationMandate — action authorized by a mandate object. + DelegationMandate = "mandate" + // DelegationAgentOwn — the agent acts as its OWN principal, with no + // delegated human/agent subject. This is forge's current PDP posture: + // tool-call decisions are made as the agent principal (caller.subject = + // agent:), and no end-user subject is threaded into the decision. + // A principal_sub MUST NOT accompany agent_own (that would be a phantom + // principal); the delegated modes above are populated — together with a + // principal_sub — by items 3 (chain) / L2 (connected-account, mandate) + // as those flows land. + DelegationAgentOwn = "agent_own" + // DelegationNone — no delegation context at all. + DelegationNone = "none" +) + +// Attestation levels — how strongly the agent's workload identity is bound. +const ( + // AttestationPlacement — a k8s_sa projected ServiceAccount token: an + // unbound bearer, trusted by pod placement + short TTL (#444 item 6, + // security-next#42 Decision #7). + AttestationPlacement = "attested:placement" + // AttestationWorkload — a SPIRE X.509-SVID-bound token (future; item 6). + AttestationWorkload = "attested:workload" +) + +// AttestationLevelForMode derives the attestation level from the workload +// identity mode. Today only k8s_sa is handled → attested:placement; anything +// else (self-hosted / no workload identity) returns "" so the field is omitted. +// SPIRE mode (attested:workload) lands with #444 item 6. +func AttestationLevelForMode() string { + if os.Getenv(EnvWorkloadIdentityMode) == WorkloadIdentityModeK8sSA { + return AttestationPlacement + } + return "" +} diff --git a/forge-core/runtime/agent_identity_test.go b/forge-core/runtime/agent_identity_test.go new file mode 100644 index 00000000..10c2b1cd --- /dev/null +++ b/forge-core/runtime/agent_identity_test.go @@ -0,0 +1,101 @@ +package runtime + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" +) + +// Agent-identity L1–L4 (#444 item 2): the process-static agentic-identity +// stamp lands on every audit event, and the unpopulated promoted columns +// stay omitted (pre-#444 JSON shape) until their source flows land. + +func TestAttestationLevelForMode(t *testing.T) { + t.Run("k8s_sa → attested:placement", func(t *testing.T) { + t.Setenv(EnvWorkloadIdentityMode, WorkloadIdentityModeK8sSA) + if got := AttestationLevelForMode(); got != AttestationPlacement { + t.Errorf("AttestationLevelForMode() = %q, want %q", got, AttestationPlacement) + } + }) + t.Run("unset → empty", func(t *testing.T) { + t.Setenv(EnvWorkloadIdentityMode, "") + if got := AttestationLevelForMode(); got != "" { + t.Errorf("AttestationLevelForMode() = %q, want \"\"", got) + } + }) + t.Run("other mode → empty (SPIRE is a later phase)", func(t *testing.T) { + t.Setenv(EnvWorkloadIdentityMode, "attested_workload") + if got := AttestationLevelForMode(); got != "" { + t.Errorf("AttestationLevelForMode() = %q, want \"\"", got) + } + }) +} + +func TestWithAgentIdentity_StampsEveryEvent(t *testing.T) { + var buf bytes.Buffer + audit := NewAuditLogger(&buf) + audit.WithAgentIdentity("agt-99", AttestationPlacement, DelegationAgentOwn) + + audit.EmitFromContext(context.Background(), AuditEvent{Event: AuditSessionStart}) + + var evt AuditEvent + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &evt); err != nil { + t.Fatalf("decode: %v\n%s", err, buf.String()) + } + if evt.ActorAgentID != "agt-99" { + t.Errorf("actor_agent_id = %q, want agt-99", evt.ActorAgentID) + } + if evt.AttestationLevel != AttestationPlacement { + t.Errorf("attestation_level = %q, want %q", evt.AttestationLevel, AttestationPlacement) + } + if evt.DelegationMode != DelegationAgentOwn { + t.Errorf("delegation_mode = %q, want %q", evt.DelegationMode, DelegationAgentOwn) + } +} + +func TestWithAgentIdentity_UnpopulatedColumnsOmitted(t *testing.T) { + // The plumb-only columns (no source until items 3 / L2) must NOT appear + // in the JSON — a phantom principal_sub especially would trip the + // platform's guard, and every unset column must preserve the pre-#444 + // wire shape. + var buf bytes.Buffer + audit := NewAuditLogger(&buf) + audit.WithAgentIdentity("agt-99", "", DelegationAgentOwn) // non-k8s_sa: no attestation + audit.EmitFromContext(context.Background(), AuditEvent{Event: AuditSessionStart}) + + js := buf.String() + for _, forbidden := range []string{ + `"principal_sub"`, `"principal_iss"`, `"actor_workload_id"`, + `"mandate_id"`, `"grant_ref"`, `"chain_id"`, `"chain_hop"`, + `"attestation_level"`, // empty (non-k8s_sa) → omitted + } { + if strings.Contains(js, forbidden) { + t.Errorf("unpopulated column %s must be omitted, got: %s", forbidden, js) + } + } + // agent_own must NOT carry a principal (phantom-principal invariant). + var evt AuditEvent + _ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &evt) + if evt.PrincipalSub != "" { + t.Errorf("principal_sub must be empty under agent_own, got %q", evt.PrincipalSub) + } +} + +func TestWithAgentIdentity_ExplicitEventValueWins(t *testing.T) { + // An explicit per-event value (e.g. a delegated principal set by a later + // item) must take precedence over the static stamp. + var buf bytes.Buffer + audit := NewAuditLogger(&buf) + audit.WithAgentIdentity("agt-99", AttestationPlacement, DelegationAgentOwn) + audit.EmitFromContext(context.Background(), AuditEvent{ + Event: AuditSessionStart, + DelegationMode: DelegationChained, + }) + var evt AuditEvent + _ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &evt) + if evt.DelegationMode != DelegationChained { + t.Errorf("explicit delegation_mode should win: got %q, want %q", evt.DelegationMode, DelegationChained) + } +} diff --git a/forge-core/runtime/audit.go b/forge-core/runtime/audit.go index 2ea5ce63..f09ffef8 100644 --- a/forge-core/runtime/audit.go +++ b/forge-core/runtime/audit.go @@ -453,6 +453,32 @@ type AuditEvent struct { EntityID string `json:"entity_id,omitempty"` EntityType string `json:"entity_type,omitempty"` + // Agentic-identity promoted columns (agent-identity L1–L4, #444 item 2; + // schema promoted in security-next#41). Populated as their source flows + // land — all use omitempty so events without a value keep the pre-#444 + // JSON shape: + // - ActorAgentID / AttestationLevel / DelegationMode are stamped now + // (process-static; see AuditLogger.WithAgentIdentity). DelegationMode + // is agent_own today — the agent acts as its own principal. + // - PrincipalSub / PrincipalIss accompany a delegated DelegationMode and + // are populated by items 3 (chain) / L2 (connected-account, mandate). + // They MUST stay empty under agent_own (a principal there is a phantom). + // - ActorWorkloadID needs the SA-ref source (not parsed from the token). + // - MandateID / GrantRef come from the L2 delegation flows. + // - ChainID / ChainHop come from item 3's ChainContext (X-Agent-Chain-Token). + PrincipalSub string `json:"principal_sub,omitempty"` + PrincipalIss string `json:"principal_iss,omitempty"` + DelegationMode string `json:"delegation_mode,omitempty"` + ActorAgentID string `json:"actor_agent_id,omitempty"` + ActorWorkloadID string `json:"actor_workload_id,omitempty"` + AttestationLevel string `json:"attestation_level,omitempty"` + MandateID string `json:"mandate_id,omitempty"` + GrantRef string `json:"grant_ref,omitempty"` + ChainID string `json:"chain_id,omitempty"` + // ChainHop is a pointer: hop 0 (chain origin) is meaningful, so it must be + // distinguishable from "no chain" (nil → omitted). + ChainHop *int `json:"chain_hop,omitempty"` + // LLM call attribution (llm_call, llm_call_cancelled, invocation_complete). Model string `json:"model,omitempty"` Provider string `json:"provider,omitempty"` @@ -566,6 +592,16 @@ type AuditLogger struct { // See issue #164. tenantEntityID string tenantEntityType string + + // Static agentic-identity stamp, installed once at startup via + // WithAgentIdentity() (#444 item 2). These are process-static today: + // actor_agent_id is the agent's own id, attestation_level is derived + // from WORKLOAD_IDENTITY_MODE, and delegation_mode is agent_own (the + // agent acts as its own principal). Per-request identity (delegated + // principals, chain_id/chain_hop) is layered on later by items 3 / L2. + agentActorID string + agentAttestationLevel string + agentDelegationMode string } // WithTenancy installs the deployment-time tenancy stamp on the @@ -635,6 +671,35 @@ func (a *AuditLogger) entityStamp() (entityID, entityType string) { return a.tenantEntityID, a.tenantEntityType } +// WithAgentIdentity installs the deployment-time agentic-identity stamp +// (#444 item 2): the agent's own id (actor_agent_id), the attestation level +// derived from WORKLOAD_IDENTITY_MODE (attested:placement in k8s_sa mode, else +// ""), and the delegation mode (agent_own today — the agent acts as its own +// principal). Empty arguments disable the corresponding field. Called once at +// runner startup alongside WithEntity. Returns the receiver for fluent +// construction. +// +// Precedence at emit time mirrors WithEntity: an explicit value on the event +// wins; otherwise this static stamp fills in. Per-request identity fields +// (principal_sub, chain_id/chain_hop, delegated modes) are NOT set here — they +// arrive via later items and are set on the event directly. +func (a *AuditLogger) WithAgentIdentity(actorAgentID, attestationLevel, delegationMode string) *AuditLogger { + a.mu.Lock() + a.agentActorID = actorAgentID + a.agentAttestationLevel = attestationLevel + a.agentDelegationMode = delegationMode + a.mu.Unlock() + return a +} + +// agentIdentityStamp returns the static agentic-identity stamp under lock. +// Internal — emit paths use this. +func (a *AuditLogger) agentIdentityStamp() (actorAgentID, attestationLevel, delegationMode string) { + a.mu.Lock() + defer a.mu.Unlock() + return a.agentActorID, a.agentAttestationLevel, a.agentDelegationMode +} + // NewAuditLogger creates a single-sink AuditLogger wrapping the given // writer. Backward-compatible with pre-FWS-7 callers; tests and the // CLI's per-command audit loggers (channel.go / run.go) continue to @@ -764,6 +829,22 @@ func (a *AuditLogger) Emit(event AuditEvent) { event.EntityType = staticEntityType } } + // Deployment-time agentic-identity stamp (#444 item 2). Process-static, + // so — like the entity stamp — it has no ctx layer. Explicit values on + // the event (e.g. a per-request delegated principal set by a later item) + // take precedence. + if event.ActorAgentID == "" || event.AttestationLevel == "" || event.DelegationMode == "" { + staticActorID, staticAttestation, staticDelegation := a.agentIdentityStamp() + if event.ActorAgentID == "" { + event.ActorAgentID = staticActorID + } + if event.AttestationLevel == "" { + event.AttestationLevel = staticAttestation + } + if event.DelegationMode == "" { + event.DelegationMode = staticDelegation + } + } // Governance R5 (#212, chain) + R6 (#213, signing) integration. // // Hold a.mu across the whole chain-mint → sign → marshal → hash → From 1a6ddad134ba152a993cc3b705c9330b91623892 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 7 Sep 2026 10:42:00 -0400 Subject: [PATCH 2/2] review(identity): urn:agent actor id + phantom-principal hardening (#446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the #446 review (verified against security-next develop): 1. actor_agent_id now uses the urn:agent: form the platform's L4 actor-breakdown / foreign-agent reports key on (new AgentURN helper), at both the PDP caller and the audit stamp. caller.subject, audit entity_id, and pdpRequest.agent keep the bare id (their existing forms). 2. Phantom-principal hardening: the audit emitter AND the PDP request now defensively clear principal_sub/iss whenever delegation_mode is agent_own — insurance for when items 3 / L2 begin populating a delegated principal. Harmless today (principal_sub is unset). 3. Rollout note (tenancy.md): a PDP bind-strength floor of attested:placement+ denies a forge deployed without WORKLOAD_IDENTITY_MODE=k8s_sa (empty attestation ranks below placement); an `asserted` floor does not. 4. Doc nit: fixed the AttestationPlacement constant comment (item 6 is the SPIRE attested:workload phase, not placement). Tests: AgentURN format + empty guard; agent_own clears an explicitly-set principal_sub; PDP caller asserts the urn:agent: actor id + bare subject. golangci-lint clean; runtime + cli-runtime suites pass. --- docs/security/tenancy.md | 2 ++ forge-cli/runtime/pdp_resolver.go | 11 ++++++++- forge-cli/runtime/pdp_resolver_test.go | 12 ++++++---- forge-cli/runtime/runner.go | 2 +- forge-core/runtime/agent_identity.go | 19 ++++++++++++--- forge-core/runtime/agent_identity_test.go | 28 +++++++++++++++++++++++ forge-core/runtime/audit.go | 9 ++++++++ 7 files changed, 74 insertions(+), 9 deletions(-) diff --git a/docs/security/tenancy.md b/docs/security/tenancy.md index 88641a90..d78173e6 100644 --- a/docs/security/tenancy.md +++ b/docs/security/tenancy.md @@ -90,6 +90,8 @@ When an agent is deployed with **`WORKLOAD_IDENTITY_MODE=k8s_sa`**, the platform The token is read **fresh from the file on every request and never cached** — the kubelet rotates the file in place, so a cached value goes stale and is rejected (the `no_token` failure class). Like the tenancy headers, `X-Workload-Token` is **omitted entirely** (never sent empty) when workload identity is inactive: no `WORKLOAD_IDENTITY_MODE=k8s_sa`, or no readable token file (the normal case for self-hosted / non-Kubernetes deploys). Presentation is additive and never blocks a callout. +**Attestation level + PDP floors (rollout note).** In `k8s_sa` mode forge stamps `attestation_level: attested:placement` on the PDP request + audit events (agent-identity L2); without it the level is empty, which ranks *below* `attested:placement`. So a PDP policy with a per-tool bind-strength floor of `attested:placement` (or higher) will **DENY** a forge deployed **without** `WORKLOAD_IDENTITY_MODE=k8s_sa` once enforcement is on. This is correct-by-design (an unattested workload shouldn't clear a placement floor), but self-hosted / non-Kubernetes deployments must either run in `k8s_sa` mode or keep those floors at `asserted` (empty ranks equal to `asserted`, so an `asserted` floor does not deny). + ## Backwards compatibility Both `org_id` and `workspace_id` use `omitempty`. Deployments that set neither env nor header keep emitting the pre-tenancy JSON shape verbatim. Consumers that ignore unknown keys continue to work unchanged. The audit schema version is **not** bumped — additive optional fields are schema-compatible per the documented policy. diff --git a/forge-cli/runtime/pdp_resolver.go b/forge-cli/runtime/pdp_resolver.go index ef9f8d7e..bacf5b75 100644 --- a/forge-cli/runtime/pdp_resolver.go +++ b/forge-cli/runtime/pdp_resolver.go @@ -189,7 +189,9 @@ func (p *pdpResolver) Resolve(ctx context.Context, hctx *coreruntime.HookContext Subject: "agent:" + p.agentID, // Agentic-identity (#444 item 2): the agent acts as its own // principal, so no principal_sub accompanies agent_own. - ActorAgentID: p.agentID, + // actor_agent_id is the urn:agent: form the platform's L4 + // reports key on; caller.subject stays the bare "agent:". + ActorAgentID: coreruntime.AgentURN(p.agentID), AttestationLevel: p.attestationLevel, DelegationMode: p.delegationMode, }, @@ -201,6 +203,13 @@ func (p *pdpResolver) Resolve(ctx context.Context, hctx *coreruntime.HookContext InvocationID: hctx.CorrelationID, Context: map[string]any{}, } + // Phantom-principal invariant (mirrors the audit emitter): never send a + // principal_sub under agent_own. Insurance for when items 3 / L2 begin + // populating a delegated principal. + if reqBody.Caller.DelegationMode == coreruntime.DelegationAgentOwn { + reqBody.Caller.PrincipalSub = "" + reqBody.Caller.PrincipalIss = "" + } body, err := json.Marshal(reqBody) if err != nil { return p.deny(op, "marshal pdp request: "+err.Error()) diff --git a/forge-cli/runtime/pdp_resolver_test.go b/forge-cli/runtime/pdp_resolver_test.go index 5ed3e677..cae92e16 100644 --- a/forge-cli/runtime/pdp_resolver_test.go +++ b/forge-cli/runtime/pdp_resolver_test.go @@ -59,10 +59,14 @@ func TestPDPResolver_Allow(t *testing.T) { if req.Caller.Subject != "agent:member-service" || req.Caller.EntitledAccounts != nil { t.Errorf("caller = %+v, want subject agent:member-service, no entitled_accounts", req.Caller) } - // Agentic-identity (#444 item 2): the caller carries actor_agent_id + - // delegation_mode (agent_own). No principal_sub under agent_own. - if req.Caller.ActorAgentID != "member-service" { - t.Errorf("caller.actor_agent_id = %q, want member-service", req.Caller.ActorAgentID) + // Agentic-identity (#444 item 2): the caller carries actor_agent_id + // (urn:agent: — the L4-report form) + delegation_mode (agent_own). + // No principal_sub under agent_own. caller.subject stays the bare form. + if req.Caller.ActorAgentID != "urn:agent:member-service" { + t.Errorf("caller.actor_agent_id = %q, want urn:agent:member-service", req.Caller.ActorAgentID) + } + if req.Caller.Subject != "agent:member-service" { + t.Errorf("caller.subject = %q, want bare agent:member-service", req.Caller.Subject) } if req.Caller.DelegationMode != coreruntime.DelegationAgentOwn { t.Errorf("caller.delegation_mode = %q, want %q", req.Caller.DelegationMode, coreruntime.DelegationAgentOwn) diff --git a/forge-cli/runtime/runner.go b/forge-cli/runtime/runner.go index c40c88ed..69bc2ddf 100644 --- a/forge-cli/runtime/runner.go +++ b/forge-cli/runtime/runner.go @@ -411,7 +411,7 @@ func (r *Runner) Run(ctx context.Context) error { // (attested:placement in k8s_sa mode) + delegation_mode. agent_own reflects // the current PDP posture — the agent acts as its own principal; delegated // principals + chain fields are layered on by items 3 / L2. - auditLogger.WithAgentIdentity(agentID, coreruntime.AttestationLevelForMode(), coreruntime.DelegationAgentOwn) + auditLogger.WithAgentIdentity(coreruntime.AgentURN(agentID), coreruntime.AttestationLevelForMode(), coreruntime.DelegationAgentOwn) // Ed25519 event signing (#213). Signing is opt-in via env: // FORGE_AUDIT_SIGNING_KEY_B64 (PKCS#8 DER base64, or PEM inline) diff --git a/forge-core/runtime/agent_identity.go b/forge-core/runtime/agent_identity.go index 28e6c6ba..16eeb29e 100644 --- a/forge-core/runtime/agent_identity.go +++ b/forge-core/runtime/agent_identity.go @@ -36,13 +36,26 @@ const ( // Attestation levels — how strongly the agent's workload identity is bound. const ( // AttestationPlacement — a k8s_sa projected ServiceAccount token: an - // unbound bearer, trusted by pod placement + short TTL (#444 item 6, - // security-next#42 Decision #7). + // unbound bearer, trusted by pod placement + short TTL. Set now (#444 + // item 2). The unbound-bearer replay model is described under #444 item 6 + // (which adds the SPIRE alternative), security-next#42 Decision #7. AttestationPlacement = "attested:placement" - // AttestationWorkload — a SPIRE X.509-SVID-bound token (future; item 6). + // AttestationWorkload — a SPIRE X.509-SVID-bound token. Future; #444 item 6. AttestationWorkload = "attested:workload" ) +// AgentURN formats an agent id as the actor_agent_id value the platform's L4 +// actor-breakdown / foreign-agent reports key on (security-next develop): +// urn:agent:. Returns "" for an empty slug so the field stays omitted +// rather than a bare "urn:agent:". The bare id remains the form used elsewhere +// (audit entity_id, the PDP caller.subject "agent:", pdpRequest.agent). +func AgentURN(slug string) string { + if slug == "" { + return "" + } + return "urn:agent:" + slug +} + // AttestationLevelForMode derives the attestation level from the workload // identity mode. Today only k8s_sa is handled → attested:placement; anything // else (self-hosted / no workload identity) returns "" so the field is omitted. diff --git a/forge-core/runtime/agent_identity_test.go b/forge-core/runtime/agent_identity_test.go index 10c2b1cd..9440207a 100644 --- a/forge-core/runtime/agent_identity_test.go +++ b/forge-core/runtime/agent_identity_test.go @@ -33,6 +33,34 @@ func TestAttestationLevelForMode(t *testing.T) { }) } +func TestAgentURN(t *testing.T) { + if got := AgentURN("agt-1788"); got != "urn:agent:agt-1788" { + t.Errorf("AgentURN() = %q, want urn:agent:agt-1788", got) + } + if got := AgentURN(""); got != "" { + t.Errorf("AgentURN(\"\") = %q, want \"\" (no bare urn:agent: prefix)", got) + } +} + +func TestWithAgentIdentity_ClearsPhantomPrincipalUnderAgentOwn(t *testing.T) { + // Insurance for items 3 / L2: even if a caller wrongly pairs a principal + // with agent_own, the emitter clears it so no phantom principal reaches + // the audit stream / PDP. + var buf bytes.Buffer + audit := NewAuditLogger(&buf) + audit.WithAgentIdentity("urn:agent:x", AttestationPlacement, DelegationAgentOwn) + audit.EmitFromContext(context.Background(), AuditEvent{ + Event: AuditSessionStart, + PrincipalSub: "user:should-be-cleared", + PrincipalIss: "https://idp.example", + }) + var evt AuditEvent + _ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &evt) + if evt.PrincipalSub != "" || evt.PrincipalIss != "" { + t.Errorf("agent_own must clear principal_sub/iss, got sub=%q iss=%q", evt.PrincipalSub, evt.PrincipalIss) + } +} + func TestWithAgentIdentity_StampsEveryEvent(t *testing.T) { var buf bytes.Buffer audit := NewAuditLogger(&buf) diff --git a/forge-core/runtime/audit.go b/forge-core/runtime/audit.go index f09ffef8..19aa96fe 100644 --- a/forge-core/runtime/audit.go +++ b/forge-core/runtime/audit.go @@ -845,6 +845,15 @@ func (a *AuditLogger) Emit(event AuditEvent) { event.DelegationMode = staticDelegation } } + // Phantom-principal invariant: agent_own means the agent acts as its own + // principal, so a principal_sub/iss must NOT ride along. Clear defensively + // at this choke point so a future item (3 / L2) that starts setting + // principal_sub can never emit a phantom principal by pairing it with + // agent_own. Harmless today (principal_sub is unset). + if event.DelegationMode == DelegationAgentOwn { + event.PrincipalSub = "" + event.PrincipalIss = "" + } // Governance R5 (#212, chain) + R6 (#213, signing) integration. // // Hold a.mu across the whole chain-mint → sign → marshal → hash →