Skip to content

Commit b240550

Browse files
committed
fix(guardrails): reorder PolicyDecision by severity; block-before-redact
Addresses initializ-mk's review on #221. ## Enum ordinal now maps to severity (was: latent aggregate bug) Pre-fix constant order was Allow=0, Deny=1, Modify=2, so `partA.Decision > partB.Decision` in CheckOutbound's aggregate escalation was actually treating Modify as MORE severe than Deny — opposite of the "Allow < Modify < Deny" comment. Worked only because Deny short-circuits via the error return path. Reordered to Allow < Modify < StepUp < Defer < Deny so the ordinal comparison encodes restrictiveness. String() output and audit-wire strings are unchanged (SIEM-stable). CheckOutbound's aggregate escalation now correctly ranks any decision. New TestPolicyDecision_SeverityOrdering pins the contract so a future refactor can't silently break it. Also pins DecisionAllow as the zero value so `PolicyResult{}` still implicitly means Allow. ## Block-before-redact in applyOutputPolicy (was: silent Deny→Modify) Pre-fix, block detection ran against the progressively-redacted string. A `redact` rule listed before a `block` rule could rewrite the substring the block pattern would have caught, silently downgrading Deny to Modify. Post-fix, two-pass evaluation: pass 1 checks every block pattern against the ORIGINAL content and short-circuits on match; pass 2 applies redacts cumulatively. Regression test TestApplyOutputPolicy_RedactDoesNotHideEarlierBlock proves the downgrade is closed. ## Docs + CHANGELOG (was: undocumented behavior change) Dropping the cli_execute short-circuit is backward-incompatible: existing deny_commands patterns now evaluate against the raw JSON of every tool call. Added: - CHANGELOG.md "Changed (potentially breaking)" section under Unreleased with migration guidance. - docs/security/policy-decisions.md — new "Behavior change: match target asymmetry" table (cli_execute → reconstructed command line; other tools → raw JSON) and a "Block/redact ordering" section documenting the two-pass evaluation. ## Aggregate comment correction CheckOutbound's docstring said aggregate.Modified "reflects the last modified part" but strict `>` keeps the FIRST modified part at each severity level. Docstring updated to match code. ## Dead code cleanup Six pre-existing `result = nil` lines on gate-error paths where the function immediately returns — carried over from an earlier refactor. Removed while I'm here.
1 parent 6204c63 commit b240550

6 files changed

Lines changed: 173 additions & 34 deletions

File tree

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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,51 @@ removes the `cli_execute` short-circuit so:
3131
`applyOutputPolicy` so future call sites (MCP tool result hook,
3232
RAG context ingestion) can reuse the same MODIFY semantics.
3333

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+
3479
## Audit event mapping
3580

3681
Every `guardrail_check` event carries a `fields.decision` string:

forge-cli/runtime/guardrails_engine.go

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,6 @@ func (e *LibraryGuardrailEngine) CheckInbound(ctx context.Context, msg *a2a.Mess
189189
})
190190
if err != nil {
191191
e.logger.Warn("guardrail input gate error", map[string]any{"error": err.Error()})
192-
result = nil
193192
return coreruntime.Allow(), nil
194193
}
195194

@@ -228,9 +227,14 @@ func (e *LibraryGuardrailEngine) CheckInbound(ctx context.Context, msg *a2a.Mess
228227
// only in enforce mode. One guardrail.output span per text part — the
229228
// trace tree mirrors the part-level iteration.
230229
//
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.
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.
234238
func (e *LibraryGuardrailEngine) CheckOutbound(ctx context.Context, msg *a2a.Message) (coreruntime.PolicyResult, error) {
235239
aggregate := coreruntime.Allow()
236240
for i, p := range msg.Parts {
@@ -243,8 +247,11 @@ func (e *LibraryGuardrailEngine) CheckOutbound(ctx context.Context, msg *a2a.Mes
243247
if blockErr != nil {
244248
return partResult, blockErr
245249
}
246-
// Escalate aggregate if this part is more severe than what
247-
// we've seen so far. Allow < Modify < Deny.
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.
248255
if partResult.Decision > aggregate.Decision {
249256
aggregate = partResult
250257
}
@@ -277,7 +284,6 @@ func (e *LibraryGuardrailEngine) checkOneOutboundPart(ctx context.Context, msg *
277284
})
278285
if err != nil {
279286
e.logger.Warn("guardrail output gate error", map[string]any{"error": err.Error()})
280-
result = nil
281287
return coreruntime.Allow(), nil
282288
}
283289

@@ -339,7 +345,6 @@ func (e *LibraryGuardrailEngine) CheckToolCall(ctx context.Context, toolName, ar
339345
"tool": toolName,
340346
"error": err.Error(),
341347
})
342-
result = nil
343348
return args, nil
344349
}
345350

@@ -406,7 +411,6 @@ func (e *LibraryGuardrailEngine) CheckToolOutput(ctx context.Context, toolName,
406411
"tool": toolName,
407412
"error": err.Error(),
408413
})
409-
result = nil
410414
return text, nil
411415
}
412416

@@ -468,7 +472,6 @@ func (e *LibraryGuardrailEngine) CheckContext(ctx context.Context, content strin
468472
})
469473
if err != nil {
470474
e.logger.Warn("guardrail context gate error", map[string]any{"error": err.Error()})
471-
result = nil
472475
return content, nil
473476
}
474477

@@ -529,7 +532,6 @@ func (e *LibraryGuardrailEngine) CheckStream(ctx context.Context, chunk string)
529532
})
530533
if err != nil {
531534
e.logger.Warn("guardrail stream gate error", map[string]any{"error": err.Error()})
532-
result = nil
533535
return chunk, nil
534536
}
535537

forge-core/runtime/guardrails.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,23 @@ import (
1212
// piece of content. Governance R4 requires the engine to be capable
1313
// of expressing each of these — even if a particular gate only
1414
// exercises a subset today. See docs/security/policy-decisions.md.
15+
//
16+
// Constants are ordered by RESTRICTIVENESS — the ordinal value maps
17+
// to severity so `partA.Decision > partB.Decision` selects the more
18+
// restrictive decision when aggregating across multiple parts:
19+
//
20+
// Allow < Modify < StepUp < Defer < Deny
21+
//
22+
// Callers comparing severity SHOULD use the ordinal directly. Do NOT
23+
// reorder without updating every aggregate site (see
24+
// LibraryGuardrailEngine.CheckOutbound and its per-part escalation).
1525
type PolicyDecision int
1626

1727
const (
1828
// DecisionAllow — content passes through unmodified. Zero value.
29+
// Least restrictive.
1930
DecisionAllow PolicyDecision = iota
2031

21-
// DecisionDeny — content is rejected. Caller MUST propagate the
22-
// error and MUST NOT let the content proceed.
23-
DecisionDeny
24-
2532
// DecisionModify — content is admissible but must be rewritten
2633
// (redacted, truncated, tagged) before it moves forward.
2734
// PolicyResult.Modified carries the replacement.
@@ -36,6 +43,10 @@ const (
3643
// (platform API, human queue) before the caller can proceed.
3744
// Reserved for R4c (#211).
3845
DecisionDefer
46+
47+
// DecisionDeny — content is rejected. Caller MUST propagate the
48+
// error and MUST NOT let the content proceed. Most restrictive.
49+
DecisionDeny
3950
)
4051

4152
// String returns the audit-safe decision token. Matches the strings

forge-core/runtime/policy_decision_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@ import (
55
"testing"
66
)
77

8+
// TestPolicyDecision_SeverityOrdering pins the ordinal-as-severity
9+
// contract callers rely on (LibraryGuardrailEngine.CheckOutbound
10+
// uses `>` to escalate the aggregate result). Reordering these
11+
// constants without updating aggregate sites is exactly the class
12+
// of silent breakage this test guards against.
13+
func TestPolicyDecision_SeverityOrdering(t *testing.T) {
14+
order := []PolicyDecision{
15+
DecisionAllow,
16+
DecisionModify,
17+
DecisionStepUp,
18+
DecisionDefer,
19+
DecisionDeny,
20+
}
21+
for i := 1; i < len(order); i++ {
22+
if order[i-1] >= order[i] {
23+
t.Errorf("severity broken: %s(%d) should be < %s(%d)",
24+
order[i-1], order[i-1], order[i], order[i])
25+
}
26+
}
27+
// Also pin the zero-value expectation — deployments that
28+
// return `PolicyResult{}` implicitly mean Allow.
29+
if DecisionAllow != 0 {
30+
t.Errorf("DecisionAllow must remain the zero value; got %d", DecisionAllow)
31+
}
32+
}
33+
834
func TestPolicyDecision_StringMapping(t *testing.T) {
935
cases := []struct {
1036
d PolicyDecision
@@ -65,6 +91,31 @@ func TestApplyOutputPolicy_BlockShortCircuits(t *testing.T) {
6591
}
6692
}
6793

94+
// TestApplyOutputPolicy_RedactDoesNotHideEarlierBlock is a regression
95+
// test for reviewer initializ-mk's #221 point: a "redact" pattern
96+
// listed before a "block" pattern MUST NOT rewrite text in a way
97+
// that suppresses the block match. Pre-fix, the loop matched
98+
// blocks against the progressively-redacted string, so a
99+
// well-crafted redact-then-block config silently downgraded Deny
100+
// to Modify. Post-fix, block checks run against the ORIGINAL
101+
// content first (two-pass evaluation).
102+
func TestApplyOutputPolicy_RedactDoesNotHideEarlierBlock(t *testing.T) {
103+
// The redact rule matches "secret " and rewrites it away.
104+
// The block rule wants to match "secret key" — with the pre-fix
105+
// single-pass loop it never fires because "secret " was already
106+
// redacted to "[BLOCKED BY POLICY]". Post-fix, block runs first
107+
// against original content and returns Deny.
108+
filters := []compiledOutputFilter{
109+
{re: regexp.MustCompile(`secret `), action: "redact"},
110+
{re: regexp.MustCompile(`secret key`), action: "block"},
111+
}
112+
res := applyOutputPolicy("leaked secret key: abc", filters, &testLogger{}, "any_tool")
113+
if res.Decision != DecisionDeny {
114+
t.Errorf("block must beat earlier redact — got %s (modified=%q)",
115+
res.Decision, res.Modified)
116+
}
117+
}
118+
68119
func TestApplyOutputPolicy_AllowWhenNoMatch(t *testing.T) {
69120
filters := []compiledOutputFilter{{
70121
re: regexp.MustCompile(`nope`),

forge-core/runtime/skill_guardrails.go

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -173,35 +173,40 @@ func (s *SkillGuardrailEngine) CheckCommandOutput(toolName, toolOutput string) (
173173
// scanning, RAG-context scanning, MCP tool-result scanning) can share
174174
// the same MODIFY semantics.
175175
//
176-
// Precedence: any "block" match short-circuits the loop and returns
177-
// DecisionDeny. Otherwise, every "redact" match is applied
178-
// cumulatively and the aggregated redacted string is returned as
179-
// DecisionModify (if any pattern matched) or DecisionAllow (no matches).
176+
// Two-pass evaluation (fixes the redact-hides-block downgrade
177+
// reviewer initializ-mk flagged): pass 1 checks every "block" pattern
178+
// against the ORIGINAL content and short-circuits to Deny on any
179+
// match. Only after all blocks pass does pass 2 apply "redact"
180+
// substitutions cumulatively. This guarantees a block-worthy string
181+
// isn't silently downgraded to Modify by an earlier redact that
182+
// rewrote the substring the block pattern would have caught.
180183
//
181184
// `logger` and `tool` are for the redaction log line — pass "" for
182185
// tool when the caller isn't tool-scoped.
183186
func applyOutputPolicy(content string, filters []compiledOutputFilter, logger Logger, tool string) PolicyResult {
187+
// Pass 1: block checks against ORIGINAL content.
188+
for _, f := range filters {
189+
if f.action == "block" && f.re.MatchString(content) {
190+
return Deny("output matched deny_output block pattern")
191+
}
192+
}
193+
// Pass 2: cumulative redacts.
184194
modified := content
185195
changed := false
186196
for _, f := range filters {
187-
if !f.re.MatchString(modified) {
197+
if f.action != "redact" || !f.re.MatchString(modified) {
188198
continue
189199
}
190-
switch f.action {
191-
case "block":
192-
return Deny("output matched deny_output block pattern")
193-
case "redact":
194-
modified = f.re.ReplaceAllString(modified, "[BLOCKED BY POLICY]")
195-
changed = true
196-
fields := map[string]any{
197-
"pattern": f.re.String(),
198-
"action": "redact",
199-
}
200-
if tool != "" {
201-
fields["tool"] = tool
202-
}
203-
logger.Warn("skill guardrail output redaction", fields)
200+
modified = f.re.ReplaceAllString(modified, "[BLOCKED BY POLICY]")
201+
changed = true
202+
fields := map[string]any{
203+
"pattern": f.re.String(),
204+
"action": "redact",
205+
}
206+
if tool != "" {
207+
fields["tool"] = tool
204208
}
209+
logger.Warn("skill guardrail output redaction", fields)
205210
}
206211
if changed {
207212
return Modify(modified, "output matched deny_output redact pattern")

0 commit comments

Comments
 (0)