Skip to content

Commit 6204c63

Browse files
committed
feat(guardrails): generalize MODIFY policy decision beyond cli_execute (closes #209)
Governance R4a requires MODIFY to be a first-class policy decision available to any evaluated content — not a special-cased cli_execute-only path. This change: - Introduces PolicyDecision (Allow/Deny/Modify/StepUp/Defer) + PolicyResult in forge-core/runtime. StepUp and Defer are reserved for R4b/R4c (#210/#211) and not yet emitted. - Migrates GuardrailChecker.CheckInbound / CheckOutbound to return (PolicyResult, error). NoopGuardrailChecker + LibraryGuardrail Engine + all runner call sites updated. - Drops the `if toolName != "cli_execute"` short-circuits in SkillGuardrailEngine.CheckCommandInput / CheckCommandOutput. deny_commands now matches on the raw JSON of non-cli tools; deny_output redact/block fires for output of ANY tool. - Hoists the redact/block loop into a package-level applyOutputPolicy so future call sites (MCP tool result hook, RAG ingest) can reuse the same MODIFY semantics. Docs at docs/security/policy-decisions.md.
1 parent 51df9a4 commit 6204c63

10 files changed

Lines changed: 386 additions & 74 deletions

docs/security/policy-decisions.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Policy decisions
2+
3+
Forge's guardrail engine can emit one of five decisions for any
4+
evaluated piece of content:
5+
6+
| Decision | Meaning | Where it fires today |
7+
|-----------|------------------------------------------------------------|-----------------------------------------------------------|
8+
| `allow` | Content passes through unmodified | Every gate, default |
9+
| `deny` | Content is rejected — the caller must not admit it | InputGate, OutputGate, ToolCallGate, ToolOutputGate |
10+
| `modify` | Content is admissible but must be rewritten first | InputGate (masking), OutputGate (masking), ToolOutputGate (redact) |
11+
| `step_up` | Reserved for R4b (#210) — additional user interaction | not yet emitted |
12+
| `defer` | Reserved for R4c (#211) — out-of-band lookup required | not yet emitted |
13+
14+
The `PolicyDecision` type and `PolicyResult` struct live in
15+
`forge-core/runtime/guardrails.go`. See the interface docstrings on
16+
`GuardrailChecker` for the exact contract.
17+
18+
## MODIFY, generalized (#209)
19+
20+
Before #209, `modify` was implemented for exactly one path —
21+
`cli_execute` output redaction. Skill-authored `deny_output` /
22+
`deny_commands` patterns silently no-op'd for every other tool. #209
23+
removes the `cli_execute` short-circuit so:
24+
25+
- **User prompts** — the LibraryGuardrailEngine's InputGate mask
26+
decision returns `DecisionModify` from `CheckInbound`.
27+
- **LLM responses** — same via OutputGate on `CheckOutbound`.
28+
- **Any tool output**`SkillGuardrailEngine.CheckCommandOutput`
29+
now applies the redact/block loop to output of ANY tool, not just
30+
cli_execute. The loop is hoisted into a package-level
31+
`applyOutputPolicy` so future call sites (MCP tool result hook,
32+
RAG context ingestion) can reuse the same MODIFY semantics.
33+
34+
## Audit event mapping
35+
36+
Every `guardrail_check` event carries a `fields.decision` string:
37+
38+
- `allowed` — Allow
39+
- `masked` — Modify
40+
- `blocked` — Deny (enforce mode)
41+
- `warned` — Deny (warn mode)
42+
43+
These strings predate the `PolicyDecision` enum by several sprints —
44+
they stay for SIEM stability. Consumers building on the enum should
45+
map `allowed ↔ allow`, `masked ↔ modify`, `blocked ↔ deny`.
46+
47+
## Test surface
48+
49+
- `forge-core/runtime/policy_decision_test.go` — enum semantics +
50+
`applyOutputPolicy` behavior across tools.
51+
- `forge-core/runtime/skill_guardrails_test.go``deny_commands`
52+
and `deny_output` MODIFY paths fire for non-cli_execute tools.

forge-cli/runtime/guardrails_engine.go

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,13 @@ func (e *LibraryGuardrailEngine) structuredIfFileMode() *models.StructuredGuardr
154154
}
155155

156156
// CheckInbound validates an inbound (user) message via InputGate.
157-
func (e *LibraryGuardrailEngine) CheckInbound(ctx context.Context, msg *a2a.Message) error {
157+
// The returned PolicyResult carries the engine's Allow/Deny/Modify
158+
// decision (see #209). On Modify, msg.Parts is mutated in place so
159+
// downstream reads see the redacted text.
160+
func (e *LibraryGuardrailEngine) CheckInbound(ctx context.Context, msg *a2a.Message) (coreruntime.PolicyResult, error) {
158161
text := coreruntime.ExtractText(msg)
159162
if text == "" {
160-
return nil
163+
return coreruntime.Allow(), nil
161164
}
162165

163166
// Span lifecycle (issue #161): open before the library call so the
@@ -187,7 +190,7 @@ func (e *LibraryGuardrailEngine) CheckInbound(ctx context.Context, msg *a2a.Mess
187190
if err != nil {
188191
e.logger.Warn("guardrail input gate error", map[string]any{"error": err.Error()})
189192
result = nil
190-
return nil
193+
return coreruntime.Allow(), nil
191194
}
192195

193196
switch result.Decision {
@@ -202,47 +205,58 @@ func (e *LibraryGuardrailEngine) CheckInbound(ctx context.Context, msg *a2a.Mess
202205
e.emitGuardrailEvent(ctx, "", result.MaskedContent, guardrailResultMasked, result)
203206
evidenceContent = result.MaskedContent
204207
decisionString = guardrailResultMasked
208+
return coreruntime.Modify(result.MaskedContent, violationSummary(result)), nil
205209
}
206210
case guardrails.DecisionBlock:
207211
desc := violationSummary(result)
208212
if e.enforce {
209213
e.emitGuardrailEvent(ctx, "", text, guardrailResultBlocked, result)
210214
evidenceContent = text
211215
decisionString = guardrailResultBlocked
212-
return fmt.Errorf("input blocked: %s", desc)
216+
return coreruntime.Deny(desc), fmt.Errorf("input blocked: %s", desc)
213217
}
214218
e.logger.Warn("guardrail input violation (warn mode)", map[string]any{"detail": desc})
215219
e.emitGuardrailEvent(ctx, "", text, guardrailResultWarned, result)
216220
evidenceContent = text
217221
decisionString = guardrailResultWarned
218222
}
219-
return nil
223+
return coreruntime.Allow(), nil
220224
}
221225

222226
// CheckOutbound validates an outbound (agent) message via OutputGate.
223227
// Masked content is applied in-place; blocked content returns an error
224228
// only in enforce mode. One guardrail.output span per text part — the
225229
// trace tree mirrors the part-level iteration.
226-
func (e *LibraryGuardrailEngine) CheckOutbound(ctx context.Context, msg *a2a.Message) error {
230+
//
231+
// The aggregated PolicyResult follows the most-severe part's outcome:
232+
// any Deny → Deny; else any Modify → Modify (Modified reflects the
233+
// last modified part's content); else Allow.
234+
func (e *LibraryGuardrailEngine) CheckOutbound(ctx context.Context, msg *a2a.Message) (coreruntime.PolicyResult, error) {
235+
aggregate := coreruntime.Allow()
227236
for i, p := range msg.Parts {
228237
if p.Kind != a2a.PartKindText || p.Text == "" {
229238
continue
230239
}
231240

232241
original := p.Text
233-
blockErr := e.checkOneOutboundPart(ctx, msg, i, original)
242+
partResult, blockErr := e.checkOneOutboundPart(ctx, msg, i, original)
234243
if blockErr != nil {
235-
return blockErr
244+
return partResult, blockErr
245+
}
246+
// Escalate aggregate if this part is more severe than what
247+
// we've seen so far. Allow < Modify < Deny.
248+
if partResult.Decision > aggregate.Decision {
249+
aggregate = partResult
236250
}
237251
}
238-
return nil
252+
return aggregate, nil
239253
}
240254

241255
// checkOneOutboundPart runs OutputGate over a single text part. Split
242256
// from CheckOutbound so the per-part span has a clean lifetime (open
243257
// at function entry, deferred close at exit) without juggling state
244258
// across iterations.
245-
func (e *LibraryGuardrailEngine) checkOneOutboundPart(ctx context.Context, msg *a2a.Message, i int, original string) error {
259+
func (e *LibraryGuardrailEngine) checkOneOutboundPart(ctx context.Context, msg *a2a.Message, i int, original string) (coreruntime.PolicyResult, error) {
246260
ctx, span := startGuardrailSpan(ctx, "output", "")
247261
var (
248262
evidenceContent string
@@ -264,7 +278,7 @@ func (e *LibraryGuardrailEngine) checkOneOutboundPart(ctx context.Context, msg *
264278
if err != nil {
265279
e.logger.Warn("guardrail output gate error", map[string]any{"error": err.Error()})
266280
result = nil
267-
return nil
281+
return coreruntime.Allow(), nil
268282
}
269283

270284
switch result.Decision {
@@ -275,21 +289,22 @@ func (e *LibraryGuardrailEngine) checkOneOutboundPart(ctx context.Context, msg *
275289
e.emitGuardrailEvent(ctx, "", result.MaskedContent, guardrailResultMasked, result)
276290
evidenceContent = result.MaskedContent
277291
decisionString = guardrailResultMasked
292+
return coreruntime.Modify(result.MaskedContent, violationSummary(result)), nil
278293
}
279294
case guardrails.DecisionBlock:
280295
desc := violationSummary(result)
281296
if e.enforce {
282297
e.emitGuardrailEvent(ctx, "", original, guardrailResultBlocked, result)
283298
evidenceContent = original
284299
decisionString = guardrailResultBlocked
285-
return fmt.Errorf("output blocked: %s", desc)
300+
return coreruntime.Deny(desc), fmt.Errorf("output blocked: %s", desc)
286301
}
287302
e.logger.Warn("guardrail output violation (warn mode)", map[string]any{"detail": desc})
288303
e.emitGuardrailEvent(ctx, "", original, guardrailResultWarned, result)
289304
evidenceContent = original
290305
decisionString = guardrailResultWarned
291306
}
292-
return nil
307+
return coreruntime.Allow(), nil
293308
}
294309

295310
// CheckToolCall validates the arguments the agent is about to pass to

forge-cli/runtime/guardrails_engine_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,13 @@ func TestFileGuardrailEngine_CheckInbound(t *testing.T) {
4949
Parts: []a2a.Part{{Kind: a2a.PartKindText, Text: "Hello, how are you?"}},
5050
}
5151
ctx := context.Background()
52-
if err := engine.CheckInbound(ctx, msg); err != nil {
52+
if _, err := engine.CheckInbound(ctx, msg); err != nil {
5353
t.Errorf("normal message should pass inbound check: %v", err)
5454
}
5555

5656
// Empty message should pass
5757
emptyMsg := &a2a.Message{Role: "user"}
58-
if err := engine.CheckInbound(ctx, emptyMsg); err != nil {
58+
if _, err := engine.CheckInbound(ctx, emptyMsg); err != nil {
5959
t.Errorf("empty message should pass inbound check: %v", err)
6060
}
6161
}
@@ -73,7 +73,7 @@ func TestFileGuardrailEngine_CheckOutbound(t *testing.T) {
7373
Role: "agent",
7474
Parts: []a2a.Part{{Kind: a2a.PartKindText, Text: "Here is the result."}},
7575
}
76-
if err := engine.CheckOutbound(context.Background(), msg); err != nil {
76+
if _, err := engine.CheckOutbound(context.Background(), msg); err != nil {
7777
t.Errorf("normal message should pass outbound check: %v", err)
7878
}
7979
}
@@ -122,7 +122,7 @@ func TestBuildGuardrailChecker_FileMode(t *testing.T) {
122122
Role: "user",
123123
Parts: []a2a.Part{{Kind: a2a.PartKindText, Text: "hello"}},
124124
}
125-
if err := checker.CheckInbound(context.Background(), msg); err != nil {
125+
if _, err := checker.CheckInbound(context.Background(), msg); err != nil {
126126
t.Errorf("default checker should pass normal message: %v", err)
127127
}
128128
}
@@ -148,7 +148,7 @@ func TestLibraryGuardrailEngine_EmitsAuditOnInboundMask(t *testing.T) {
148148
Role: "user",
149149
Parts: []a2a.Part{{Kind: a2a.PartKindText, Text: "my email is foo@example.com please verify"}},
150150
}
151-
if err := engine.CheckInbound(context.Background(), msg); err != nil {
151+
if _, err := engine.CheckInbound(context.Background(), msg); err != nil {
152152
t.Fatalf("CheckInbound: %v", err)
153153
}
154154

@@ -264,7 +264,7 @@ func TestLibraryGuardrailEngine_OmitsEvidenceByDefault(t *testing.T) {
264264
Role: "user",
265265
Parts: []a2a.Part{{Kind: a2a.PartKindText, Text: "my email is foo@example.com"}},
266266
}
267-
if err := engine.CheckInbound(context.Background(), msg); err != nil {
267+
if _, err := engine.CheckInbound(context.Background(), msg); err != nil {
268268
t.Fatalf("CheckInbound: %v", err)
269269
}
270270

forge-cli/runtime/guardrails_tracing_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func TestCheckInbound_OpensInputSpanWithGateAttributes(t *testing.T) {
6767
Role: "user",
6868
Parts: []a2a.Part{{Kind: a2a.PartKindText, Text: "my email is foo@example.com"}},
6969
}
70-
if err := engine.CheckInbound(context.Background(), msg); err != nil {
70+
if _, err := engine.CheckInbound(context.Background(), msg); err != nil {
7171
t.Fatalf("CheckInbound: %v", err)
7272
}
7373

@@ -102,7 +102,7 @@ func TestCheckInbound_CaptureContent_StampsRedactedEvidence(t *testing.T) {
102102
Role: "user",
103103
Parts: []a2a.Part{{Kind: a2a.PartKindText, Text: "my email is foo@example.com"}},
104104
}
105-
if err := engine.CheckInbound(context.Background(), msg); err != nil {
105+
if _, err := engine.CheckInbound(context.Background(), msg); err != nil {
106106
t.Fatalf("CheckInbound: %v", err)
107107
}
108108

@@ -152,7 +152,7 @@ func TestCheckOutbound_OpensOutputSpan_NoToolAttribute(t *testing.T) {
152152
Role: "agent",
153153
Parts: []a2a.Part{{Kind: a2a.PartKindText, Text: "Here is your answer."}},
154154
}
155-
if err := engine.CheckOutbound(context.Background(), msg); err != nil {
155+
if _, err := engine.CheckOutbound(context.Background(), msg); err != nil {
156156
t.Fatalf("CheckOutbound: %v", err)
157157
}
158158

@@ -212,7 +212,7 @@ func TestCheckInbound_NoTracing_NoSpansRecorded(t *testing.T) {
212212
Role: "user",
213213
Parts: []a2a.Part{{Kind: a2a.PartKindText, Text: "hello"}},
214214
}
215-
if err := engine.CheckInbound(context.Background(), msg); err != nil {
215+
if _, err := engine.CheckInbound(context.Background(), msg); err != nil {
216216
t.Fatalf("CheckInbound: %v", err)
217217
}
218218

forge-cli/runtime/runner.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,7 +1270,7 @@ func (r *Runner) registerHandlers(srv *server.Server, executor coreruntime.Agent
12701270
server.WriteSSEEvent(w, flusher, "status", task) //nolint:errcheck
12711271

12721272
// Guardrail check inbound
1273-
if err := guardrails.CheckInbound(ctx, &params.Message); err != nil {
1273+
if _, err := guardrails.CheckInbound(ctx, &params.Message); err != nil {
12741274
task.Status = a2a.TaskStatus{
12751275
State: a2a.TaskStateFailed,
12761276
Message: &a2a.Message{
@@ -1340,7 +1340,7 @@ func (r *Runner) registerHandlers(srv *server.Server, executor coreruntime.Agent
13401340
var finalState a2a.TaskState
13411341
for respMsg := range ch {
13421342
// Guardrail check outbound
1343-
if grErr := guardrails.CheckOutbound(ctx, respMsg); grErr != nil {
1343+
if _, grErr := guardrails.CheckOutbound(ctx, respMsg); grErr != nil {
13441344
task.Status = a2a.TaskStatus{
13451345
State: a2a.TaskStateFailed,
13461346
Message: &a2a.Message{
@@ -1511,7 +1511,7 @@ func (r *Runner) executeTask(
15111511
auditLogger.EmitInvocationComplete(ctx, snap.InvocationDuration, fields)
15121512
}
15131513

1514-
if err := guardrails.CheckInbound(ctx, &params.Message); err != nil {
1514+
if _, err := guardrails.CheckInbound(ctx, &params.Message); err != nil {
15151515
task.Status = a2a.TaskStatus{
15161516
State: a2a.TaskStateFailed,
15171517
Message: &a2a.Message{
@@ -1582,7 +1582,7 @@ func (r *Runner) executeTask(
15821582
}
15831583

15841584
if respMsg != nil {
1585-
if err := guardrails.CheckOutbound(ctx, respMsg); err != nil {
1585+
if _, err := guardrails.CheckOutbound(ctx, respMsg); err != nil {
15861586
task.Status = a2a.TaskStatus{
15871587
State: a2a.TaskStateFailed,
15881588
Message: &a2a.Message{
@@ -1781,7 +1781,7 @@ func (r *Runner) registerRESTHandlers(srv *server.Server, executor coreruntime.A
17811781
store.Put(task)
17821782
server.WriteSSEEvent(w, flusher, "status", task) //nolint:errcheck
17831783

1784-
if err := guardrails.CheckInbound(ctx, &params.Message); err != nil {
1784+
if _, err := guardrails.CheckInbound(ctx, &params.Message); err != nil {
17851785
task.Status = a2a.TaskStatus{
17861786
State: a2a.TaskStateFailed,
17871787
Message: &a2a.Message{
@@ -1845,7 +1845,7 @@ func (r *Runner) registerRESTHandlers(srv *server.Server, executor coreruntime.A
18451845

18461846
var finalState a2a.TaskState
18471847
for respMsg := range ch {
1848-
if grErr := guardrails.CheckOutbound(ctx, respMsg); grErr != nil {
1848+
if _, grErr := guardrails.CheckOutbound(ctx, respMsg); grErr != nil {
18491849
task.Status = a2a.TaskStatus{
18501850
State: a2a.TaskStateFailed,
18511851
Message: &a2a.Message{

0 commit comments

Comments
 (0)