Skip to content

[ASI02] Platform-layer denied_command_patterns — operator-authored, org-wide command denial across all skills #238

Description

@initializ-mk

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 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.md deny_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:

  1. 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).
  2. 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.

Out of scope

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    asi02OWASP ASI02forge-coreAffects the forge-core library (runtime, security, types, llm, mcp, auth)owasp-asiOWASP Top 10 for Agentic Applications 2026 conformanceplatformInitializ Platform (governance control plane) responsibilitysecuritySecurity vulnerability fixes

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions