Skip to content

Commit 72d3791

Browse files
authored
Merge pull request #221 from initializ/feat/gov-r4a-modify-generalization-v2
feat(guardrails): generalize MODIFY policy decision beyond cli_execute
2 parents 5dfb4da + b240550 commit 72d3791

11 files changed

Lines changed: 536 additions & 85 deletions

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,31 @@
22

33
## Unreleased
44

5+
### Changed (potentially breaking)
6+
7+
- **Guardrail MODIFY / DENY now apply to every tool, not just
8+
`cli_execute` (#209 / governance R4a).** Pre-#209 the
9+
`SkillGuardrailEngine` short-circuited on any tool whose name
10+
wasn't `cli_execute`, so `deny_commands` / `deny_output` patterns
11+
silently no-op'd for `web_search`, `http_request`, MCP calls, and
12+
every custom tool. The short-circuit is removed.
13+
- **Match-target asymmetry to be aware of**: for `cli_execute`
14+
the match target is still the reconstructed shell command line
15+
(`binary arg1 arg2 …`) so existing shell-style patterns keep
16+
working. For every other tool the match target is the raw
17+
tool-input JSON as the LLM produced it. See
18+
`docs/security/policy-decisions.md` for migration guidance.
19+
- **`GuardrailChecker.CheckInbound` / `CheckOutbound` signatures
20+
change** to return `(PolicyResult, error)` — the new
21+
`PolicyDecision` enum (`Allow` < `Modify` < `StepUp` < `Defer`
22+
< `Deny`, ordered by restrictiveness) is the R4 taxonomy from
23+
the governance framework. `StepUp`/`Defer` are reserved for R4b
24+
(#210) / R4c (#211) and not yet emitted; callers today still
25+
read only Allow / Modify / Deny.
26+
- Every `guardrail_check` audit event already carried a
27+
`fields.decision` string; nothing on the audit wire shape
28+
changes.
29+
530
### Added
631

732
- **Anthropic-format custom URLs + AWS Bedrock SigV4 outbound auth

docs/security/policy-decisions.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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+
### ⚠️ Behavior change: match target asymmetry
35+
36+
`deny_commands` patterns match against **different target strings**
37+
depending on the tool:
38+
39+
| Tool | Match target |
40+
|------------------|---------------------------------------------------------|
41+
| `cli_execute` | Reconstructed shell command line: `binary arg1 arg2 …` |
42+
| any other tool | The raw tool-input JSON verbatim |
43+
44+
For `cli_execute` this preserves the pre-#209 semantics — patterns
45+
authored as shell-style regexes (`\bget\s+secrets?\b`, `rm\s+-rf`)
46+
still fire the same way against the parsed `binary`+`args`.
47+
48+
For every other tool, the match runs against the JSON payload as
49+
the LLM produced it. A pattern like `"query":"kubectl get secrets"`
50+
will match a `web_search` invocation whose LLM-provided arguments
51+
happen to contain that substring; a pattern like `rm\s+-rf` will
52+
NOT (there's no reconstructed command line for `web_search`).
53+
54+
**Migration**: operators upgrading a pre-#209 skill should audit
55+
their `deny_commands` list. A permissive shell-style pattern that
56+
previously affected only `cli_execute` may now start denying
57+
`web_search` / `http_request` / MCP calls whose JSON body happens
58+
to contain that text. Split rules that were meant for one tool
59+
family into per-tool patterns anchored on JSON structure (e.g.
60+
`"binary":"kubectl"` for cli_execute-only matches) if the wider
61+
scope is undesirable.
62+
63+
### Block/redact ordering
64+
65+
`applyOutputPolicy` evaluates `deny_output` filters in two passes:
66+
67+
1. **Block pass** — every `block` pattern is checked against the
68+
**original** content. A single match short-circuits the whole
69+
call to `Deny`.
70+
2. **Redact pass** — every `redact` pattern is applied cumulatively
71+
to the (possibly already-partially-redacted) content, returning
72+
`Modify` if any substitution happened.
73+
74+
The two-pass design guarantees an earlier `redact` cannot suppress a
75+
later `block` match by rewriting the substring the block pattern
76+
would have caught — a `Deny`-worthy string is never silently
77+
downgraded to `Modify`.
78+
79+
## Audit event mapping
80+
81+
Every `guardrail_check` event carries a `fields.decision` string:
82+
83+
- `allowed` — Allow
84+
- `masked` — Modify
85+
- `blocked` — Deny (enforce mode)
86+
- `warned` — Deny (warn mode)
87+
88+
These strings predate the `PolicyDecision` enum by several sprints —
89+
they stay for SIEM stability. Consumers building on the enum should
90+
map `allowed ↔ allow`, `masked ↔ modify`, `blocked ↔ deny`.
91+
92+
## Test surface
93+
94+
- `forge-core/runtime/policy_decision_test.go` — enum semantics +
95+
`applyOutputPolicy` behavior across tools.
96+
- `forge-core/runtime/skill_guardrails_test.go``deny_commands`
97+
and `deny_output` MODIFY paths fire for non-cli_execute tools.

forge-cli/runtime/guardrails_engine.go

Lines changed: 36 additions & 19 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
@@ -186,8 +189,7 @@ func (e *LibraryGuardrailEngine) CheckInbound(ctx context.Context, msg *a2a.Mess
186189
})
187190
if err != nil {
188191
e.logger.Warn("guardrail input gate error", map[string]any{"error": err.Error()})
189-
result = nil
190-
return nil
192+
return coreruntime.Allow(), nil
191193
}
192194

193195
switch result.Decision {
@@ -202,47 +204,66 @@ func (e *LibraryGuardrailEngine) CheckInbound(ctx context.Context, msg *a2a.Mess
202204
e.emitGuardrailEvent(ctx, "", result.MaskedContent, guardrailResultMasked, result)
203205
evidenceContent = result.MaskedContent
204206
decisionString = guardrailResultMasked
207+
return coreruntime.Modify(result.MaskedContent, violationSummary(result)), nil
205208
}
206209
case guardrails.DecisionBlock:
207210
desc := violationSummary(result)
208211
if e.enforce {
209212
e.emitGuardrailEvent(ctx, "", text, guardrailResultBlocked, result)
210213
evidenceContent = text
211214
decisionString = guardrailResultBlocked
212-
return fmt.Errorf("input blocked: %s", desc)
215+
return coreruntime.Deny(desc), fmt.Errorf("input blocked: %s", desc)
213216
}
214217
e.logger.Warn("guardrail input violation (warn mode)", map[string]any{"detail": desc})
215218
e.emitGuardrailEvent(ctx, "", text, guardrailResultWarned, result)
216219
evidenceContent = text
217220
decisionString = guardrailResultWarned
218221
}
219-
return nil
222+
return coreruntime.Allow(), nil
220223
}
221224

222225
// CheckOutbound validates an outbound (agent) message via OutputGate.
223226
// Masked content is applied in-place; blocked content returns an error
224227
// only in enforce mode. One guardrail.output span per text part — the
225228
// trace tree mirrors the part-level iteration.
226-
func (e *LibraryGuardrailEngine) CheckOutbound(ctx context.Context, msg *a2a.Message) error {
229+
//
230+
// The aggregated PolicyResult follows the MOST-RESTRICTIVE part's
231+
// outcome per the PolicyDecision ordering (Allow < Modify < StepUp <
232+
// Defer < Deny). The strict `>` comparison keeps the FIRST part at
233+
// each severity level — subsequent equal-severity parts do not
234+
// overwrite Modified. In practice callers today only inspect the
235+
// mutated msg.Parts (each part carries its own redaction) — the
236+
// aggregate.Modified string is scaffolding for future R4b/R4c
237+
// callers that need a single-string projection.
238+
func (e *LibraryGuardrailEngine) CheckOutbound(ctx context.Context, msg *a2a.Message) (coreruntime.PolicyResult, error) {
239+
aggregate := coreruntime.Allow()
227240
for i, p := range msg.Parts {
228241
if p.Kind != a2a.PartKindText || p.Text == "" {
229242
continue
230243
}
231244

232245
original := p.Text
233-
blockErr := e.checkOneOutboundPart(ctx, msg, i, original)
246+
partResult, blockErr := e.checkOneOutboundPart(ctx, msg, i, original)
234247
if blockErr != nil {
235-
return blockErr
248+
return partResult, blockErr
249+
}
250+
// Escalate aggregate when this part is more restrictive. Uses
251+
// the PolicyDecision ordinal-as-severity contract established
252+
// in forge-core/runtime/guardrails.go: constants are ordered
253+
// Allow < Modify < StepUp < Defer < Deny, so a numeric > is
254+
// safe here even when StepUp/Defer flow through in future.
255+
if partResult.Decision > aggregate.Decision {
256+
aggregate = partResult
236257
}
237258
}
238-
return nil
259+
return aggregate, nil
239260
}
240261

241262
// checkOneOutboundPart runs OutputGate over a single text part. Split
242263
// from CheckOutbound so the per-part span has a clean lifetime (open
243264
// at function entry, deferred close at exit) without juggling state
244265
// across iterations.
245-
func (e *LibraryGuardrailEngine) checkOneOutboundPart(ctx context.Context, msg *a2a.Message, i int, original string) error {
266+
func (e *LibraryGuardrailEngine) checkOneOutboundPart(ctx context.Context, msg *a2a.Message, i int, original string) (coreruntime.PolicyResult, error) {
246267
ctx, span := startGuardrailSpan(ctx, "output", "")
247268
var (
248269
evidenceContent string
@@ -263,8 +284,7 @@ func (e *LibraryGuardrailEngine) checkOneOutboundPart(ctx context.Context, msg *
263284
})
264285
if err != nil {
265286
e.logger.Warn("guardrail output gate error", map[string]any{"error": err.Error()})
266-
result = nil
267-
return nil
287+
return coreruntime.Allow(), nil
268288
}
269289

270290
switch result.Decision {
@@ -275,21 +295,22 @@ func (e *LibraryGuardrailEngine) checkOneOutboundPart(ctx context.Context, msg *
275295
e.emitGuardrailEvent(ctx, "", result.MaskedContent, guardrailResultMasked, result)
276296
evidenceContent = result.MaskedContent
277297
decisionString = guardrailResultMasked
298+
return coreruntime.Modify(result.MaskedContent, violationSummary(result)), nil
278299
}
279300
case guardrails.DecisionBlock:
280301
desc := violationSummary(result)
281302
if e.enforce {
282303
e.emitGuardrailEvent(ctx, "", original, guardrailResultBlocked, result)
283304
evidenceContent = original
284305
decisionString = guardrailResultBlocked
285-
return fmt.Errorf("output blocked: %s", desc)
306+
return coreruntime.Deny(desc), fmt.Errorf("output blocked: %s", desc)
286307
}
287308
e.logger.Warn("guardrail output violation (warn mode)", map[string]any{"detail": desc})
288309
e.emitGuardrailEvent(ctx, "", original, guardrailResultWarned, result)
289310
evidenceContent = original
290311
decisionString = guardrailResultWarned
291312
}
292-
return nil
313+
return coreruntime.Allow(), nil
293314
}
294315

295316
// CheckToolCall validates the arguments the agent is about to pass to
@@ -324,7 +345,6 @@ func (e *LibraryGuardrailEngine) CheckToolCall(ctx context.Context, toolName, ar
324345
"tool": toolName,
325346
"error": err.Error(),
326347
})
327-
result = nil
328348
return args, nil
329349
}
330350

@@ -391,7 +411,6 @@ func (e *LibraryGuardrailEngine) CheckToolOutput(ctx context.Context, toolName,
391411
"tool": toolName,
392412
"error": err.Error(),
393413
})
394-
result = nil
395414
return text, nil
396415
}
397416

@@ -453,7 +472,6 @@ func (e *LibraryGuardrailEngine) CheckContext(ctx context.Context, content strin
453472
})
454473
if err != nil {
455474
e.logger.Warn("guardrail context gate error", map[string]any{"error": err.Error()})
456-
result = nil
457475
return content, nil
458476
}
459477

@@ -514,7 +532,6 @@ func (e *LibraryGuardrailEngine) CheckStream(ctx context.Context, chunk string)
514532
})
515533
if err != nil {
516534
e.logger.Warn("guardrail stream gate error", map[string]any{"error": err.Error()})
517-
result = nil
518535
return chunk, nil
519536
}
520537

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

0 commit comments

Comments
 (0)