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: 1 addition & 1 deletion .claude/skills/forge.md
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,7 @@ when OTel tracing is enabled (OTel v1 / Phase 4 / #105). Both use
| `AuditScheduleComplete` | `schedule_complete` | Cron task finished |
| `AuditScheduleSkip` | `schedule_skip` | Cron task skipped (e.g. agent busy) |
| `AuditScheduleModify` | `schedule_modify` | Schedule mutated at runtime |
| `EventAuthVerify` | `auth_verify` | Inbound request authenticated (`provider`, `user_id`, `org_id`, `token_kind`) |
| `EventAuthVerify` | `auth_verify` | Inbound request authenticated (`provider`, `user_id`, `org_id`, `token_kind`; `email` when the identity carries one). **Channel invoker:** for a channel-originated request the transport credential is the loopback token (`provider:internal`/`user_id:forge-internal`, recorded truthfully) and the human sender is stamped as `channel`/`channel_user`/`channel_email` from the `X-Forge-Channel*` headers — honored only for the runtime-internal identity (same trust gate as `applyChannelOnBehalfOf`). Slack/Teams resolve `channel_email`; Telegram (numeric id) & WhatsApp (msisdn) carry `channel_user` only |
| `EventAuthFail` | `auth_fail` | Inbound request rejected (`reason`, `token_kind`) |
| `EventMCPServerStarted` | `mcp_server_started` | MCP server handshake succeeded |
| `EventMCPServerFailed` | `mcp_server_failed` | MCP server dial / handshake failed |
Expand Down
39 changes: 37 additions & 2 deletions docs/security/audit-logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ All runtime security events are emitted as structured NDJSON to stderr with corr
| `context_compressed` | [Context compression](../core-concepts/context-compression.md) shrank content before it reached the LLM. Carries `fields.seam` (`tool_output` from the AfterToolExec hook / `request` from the client wrapper), `fields.tool`, `tokens_before` / `tokens_after` / `saved_tokens`, plus running totals `total_saved_tokens` / `total_compressions` / `total_expansions` so any single event shows the cumulative picture. Token figures are tokenizer estimates; billed truth stays in `llm_call.input_tokens`. |
| `context_expanded` | The model retrieved offloaded content via the `context_expand` tool. Carries `fields.hash`, `hit` (`false` = expired/evicted), `bytes`, the producing `tool`, `candidates` (top keep-pattern tokens mined from the retrieved content, ≤5 — lets a platform consuming the audit stream aggregate [learning](../core-concepts/context-compression.md#the-learning-loop) fleet-wide, immune to pod restarts), and the same running totals — expansions are the cost side auditors net against savings. |
| `context_pattern_suggested` | The [compression learning loop](../core-concepts/context-compression.md#the-learning-loop) surfaced a `keep_patterns` candidate: a domain-state token retrieved via `context_expand` in 3+ distinct expansions that the keep floor does not already protect. Fired once per pattern. Carries `fields.pattern`, `expansions`, `tools` (array). Review via `forge compression suggestions`. |
| `auth_verify` | Inbound request authenticated successfully (with `provider`, `user_id`, `org_id`, `token_kind`). Carries the invocation `correlation_id` (minted at ingress, before auth — see below) and, for orchestrator-dispatched calls, `workflow_execution_id` — so it groups with the task events that follow it in the same request. **Channel-originated tasks (#356):** when the request arrives through a channel adapter (Slack/Telegram/…), the runtime grafts the human sender onto the identity — `fields.user_id` / `fields.email` carry the channel user (from the adapter's `X-Forge-Channel-User` / `-Email` / `-Channel` headers) and the source is marked `channel:<adapter>`, so a tool call is attributed to the person who asked, not the bot. This graft is bound to a runtime-internal marker and never honored from an external caller. |
| `auth_verify` | Inbound request authenticated successfully (with `provider`, `user_id`, `org_id`, `token_kind`). Carries the invocation `correlation_id` (minted at ingress, before auth — see below) and, for orchestrator-dispatched calls, `workflow_execution_id` — so it groups with the task events that follow it in the same request. **Channel-originated tasks:** when the request arrives through a channel adapter (Slack/Telegram/WhatsApp/…), the transport credential is the runtime loopback token, so `fields.provider` / `fields.user_id` record it truthfully (`internal` / `forge-internal`); the human who sent the message is recorded alongside in `fields.channel` / `fields.channel_user` / `fields.channel_email` (from the adapter's `X-Forge-Channel` / `-User` / `-Email` headers). These invoker fields are honored **only** for the runtime-internal transport identity and are never accepted from an external caller. (Downstream the runtime *also* grafts that human onto the request identity — `Source: channel:<adapter>` — so the agent run and delegated MCP tools act as the person who asked; see [Authentication](authentication.md).) |
| `mcp_auth_required` | A delegated (`auth.type: user`) MCP call parked awaiting the user's consent (#330). Carries `server`, `subject`, `deadline`/`timeout_ms`, and the parked call's `correlation_id` / `task_id` / `seq` so it attributes to the invocation that triggered it (#366). |
| `mcp_auth_resolved` | The parked call's consent arrived (or the wait was canceled) and it resumed (#330). Carries `server`, `subject`, `wait_ms`. Emitted **once**, attributed to the parked invocation (#366). |
| `mcp_auth_timeout` | No consent within the window; the parked MCP call fails `no_token` (#330). Carries `server`, `subject`, `wait_ms`, `decision`. |
Expand Down Expand Up @@ -258,7 +258,42 @@ Every inbound request to `/tasks` emits exactly one of `auth_verify` or `auth_fa

`user_id` is the canonical identifier the verifier returned (ARN for AWS, JWT
`sub` for OIDC/IAP/AAD). `org_id` is the AWS account, Entra tenant GUID, or
OIDC `tid`/`org_id`-mapped claim depending on the provider.
OIDC `tid`/`org_id`-mapped claim depending on the provider. `email` is stamped
whenever the verified identity carries one, so the audit records **who**
authenticated.

**Channel invoker (end-user on-behalf-of).** When a request arrives through a
channel adapter, the transport credential is the runtime's per-process loopback
token — so `provider":"internal"` and `user_id":"forge-internal"` are recorded
truthfully — and the human who typed the message is asserted by the in-pod
channel router via the `X-Forge-Channel` / `-User` / `-Email` headers. Those are
recorded as dedicated `fields` on `auth_verify` (honored **only** when the
transport identity is the runtime loopback identity, so an external caller
cannot spoof them):

| Field | Meaning | Slack | Telegram | WhatsApp | Teams |
|-------|---------|-------|----------|----------|-------|
| `channel` | Originating adapter | `slack` | `telegram` | `whatsapp` | `msteams` |
| `channel_user` | Platform-native user id | `U08ABC…` | numeric id | phone (msisdn) | AAD object id |
| `channel_email` | Resolved profile email (when the platform has one) | ✓ (`users.info`) | — | — | ✓ (planned, Graph lookup) |

```json
{
"ts":"2026-09-23T10:15:02Z",
"event":"auth_verify",
"fields":{
"method":"POST","path":"/tasks/send",
"provider":"internal","user_id":"forge-internal","token_kind":"opaque",
"channel":"slack","channel_user":"U08ABC123","channel_email":"bob@example.com"
}
}
```

Query all invocations by a given human across channels:

```bash
jq -r 'select(.event=="auth_verify" and .fields.channel) | "\(.fields.channel)\t\(.fields.channel_email // .fields.channel_user)"' forge.log | sort | uniq -c
```

**Failed authentication:**

Expand Down
2 changes: 1 addition & 1 deletion forge-cli/internal/surface/knowledge/forge.md
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,7 @@ when OTel tracing is enabled (OTel v1 / Phase 4 / #105). Both use
| `AuditScheduleComplete` | `schedule_complete` | Cron task finished |
| `AuditScheduleSkip` | `schedule_skip` | Cron task skipped (e.g. agent busy) |
| `AuditScheduleModify` | `schedule_modify` | Schedule mutated at runtime |
| `EventAuthVerify` | `auth_verify` | Inbound request authenticated (`provider`, `user_id`, `org_id`, `token_kind`) |
| `EventAuthVerify` | `auth_verify` | Inbound request authenticated (`provider`, `user_id`, `org_id`, `token_kind`; `email` when the identity carries one). **Channel invoker:** for a channel-originated request the transport credential is the loopback token (`provider:internal`/`user_id:forge-internal`, recorded truthfully) and the human sender is stamped as `channel`/`channel_user`/`channel_email` from the `X-Forge-Channel*` headers — honored only for the runtime-internal identity (same trust gate as `applyChannelOnBehalfOf`). Slack/Teams resolve `channel_email`; Telegram (numeric id) & WhatsApp (msisdn) carry `channel_user` only |
| `EventAuthFail` | `auth_fail` | Inbound request rejected (`reason`, `token_kind`) |
| `EventMCPServerStarted` | `mcp_server_started` | MCP server handshake succeeded |
| `EventMCPServerFailed` | `mcp_server_failed` | MCP server dial / handshake failed |
Expand Down
98 changes: 91 additions & 7 deletions forge-cli/runtime/auth_audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func TestAuthAudit_EmitsAuthVerifyOnSuccess(t *testing.T) {
req := httptest.NewRequest("POST", "/tasks", nil)
id := &auth.Identity{
UserID: "alice-123",
Email: "alice@example.com", // must NOT leak into the event
Email: "alice@example.com", // recorded — audit must capture WHO authenticated
OrgID: "tenant-1",
Groups: []string{"a", "b", "c"},
Source: "oidc",
Expand Down Expand Up @@ -79,6 +79,87 @@ func TestAuthAudit_EmitsAuthVerifyOnSuccess(t *testing.T) {
if ev.Fields["token_kind"] != "jwt" {
t.Errorf("token_kind = %v, want jwt", ev.Fields["token_kind"])
}
// The invoker's email is recorded so the audit answers "who authenticated?".
if ev.Fields["email"] != "alice@example.com" {
t.Errorf("email = %v, want alice@example.com", ev.Fields["email"])
}
}

// TestAuthAudit_EmitsChannelInvoker verifies that for a channel-originated
// request — where the transport credential is the runtime loopback token and
// the human is asserted via X-Forge-Channel-* headers — auth_verify records
// both the transport credential (provider/user_id) AND the channel invoker.
func TestAuthAudit_EmitsChannelInvoker(t *testing.T) {
cases := []struct {
name string
channel string
user, email string
wantEmailPresent bool
}{
// Slack/Teams resolve a profile email.
{"slack with email", "slack", "U08ABC", "bob@example.com", true},
// Telegram carries only a numeric id; WhatsApp only a phone number.
{"telegram id only", "telegram", "987654321", "", false},
{"whatsapp number only", "whatsapp", "14155550123", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
cb := makeAuthAuditCallback(coreruntime.NewAuditLogger(&buf))

req := httptest.NewRequest("POST", "/tasks", nil)
req.Header.Set("X-Forge-Channel", tc.channel)
req.Header.Set("X-Forge-Channel-User", tc.user)
if tc.email != "" {
req.Header.Set("X-Forge-Channel-Email", tc.email)
}
// The transport identity is the runtime loopback token — the same
// trust marker applyChannelOnBehalfOf gates the graft on.
id := auth.MarkRuntimeInternal(auth.Identity{UserID: "forge-internal", Source: "internal"})

cb(req, &id, nil, "opaque")

ev := captureAudit(t, &buf)[0]
if ev.Fields["channel"] != tc.channel {
t.Errorf("channel = %v, want %q", ev.Fields["channel"], tc.channel)
}
if ev.Fields["channel_user"] != tc.user {
t.Errorf("channel_user = %v, want %q", ev.Fields["channel_user"], tc.user)
}
if tc.wantEmailPresent {
if ev.Fields["channel_email"] != tc.email {
t.Errorf("channel_email = %v, want %q", ev.Fields["channel_email"], tc.email)
}
} else if _, ok := ev.Fields["channel_email"]; ok {
t.Errorf("channel_email should be absent when the channel has no email, got %v", ev.Fields["channel_email"])
}
// Transport credential still recorded truthfully.
if ev.Fields["user_id"] != "forge-internal" || ev.Fields["provider"] != "internal" {
t.Errorf("transport credential not recorded: provider=%v user_id=%v", ev.Fields["provider"], ev.Fields["user_id"])
}
})
}
}

// TestAuthAudit_ChannelHeadersIgnoredForNonLoopback ensures a non-internal
// identity (e.g. a real OIDC caller) cannot inject a spoofed channel invoker
// via X-Forge-Channel-* headers — the fields are gated on IsRuntimeInternal.
func TestAuthAudit_ChannelHeadersIgnoredForNonLoopback(t *testing.T) {
var buf bytes.Buffer
cb := makeAuthAuditCallback(coreruntime.NewAuditLogger(&buf))

req := httptest.NewRequest("POST", "/tasks", nil)
req.Header.Set("X-Forge-Channel", "slack")
req.Header.Set("X-Forge-Channel-Email", "attacker@evil.example")

cb(req, &auth.Identity{UserID: "u", Source: "oidc"}, nil, "jwt")

ev := captureAudit(t, &buf)[0]
for _, k := range []string{"channel", "channel_user", "channel_email"} {
if _, ok := ev.Fields[k]; ok {
t.Errorf("field %q must be ignored for a non-loopback identity, got %v", k, ev.Fields[k])
}
}
}

func TestAuthAudit_EmitsAuthFailOnError(t *testing.T) {
Expand Down Expand Up @@ -123,16 +204,17 @@ func TestAuthAudit_EmitsAuthFailOnError(t *testing.T) {
}
}

func TestAuthAudit_NoPIIInPayload(t *testing.T) {
// Strong negative test: even if the Identity carries an email and
// rich Claims, the emitted audit line MUST NOT include them.
func TestAuthAudit_NoClaimsOrSecretsInPayload(t *testing.T) {
// The identity's email IS recorded (audit must capture who authenticated),
// but the raw Claims bag — which can carry arbitrary sensitive payloads
// (SSN, secrets) — MUST NEVER be emitted.
var buf bytes.Buffer
cb := makeAuthAuditCallback(coreruntime.NewAuditLogger(&buf))

req := httptest.NewRequest("POST", "/tasks", nil)
id := &auth.Identity{
UserID: "u",
Email: "secret@hr.example.com",
Email: "user@example.com",
Claims: map[string]any{
"ssn": "999-99-9999",
"secret": "deadbeef",
Expand All @@ -143,17 +225,19 @@ func TestAuthAudit_NoPIIInPayload(t *testing.T) {

line := buf.String()
forbidden := []string{
"secret@hr.example.com",
"999-99-9999",
"deadbeef",
"\"email\"",
"\"claims\"",
}
for _, f := range forbidden {
if strings.Contains(line, f) {
t.Errorf("audit line leaked %q:\n%s", f, line)
}
}
// Email is intentionally present now.
if !strings.Contains(line, "user@example.com") {
t.Errorf("expected email to be recorded in the auth_verify event:\n%s", line)
}
}

func TestAuthAudit_NilLoggerReturnsNilCallback(t *testing.T) {
Expand Down
39 changes: 37 additions & 2 deletions forge-cli/runtime/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -3506,11 +3506,25 @@ func (r *Runner) resolveAuth(auditLogger *coreruntime.AuditLogger) (auth.Middlew
// makeAuthAuditCallback returns the OnAuth callback that emits structured
// auth_verify / auth_fail audit events.
//
// Fields emitted (NO PII — never email, claims, token bytes, or secrets):
// Fields emitted (never claims, token bytes, or secrets):
//
// auth_verify: { provider, user_id, org_id, groups_count, token_kind, method, path, remote_addr }
// auth_verify: { provider, user_id, org_id, groups_count, token_kind, method, path, remote_addr,
// email?, channel?, channel_user?, channel_email? }
// auth_fail: { reason, token_kind, method, path, remote_addr }
//
// End-user (invoker) identity on auth_verify:
// - `email` is stamped whenever the verified Identity carries one (e.g. an
// OIDC user), so the audit records WHO authenticated.
// - For a CHANNEL request the transport credential is the runtime's loopback
// token (provider=internal, user_id=forge-internal) — recorded truthfully —
// and the HUMAN who typed the message is asserted by the in-pod channel
// router via the X-Forge-Channel-* headers. When the verified identity is
// the loopback identity (IsRuntimeInternal — the SAME trust gate as
// applyChannelOnBehalfOf, so external callers cannot spoof it), those
// headers are recorded as `channel` (adapter), `channel_user` (platform id:
// Slack Uxxx / Telegram numeric id / WhatsApp msisdn / Teams AAD id) and
// `channel_email` (Slack/Teams profile email; empty for Telegram/WhatsApp).
//
// Reason codes for auth_fail:
//
// missing_token → no Authorization header
Expand Down Expand Up @@ -3555,6 +3569,27 @@ func makeAuthAuditCallback(auditLogger *coreruntime.AuditLogger) func(*http.Requ
"path": req.URL.Path,
"remote_addr": req.RemoteAddr,
}
// Record WHO authenticated when the identity carries an email.
if id.Email != "" {
fields["email"] = id.Email
}
// Channel on-behalf-of invoker: the transport credential is the
// runtime loopback token, but the human who sent the message is
// asserted by the in-pod channel router via X-Forge-Channel-*.
// Honored ONLY for the loopback identity (same trust marker as
// applyChannelOnBehalfOf) so the headers can't be spoofed by an
// external caller.
if id.IsRuntimeInternal() {
if ch := strings.TrimSpace(req.Header.Get("X-Forge-Channel")); ch != "" {
fields["channel"] = ch
}
if cu := strings.TrimSpace(req.Header.Get("X-Forge-Channel-User")); cu != "" {
fields["channel_user"] = cu

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.

Note — LOW (governance, not a bug): the auth audit is now PII-bearing. This deliberately reverses the prior explicit "NO PII — never email" contract (fine — confirmed decision). Worth an explicit callout that beyond email, channel_user records raw phone numbers for WhatsApp (msisdn) and Slack/Teams profile emails — so the security-audit NDJSON now contains emails AND phone numbers. Please make sure audit retention, access controls, and any privacy/DPA documentation reflect that (the phone-number case is more sensitive than email and easy to overlook under "record the invoker"). No code change needed — just ensuring the PII posture is a conscious, documented part of the audit log's handling.

}
if ce := strings.TrimSpace(req.Header.Get("X-Forge-Channel-Email")); ce != "" {
fields["channel_email"] = ce
}
}
auditLogger.EmitFromContext(req.Context(), coreruntime.AuditEvent{
Event: coreruntime.EventAuthVerify,
CorrelationID: correlationID,
Expand Down
Loading