Two related defects in how Forge drives the guardrails tool-call gate (forge-cli/runtime/guardrails_engine.go: CheckToolCall). Both cause a configured control to silently not enforce.
1. skillConstraints — skill identity is never passed (deny/allow lists don't work)
The library's SkillEvaluator (guardrails skill_evaluator.go) keys on the tool-call payload's SkillID (falling back to SkillName). Forge's CheckToolCall builds a ToolCallRequest with only ToolName set — SkillID/SkillName are left empty.
IsSkillAllowed("") therefore:
blockedSkills matches nothing — the empty id is never in the denylist, so a skill an operator explicitly blocked still runs.
- a non-empty
allowedSkills blocks every tool call — the empty id isn't in the allowlist → not_allowed → block.
So skillConstraints is a footgun: the denylist is silent-no-op, and the allowlist is a global kill-switch. An operator who sets blockedSkills: [shell-runner] gets no enforcement and no error — a security-relevant silent failure.
Fix: populate SkillName (and/or SkillID) on the ToolCallRequest — e.g. map the invoked tool's owning skill, or at minimum pass ToolName as the skill identity so the lists key on something real. Add a test that a blocked skill is denied and an allowlist admits only its members.
2. approvalGates — the Review decision is dropped (human approval silently doesn't fire)
When an approval condition matches, the library returns DecisionReview ("requires human approval"). Forge's CheckToolCall (and the inbound/outbound checks) switch only on DecisionMask / DecisionBlock — DecisionReview falls through to allow. So action: require_human_approval in approvalGates is a no-op in Forge.
(Compounding: approval matching runs inside SkillEvaluator, so it also depends on the empty skill identity from #1; and the library's GetApprovalCondition currently returns the first non-empty condition without parsing the condition expression — an upstream guardrails limitation.)
Fix: map DecisionReview onto Forge's existing human-in-the-loop machinery — the R4c DEFER engine (deferpolicy, #211) is the natural target: a guardrails Review at the tool-call gate should pause-and-resume via POST /tasks/{id}/decisions exactly like a policy-declared defer. Until then, approvalGates should be documented as unsupported so operators don't rely on it.
Impact
Both are silent non-enforcement of a configured security control — the worst failure mode. Neither errors; the operator believes the control is active.
Discovered while evaluating the guardrails sections for #284 / the platform overlay. Related: #211 (DEFER), guardrails skill_evaluator.go.
Two related defects in how Forge drives the guardrails tool-call gate (
forge-cli/runtime/guardrails_engine.go: CheckToolCall). Both cause a configured control to silently not enforce.1.
skillConstraints— skill identity is never passed (deny/allow lists don't work)The library's
SkillEvaluator(guardrailsskill_evaluator.go) keys on the tool-call payload'sSkillID(falling back toSkillName). Forge'sCheckToolCallbuilds aToolCallRequestwith onlyToolNameset —SkillID/SkillNameare left empty.IsSkillAllowed("")therefore:blockedSkillsmatches nothing — the empty id is never in the denylist, so a skill an operator explicitly blocked still runs.allowedSkillsblocks every tool call — the empty id isn't in the allowlist →not_allowed→ block.So
skillConstraintsis a footgun: the denylist is silent-no-op, and the allowlist is a global kill-switch. An operator who setsblockedSkills: [shell-runner]gets no enforcement and no error — a security-relevant silent failure.Fix: populate
SkillName(and/orSkillID) on theToolCallRequest— e.g. map the invoked tool's owning skill, or at minimum passToolNameas the skill identity so the lists key on something real. Add a test that a blocked skill is denied and an allowlist admits only its members.2.
approvalGates— theReviewdecision is dropped (human approval silently doesn't fire)When an approval condition matches, the library returns
DecisionReview("requires human approval"). Forge'sCheckToolCall(and the inbound/outbound checks) switch only onDecisionMask/DecisionBlock—DecisionReviewfalls through to allow. Soaction: require_human_approvalinapprovalGatesis a no-op in Forge.(Compounding: approval matching runs inside
SkillEvaluator, so it also depends on the empty skill identity from #1; and the library'sGetApprovalConditioncurrently returns the first non-empty condition without parsing theconditionexpression — an upstream guardrails limitation.)Fix: map
DecisionReviewonto Forge's existing human-in-the-loop machinery — the R4c DEFER engine (deferpolicy, #211) is the natural target: a guardrailsReviewat the tool-call gate should pause-and-resume viaPOST /tasks/{id}/decisionsexactly like a policy-declared defer. Until then,approvalGatesshould be documented as unsupported so operators don't rely on it.Impact
Both are silent non-enforcement of a configured security control — the worst failure mode. Neither errors; the operator believes the control is active.
Discovered while evaluating the guardrails sections for #284 / the platform overlay. Related: #211 (DEFER), guardrails
skill_evaluator.go.