feat(guardrails): generalize MODIFY policy decision beyond cli_execute#221
Conversation
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.
initializ-mk
left a comment
There was a problem hiding this comment.
Reviewed the full diff and verified the critical path — the LibraryGuardrailEngine's in-place msg.Parts mutation still runs before the new return Modify(...), so masked content is still applied (no regression). This is a solid, well-tested refactor that closes a real gap: guardrail scanning of tool output was effectively cli_execute-scoped, and this generalizes MODIFY/DENY to every tool. No blocking correctness or security bug.
Requesting changes for a few things I'd like resolved before merge — none are large.
Docs / changelog (behavior change needs to be surfaced)
Dropping the cli_execute short-circuit is a backward-incompatible change: existing deny_commands patterns now evaluate against the raw JSON of every tool call, not just cli_execute command lines. A shell-oriented pattern (e.g. rm\s+-rf, or .*) that previously only affected cli_execute will now start denying web_search / http_request / MCP calls whose payload happens to contain that text. The test change makes it concrete: {"query":"kubectl get secrets"} flips from pass to blocked.
- Add a
CHANGELOG.mdentry calling out the behavior change (the PR doesn't touch it). - In
docs/security/policy-decisions.md, document thatdeny_commandsnow matches raw JSON for non-cli_executetools, and note the asymmetry:deny_commandsmatches a reconstructed command line forcli_executebut raw JSON for every other tool — one rule list, two target shapes. Operators need this to calibrate patterns and avoid accidental matches on key names vs values / JSON escaping.
Should fix — enum ordering used as severity, but the enum isn't severity-ordered
CheckOutbound escalates with if partResult.Decision > aggregate.Decision, commenting "Allow < Modify < Deny." The constants are Allow=0, Deny=1, Modify=2, so numerically Modify > Deny — the opposite of the comment. It works today only because a Deny short-circuits via the error return (and warn-mode "deny" surfaces as Allow), so the > comparison only ever compares Allow vs Modify. It's latent: if StepUp/Defer or a non-erroring Deny ever flows through, the aggregate ranks them wrong.
- Don't overload enum order as severity — add an explicit severity helper (or reorder deliberately with a test), and fix the misleading comment.
- Nit in the same spot: the comment says the aggregate
Modified"reflects the last modified part," but strict>keeps the first modified part. (No functional impact today since callers discard the result, but the comment should match the code.)
Should fix — redact can mask a later block pattern
In applyOutputPolicy, block detection matches against the progressively-redacted modified string, not the original. A redact filter earlier in the list that rewrites text a later block filter would have caught will suppress the block — silently downgrading a Deny to a Modify. This is pre-existing behavior, but it's now the canonical reusable helper, so it's the right moment to harden.
- Evaluate all
blockpatterns against the original content before applying anyredact(or run block checks first, redact second). - Add a test for the ordering interaction (redact-before-block over overlapping content) — the current block+redact test doesn't hit it.
Minor
- The
PolicyResultreturn values are discarded at all 6 runner call sites (_, err :=). MODIFY still works via in-place mutation, and the decision plumbing is forward-looking scaffolding for R4b/R4c — fine, but worth a one-line note that the returned decision isn't consumed by production callers yet (only unit-tested), so the new interface surface is a breaking change not yet exercised end-to-end. - Pre-existing dead
result = nilon the gate-error paths is carried along unchanged — cheap to drop while you're here.
What's good
Crypto/logic aside, the migration is complete and clean (all call sites, noop + library engine, tests threaded), and the tests that previously asserted the OPPOSITE (non-cli passes through) were correctly inverted with an explanatory comment. applyOutputPolicy is a sensible extraction for the upcoming MCP/RAG call sites. Once the docs/changelog and the two should-fix items land, this is good to merge.
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.
|
Thanks for the review @initializ-mk — all four points addressed in b240550. 🟠 Enum ordinal as severity — reordered 🟠 Docs / CHANGELOG —
Aggregate comment correction — docstring on 🟡 Dead Full |
initializ-mk
left a comment
There was a problem hiding this comment.
Approving — the b240550f fix commit resolves everything from my earlier review, verified locally (full forge-core/runtime + forge-cli/runtime suites green on the branch).
- Enum severity ordering: fixed. Reordered to
Allow(0) < Modify(1) < StepUp(2) < Defer(3) < Deny(4), so the ordinal encodes restrictiveness andCheckOutbound's>aggregate is correct for any decision (not just because Deny short-circuits).String()is name-based and the audit-wire strings are unchanged, so it stays SIEM-stable;DecisionAllowremains the zero value.TestPolicyDecision_SeverityOrderingpins the contract. - Block-before-redact: fixed.
applyOutputPolicyis now two-pass — everyblockpattern is checked against the ORIGINAL content first, then redacts apply cumulatively — so a redact can no longer downgrade a block to Modify.TestApplyOutputPolicy_RedactDoesNotHideEarlierBlockproves it. (Correct consequence: block now unconditionally precedes redact regardless of authoring order — documented.) - Docs + CHANGELOG: added.
CHANGELOG.md"Changed (potentially breaking)" with migration guidance and the match-target asymmetry;docs/security/policy-decisions.mdwith the cli_execute-vs-raw-JSON asymmetry table and the block/redact ordering section. - Aggregate comment: corrected to "FIRST part at each severity level," matching the strict
>. - Dead-code cleanup: verified safe. The six
result = nilremovals sit before a deferredfinishGuardrailSpanthat early-returns on a nil result. I checkedguardrails@v0.12.0:evaluateAtGateis fail-open and its reachable error returns are(nil, err), soresultis already nil whenever the engine'serr != nilbranch runs — the removal is a genuine no-op.
No cross-PR collision here (unlike #220/#222), so this stands on its own.
Out-of-scope note for later, not a blocker for this PR: the guardrails engine itself is fail-open on internal errors (a policy-fetch failure yields Allow) — a pre-existing property of github.com/initializ/guardrails, unchanged here, but worth tracking separately if fail-closed is ever required.
Closes #209. (Re-open of #219 which GitHub auto-closed after main was force-pushed; local branch rebased onto new main.)
Summary
PolicyDecision(Allow / Deny / Modify / StepUp / Defer) +PolicyResultinforge-core/runtime— the governance R4a taxonomy.GuardrailChecker.CheckInbound/CheckOutboundto return(PolicyResult, error); noop + library engine + all 6 runner call sites updated.cli_execute-only short-circuit inSkillGuardrailEngine.CheckCommandInput/CheckCommandOutput.deny_commandsnow matches on the raw JSON of any tool;deny_outputredact/block fires for output of ANY tool.applyOutputPolicyso future call sites (MCP tool result hook, RAG ingest) can reuse the same MODIFY semantics.Acceptance criteria (from #209)
PolicyDecision+PolicyResulttypes introducedCheckInbound/CheckOutboundreturnPolicyResultdecisionfield (unchanged —guardrail_checkalready emitsdecision)Test plan
go test ./forge-core/... ./forge-cli/...— full sweep greengofmt -w+golangci-lint runall cleanDocs at
docs/security/policy-decisions.md.