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
2 changes: 2 additions & 0 deletions docs/security/audit-logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
2 changes: 2 additions & 0 deletions docs/security/tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
75 changes: 60 additions & 15 deletions forge-cli/runtime/pdp_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,36 @@ 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 {
Subject string `json:"subject,omitempty"`
// 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 {
Expand Down Expand Up @@ -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
Expand All @@ -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,
}
}

Expand All @@ -153,10 +182,19 @@ 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.
// actor_agent_id is the urn:agent:<slug> form the platform's L4
// reports key on; caller.subject stays the bare "agent:<id>".
ActorAgentID: coreruntime.AgentURN(p.agentID),
AttestationLevel: p.attestationLevel,
DelegationMode: p.delegationMode,
},
Agent: p.agentID,
Session: hctx.TaskID,
// Same source as the sibling pdp_decision event (emitPDPDecision uses
Expand All @@ -165,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())
Expand Down
30 changes: 23 additions & 7 deletions forge-cli/runtime/pdp_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
}
}

Expand Down Expand Up @@ -58,6 +59,21 @@ 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
// (urn:agent:<slug> — 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)
}
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").
Expand Down
5 changes: 5 additions & 0 deletions forge-cli/runtime/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(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)
Expand Down
68 changes: 68 additions & 0 deletions forge-core/runtime/agent_identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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:<id>), 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. 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; #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:<slug>. 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:<id>", 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.
// SPIRE mode (attested:workload) lands with #444 item 6.
func AttestationLevelForMode() string {
if os.Getenv(EnvWorkloadIdentityMode) == WorkloadIdentityModeK8sSA {
return AttestationPlacement
}
return ""
}
129 changes: 129 additions & 0 deletions forge-core/runtime/agent_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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 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)
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)
}
}
Loading
Loading