You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The platform deny-list lets operators deny at tool-name, egress-domain, and channel granularity, but has no argument/command-level construct. So an operator who wants "keep cli_execute, but ban rm -rf / git push --force / kubectl delete across the whole org" has only two options today, both bad:
ban the entire cli_execute tool (too coarse — kills every legitimate use), or
rely on per-skill SKILL.mddeny_commands, which is authored by the skill (supply chain), not the operator, and must be repeated in every skill.
Argument-level command control is currently a skill-authored capability, not a platform-governed one. The blunt "deny the whole tool" hammer sits with operators; the precise "deny this command" scalpel sits with whoever writes the SKILL.md.
Current state
Platform policy layers (system /etc/forge/policy.yaml → user ~/.forge/policy.yaml → workspace FORGE_PLATFORM_POLICY) support denied_tools (exact registry names, union-of-deny, registry-stripped at startup via reg.Remove), denied_egress_domains, denied_channels, forbidden models — forge-core/security/platform_policy_layers.go, EffectiveDeniedTools in platform_policy_enforce.go.
Command-pattern denial exists ONLY at the skill layer: SkillGuardrailEngine.CheckCommandInput (forge-core/runtime/skill_guardrails.go) matches deny_commands regex against each call, wired on the BeforeToolExec hook (forge-cli/runtime/runner.go:2243). Match target: cli_execute → reconstructed command line; other tools → raw tool-input JSON (post-R4a (governance): generalize MODIFY decision beyond cli_execute output #209 / canonicalizeToolInput).
Note: skill deny_commands currently logs via logger.Warn and does not emit a guardrail_check audit event — a platform version should fix that (operators must be able to see platform-denied commands in the audit stream).
Proposed control
Add an operator-authored, platform-layer command-pattern denylist that applies to every tool call by any skill the agent uses, enforced through the existing runtime hook path.
New field on security.PlatformPolicy, settable in each layer and unioned across layers like the other deny-lists. Use the existing skill-guardrail type — agentspec.CommandFilter{Pattern, Message} — rather than a bare []string, so operators can attach a custom deny message and the platform + skill paths share one type and one matcher:
# platform policy (any of the three layers)denied_command_patterns:
- pattern: 'kubectl\s+delete'message: "destructive kubectl blocked by org policy"
- pattern: 'git\s+push\s+--force'
At startup, compile the union of layer patterns and register them on BeforeToolExec via the same matcher skill deny_commands uses (SkillGuardrailEngine.CheckCommandInput, or a sibling engine seeded from the policy) so it runs for all tools regardless of skill.
Same match-target semantics as skill deny_commands (cli_execute → reconstructed command line; other tools → raw JSON via canonicalizeToolInput) so operators and skill authors author patterns identically.
Emit a guardrail_check (or policy_violation) audit event with source: platform and first-denying-layer attribution when a command is blocked — closing the observability gap where skill guardrails are silent in the audit stream.
Precedence: platform patterns compose union-of-deny with skill deny_commands (most-restrictive-wins); a skill cannot relax an operator pattern.
Prefer wiring this through the existing policy-layer + BeforeToolExec path over a new builtin, to keep forge-core library-only and reuse the compiled-regex engine.
Design note — this is the first RUNTIME-enforced platform-policy field
Every existing PlatformPolicy field is enforced once, at startup — a static check:
denied_tools → strip the tool from the registry at startup (reg.Remove)
denied_egress_domains → set-difference the allowlist at startup
forbidden_models / max_* → refuse to start on conflict, emitting policy_violation_at_build_time
denied_command_patterns is different: it is per-invocation, matched against each tool call's arguments at BeforeToolExec, every time. Same config surface (same PlatformPolicy struct / same YAML / same three layers / same union-of-deny resolution + first-layer attribution), but a different enforcement path — the loader must route this field to the runtime BeforeToolExec matcher, NOT to the startup EnforcePolicy + registry-strip path used by the other fields. Two consequences:
Startup still validates — compile the unioned patterns at load and fail closed on invalid regex with a clear error (behaves like the other startup checks; no silent skip).
Blocks emit a RUNTIME event per call (guardrail_check/policy_violation with source: platform + layer attribution), NOT a build-time policy_violation_at_build_time.
Net: the platform policy YAML becomes the single operator surface for BOTH static governance (which tools/domains/models exist at all) AND dynamic governance (which specific commands are allowed per call).
Acceptance criteria
denied_command_patterns ([]CommandFilter, pattern + optional message) can be set in any platform layer and is unioned across org/workspace/agent.
Patterns are compiled at startup; an invalid regex fails closed at load with a clear error (no silent skip).
A call whose args match a platform pattern is blocked at BeforeToolExec for any tool/skill (verified for cli_execute command line AND a non-cli_execute tool's JSON).
The block emits an instrumented runtime audit event (guardrail_check/policy_violation, source: platform) carrying the offending pattern, the operator message (if any), and the first-denying layer — NOT a build-time event.
The field is routed to the runtime BeforeToolExec matcher, not the startup EnforcePolicy/registry-strip path (i.e. the tool is NOT stripped; only matching calls are blocked).
A skill's own deny_commands cannot override or relax a platform pattern (compose = most-restrictive-wins).
Conformance test
TestPlatformDeniedCommand_BlocksAcrossSkills — assert an operator pattern blocks the call at BeforeToolExec for a non-cli_execute tool and emits the attributed runtime audit event; and TestPlatformDeniedCommand_InvalidRegexFailsClosed — a bad pattern in any layer aborts startup.
Whole-tool denial (already covered by denied_tools) and egress/channel denial.
Semantic (non-regex) command classification.
Guideline reference
Strengthens ASI02 (Tool Misuse) least-agency / PEP-PDP by giving operators org-wide, argument-level command control that today only skill authors can express. Complements the existing tool-name deny-list.
Problem
The platform deny-list lets operators deny at tool-name, egress-domain, and channel granularity, but has no argument/command-level construct. So an operator who wants "keep
cli_execute, but banrm -rf/git push --force/kubectl deleteacross the whole org" has only two options today, both bad:cli_executetool (too coarse — kills every legitimate use), orSKILL.mddeny_commands, which is authored by the skill (supply chain), not the operator, and must be repeated in every skill.Argument-level command control is currently a skill-authored capability, not a platform-governed one. The blunt "deny the whole tool" hammer sits with operators; the precise "deny this command" scalpel sits with whoever writes the SKILL.md.
Current state
/etc/forge/policy.yaml→ user~/.forge/policy.yaml→ workspaceFORGE_PLATFORM_POLICY) supportdenied_tools(exact registry names, union-of-deny, registry-stripped at startup viareg.Remove),denied_egress_domains,denied_channels, forbidden models —forge-core/security/platform_policy_layers.go,EffectiveDeniedToolsinplatform_policy_enforce.go.SkillGuardrailEngine.CheckCommandInput(forge-core/runtime/skill_guardrails.go) matchesdeny_commandsregex against each call, wired on theBeforeToolExechook (forge-cli/runtime/runner.go:2243). Match target:cli_execute→ reconstructed command line; other tools → raw tool-input JSON (post-R4a (governance): generalize MODIFY decision beyond cli_execute output #209 /canonicalizeToolInput).deny_commandscurrently logs vialogger.Warnand does not emit aguardrail_checkaudit event — a platform version should fix that (operators must be able to see platform-denied commands in the audit stream).Proposed control
Add an operator-authored, platform-layer command-pattern denylist that applies to every tool call by any skill the agent uses, enforced through the existing runtime hook path.
New field on
security.PlatformPolicy, settable in each layer and unioned across layers like the other deny-lists. Use the existing skill-guardrail type —agentspec.CommandFilter{Pattern, Message}— rather than a bare[]string, so operators can attach a custom deny message and the platform + skill paths share one type and one matcher:BeforeToolExecvia the same matcher skilldeny_commandsuses (SkillGuardrailEngine.CheckCommandInput, or a sibling engine seeded from the policy) so it runs for all tools regardless of skill.deny_commands(cli_execute→ reconstructed command line; other tools → raw JSON viacanonicalizeToolInput) so operators and skill authors author patterns identically.guardrail_check(orpolicy_violation) audit event withsource: platformand first-denying-layer attribution when a command is blocked — closing the observability gap where skill guardrails are silent in the audit stream.deny_commands(most-restrictive-wins); a skill cannot relax an operator pattern.Prefer wiring this through the existing policy-layer +
BeforeToolExecpath over a new builtin, to keep forge-core library-only and reuse the compiled-regex engine.Design note — this is the first RUNTIME-enforced platform-policy field
Every existing
PlatformPolicyfield is enforced once, at startup — a static check:denied_tools→ strip the tool from the registry at startup (reg.Remove)denied_egress_domains→ set-difference the allowlist at startupforbidden_models/max_*→ refuse to start on conflict, emittingpolicy_violation_at_build_timedenied_command_patternsis different: it is per-invocation, matched against each tool call's arguments atBeforeToolExec, every time. Same config surface (samePlatformPolicystruct / same YAML / same three layers / same union-of-deny resolution + first-layer attribution), but a different enforcement path — the loader must route this field to the runtimeBeforeToolExecmatcher, NOT to the startupEnforcePolicy+ registry-strip path used by the other fields. Two consequences:guardrail_check/policy_violationwithsource: platform+ layer attribution), NOT a build-timepolicy_violation_at_build_time.Net: the platform policy YAML becomes the single operator surface for BOTH static governance (which tools/domains/models exist at all) AND dynamic governance (which specific commands are allowed per call).
Acceptance criteria
denied_command_patterns([]CommandFilter,pattern+ optionalmessage) can be set in any platform layer and is unioned across org/workspace/agent.BeforeToolExecfor any tool/skill (verified forcli_executecommand line AND a non-cli_executetool's JSON).guardrail_check/policy_violation,source: platform) carrying the offending pattern, the operator message (if any), and the first-denying layer — NOT a build-time event.BeforeToolExecmatcher, not the startupEnforcePolicy/registry-strip path (i.e. the tool is NOT stripped; only matching calls are blocked).deny_commandscannot override or relax a platform pattern (compose = most-restrictive-wins).Conformance test
TestPlatformDeniedCommand_BlocksAcrossSkills— assert an operator pattern blocks the call atBeforeToolExecfor a non-cli_executetool and emits the attributed runtime audit event; andTestPlatformDeniedCommand_InvalidRegexFailsClosed— a bad pattern in any layer aborts startup.Out of scope
denied_tools) and egress/channel denial.Guideline reference
Strengthens ASI02 (Tool Misuse) least-agency / PEP-PDP by giving operators org-wide, argument-level command control that today only skill authors can express. Complements the existing tool-name deny-list.