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 @@ -936,7 +936,7 @@ when OTel tracing is enabled (OTel v1 / Phase 4 / #105). Both use
| `AuditEgressBlocked` | `egress_blocked` | Outbound request blocked |
| `AuditLLMCall` | `llm_call` | LLM provider call complete; `model`, `provider`, `input_tokens`, `output_tokens`, `duration_ms`, `request_id` |
| `AuditLLMCallCancelled` | `llm_call_cancelled` | Streaming call aborted mid-flight; partial usage counts |
| `AuditGuardrail` | `guardrail_check` | Mask / block / warn decision. Fields: `direction` (`inbound` / `outbound` / `tool_output`), `decision` (`masked` / `warned` / `blocked`), `guardrail`, `category`, `violation_count`. Opt-in `evidence` (redacted + truncated triggering text) via `FORGE_GUARDRAIL_CAPTURE_EVIDENCE=true` |
| `AuditGuardrail` | `guardrail_check` | Mask / block / warn decision. Fields: `gate` (`input` / `context` / `tool_call` / `output` / `stream` — from library `Result.Gate`), `decision` (`masked` / `warned` / `blocked`), `guardrail`, `category`, `violation_count`, optional `tool`. Opt-in `evidence` (redacted + truncated triggering text) via `FORGE_GUARDRAIL_CAPTURE_EVIDENCE=true`. |
| `AuditScheduleFire` | `schedule_fire` | Cron task triggered |
| `AuditScheduleComplete` | `schedule_complete` | Cron task finished |
| `AuditScheduleSkip` | `schedule_skip` | Cron task skipped (e.g. agent busy) |
Expand Down
2 changes: 1 addition & 1 deletion docs/security/audit-logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ All runtime security events are emitted as structured NDJSON to stderr with corr
| `llm_call_cancelled` | Streaming LLM call cancelled mid-flight; carries partial token counts captured up to cancellation. |
| `invocation_complete` | A2A invocation finished (auth → dispatch → engine → response). Carries `duration_ms` (wall-clock) plus aggregated `input_tokens_total` / `output_tokens_total` / `llm_call_count` / `model` / `provider`. |
| `invocation_cancelled` | A2A invocation cancelled mid-flight via `tasks/cancel` (or internal cancellation like parent ctx deadline). Carries `fields.reason` (one of `workflow_failure` / `cost_limit_exceeded` / `timeout` / `external_signal`), `duration_ms` up to cancellation, and any partial token totals consumed before the signal. See [Cancellation](#cancellation). |
| `guardrail_check` | Guardrail mask / block / warn decision. Carries `fields.direction` (`inbound` / `outbound` / `tool_output`), `fields.decision` (`masked` / `warned` / `blocked`), `fields.guardrail` + `fields.category` from the triggering violation, and `fields.violation_count`. With `FORGE_GUARDRAIL_CAPTURE_EVIDENCE=true` operators also opt into `fields.evidence` carrying the redacted + truncated triggering text. See [Guardrails — Audit Events](guardrails.md#audit-events). |
| `guardrail_check` | Guardrail mask / block / warn decision. Carries `fields.gate` (`input` / `context` / `tool_call` / `output` / `stream` — sourced from the library `Result.Gate`), `fields.decision` (`masked` / `warned` / `blocked`), `fields.guardrail` + `fields.category` from the triggering violation, and `fields.violation_count`. `fields.tool` is present on `tool_call` and on `output` events for tool return text. With `FORGE_GUARDRAIL_CAPTURE_EVIDENCE=true` operators also opt into `fields.evidence` carrying the redacted + truncated triggering text. See [Guardrails — Audit Events](guardrails.md#audit-events). |
| `auth_verify` | Inbound request authenticated successfully (with `provider`, `user_id`, `org_id`, `token_kind`) |
| `auth_fail` | Inbound request rejected (with `reason`, `token_kind`) |
| `agent_card_published` | Agent Card finalized at startup or hot-reload (with `name`, `version`, `protocol_version`, `url`, `skill_count`, `capabilities`, `security_schemes`, `card_size_bytes`, `card_sha256`). See [Agent Card reference](../reference/a2a-agent-card.md). |
Expand Down
23 changes: 20 additions & 3 deletions docs/security/guardrails.md
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ Default shape (metadata-only):
"correlation_id": "a1b2c3d4",
"task_id": "slack-...",
"fields": {
"direction": "inbound",
"gate": "input",
"decision": "masked",
"guardrail": "pii",
"category": "ssn",
Expand All @@ -536,14 +536,31 @@ Field reference:

| Field | Values | Meaning |
|-------|--------|---------|
| `direction` | `inbound` / `outbound` / `tool_output` | Which gate fired |
| `gate` | `input` / `context` / `tool_call` / `output` / `stream` | Library gate type that fired. Single source of truth; pulled from `Result.Gate`. |
| `decision` | `masked` / `warned` / `blocked` | Library decision after policy resolution |
| `guardrail` | `pii` / `moderation` / `security` / `none` / … | First violation's `Type` (`none` when violations list is empty) |
| `category` | `ssn` / `email` / `hate_speech` / … | First violation's `Category`; omitted when empty |
| `violation_count` | integer ≥ 0 | Length of `result.Violations` |
| `tool` | string | Tool name; present only when `direction=tool_output` |
| `tool` | string | Tool name; present when `gate=tool_call`, or when `gate=output` and the OutputGate fire was on a tool's return text |
| `evidence` | string | Captured triggering text; present only when opt-in is on (see below) |

The five gate values and where Forge invokes each:

| `gate` | Call site | Path |
|--------|-----------|------|
| `input` | A2A handler (`CheckInbound`) | User message arrives at `/` |
| `context` | `BeforeLLMCall` hook | Each system-role message before the LLM sees it |
| `tool_call` | `BeforeToolExec` hook | Args the agent is about to pass to a tool |
| `output` | `CheckOutbound` (response to user) + `AfterToolExec` hook (tool return text) | Distinguished by presence of `fields.tool` |
| `stream` | Not auto-wired | `CheckStream` is exposed but Forge's `ExecuteStream` is a buffered wrapper around non-streaming `Execute`. Real per-chunk streaming is a future runtime change. |

> **Migration from pre-#159 agents** — Earlier agent versions emitted
> a `direction` field instead of `gate` (values
> `inbound` / `outbound` / `tool_output`). Consumers that need to
> support both vintages should fall back: `event.fields.gate ?? deriveFromDirection(event.fields.direction)`,
> with `inbound → input`, `outbound → output`, `tool_output → output`
> (with `tool` set). New emissions only carry `gate`.

### Evidence capture (opt-in)

The default posture is **metadata-only**: the offending text never
Expand Down
31 changes: 21 additions & 10 deletions forge-cli/runtime/guardrails_audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,31 +127,42 @@ func prepareEvidence(s string, cfg GuardrailAuditConfig) string {
return coreruntime.TruncateForAudit(s, cap)
}

// emitGuardrailEvent builds and emits a guardrail_check audit event for
// one mask/block/warn decision. Routed through EmitFromContext so the
// per-invocation correlation_id, task_id, sequence number, and workflow
// tags auto-attach from the request context.
// emitGuardrailEvent builds and emits a guardrail_check audit event
// for one mask/block/warn decision. Routed through EmitFromContext so
// the per-invocation correlation_id, task_id, sequence number,
// tenancy, and workflow tags auto-attach from the request context.
//
// The fields.gate value comes directly from res.Gate — the library's
// own classification (input / context / tool_call / output / stream).
// This replaces the older direction field (issue #155 / #156) per
// issue #159's unified-gate decision. Operators consuming audit
// streams from agents pre-#159 should map the old `direction` field
// to `gate` via the documented fallback table.
//
// Behavior matrix:
//
// - audit logger nil → no-op (DB mode with platform-side audit only,
// or unit tests with no logger wired)
// - res nil → no-op (defensive; emit only when we have a
// guardrail Result to summarize)
// - audit logger nil → no-op (DB mode with platform-side audit
// only, or unit tests with no logger wired)
// - res nil → no-op (defensive)
// - CaptureEvidence on AND content non-empty → fields.evidence is
// set (redacted + truncated per cfg)
// - CaptureEvidence off → fields.evidence omitted entirely
//
// `tool` is set on the event when present (tool_call + tool_output
// paths), so SIEM consumers can distinguish output-gate fires on a
// tool result from output-gate fires on the model's response to the
// user (same gate value, different `tool` cardinality).
func (e *LibraryGuardrailEngine) emitGuardrailEvent(
ctx context.Context,
direction, tool, content string,
tool, content string,
decision string,
res *guardrails.Result,
) {
if e.auditLogger == nil || res == nil {
return
}
fields := map[string]any{
"direction": direction,
"gate": string(res.Gate),
"decision": decision,
"violation_count": len(res.Violations),
}
Expand Down
Loading
Loading