Skip to content

Latest commit

 

History

History
437 lines (335 loc) · 26.9 KB

File metadata and controls

437 lines (335 loc) · 26.9 KB
title Platform Policy
description Three-layer policy that bounds what a forge.yaml is allowed to declare — system, user, and workspace files compose by union and most-restrictive.
order 8

Platform Policy

Forge agents read a layered set of policy files at startup that bound what their forge.yaml is allowed to declare — egress destinations, registered tools, allowed models, channel adapters, and configuration sizes. The agent's forge.yaml is what it claims to do; the policy stack is the ceiling. The agent refuses to start when its declaration exceeds the bound (egress / tool / model / size) and skips individual channels when its channel declaration overlaps the deny list.

This is the runtime safety net for the case where a developer's forge.yaml adds a forbidden domain or tool — intentionally or by mistake. PR-time linters help catch this at config time; the platform policy enforces it at startup regardless.

The three layers

Three independent policy files are read at startup. Each is optional; an agent with none of them present runs exactly as it did pre-FWS-5.

Layer Path Set by Scope
system /etc/forge/policy.yaml (override: FORGE_SYSTEM_POLICY env) Sysadmin (corporate-laptop image, MDM profile) Every agent run by anyone on the machine
user ~/.forge/policy.yaml The developer themselves, via forge channel disable / the Web UI chip toggle / direct edit Every agent run by that user on that machine
workspace path at FORGE_PLATFORM_POLICY env Workspace operator (Initializ Command, custom controller, GitOps tooling) The single deployed agent (unchanged from FWS-5)

All three layers share the same schema. The loader silently skips an empty or absent file; a malformed file at any layer is an error that aborts startup.

Resolution semantics

When the same value appears on multiple layers' deny lists, the union is enforced. When a max-bound (size cap) is set at multiple layers, the smallest non-zero value wins ("most restrictive across layers"). Both rules apply consistently to every field — egress, tools, models, channels, max counts.

For audit attribution, the first layer to deny in load order takes credit (system → user → workspace). This means a denied_egress violation that's on the system layer's list will be attributed to layer=system in audit events even if the user layer also denied it — operators grepping for layer=system see every sysadmin-enforced violation without false positives from per-user overrides.

When is a policy applied

Per-layer behavior at startup:

Condition Result
All three layer files absent / empty No policy applied. Backward-compatible with pre-FWS-5 / pre-FWS-6 behavior.
Layer file present but malformed Agent refuses to start. Operator / sysadmin / developer mistake that must fail loudly.
Any layer denies a value forge.yaml declared (egress / tool / model / size) Agent refuses to start with a multi-line error listing every violation; emits one policy_violation_at_build_time audit event per violation, each carrying the deciding layer + path.
Any layer denies a channel forge.yaml declared Agent starts without that channel adapter; emits one channel_denied_by_policy event per skip, carrying the deciding layer + path. (Channel deny is a scope-down, not a hard error.)
All layers pass Agent starts; emits one policy_loaded audit event per non-empty layer summarizing its contents.

Policy is read once at startup. Live reload is deliberately out of scope — policy changes require a pod restart (workspace) or process restart (system / user). This keeps the running agent's state predictable and traceable to a specific policy file snapshot.

How forge package wires the workspace layer

The workspace layer is the deploy-time slot. Every Deployment manifest generated by forge package is policy-ready by default:

containers:
  - name: <agent_id>
    env:
      - name: FORGE_PLATFORM_POLICY
        value: /etc/forge/policy/platform-policy.yaml
    volumeMounts:
      - name: platform-policy
        mountPath: /etc/forge/policy
        readOnly: true
volumes:
  - name: platform-policy
    configMap:
      name: forge-platform-policy
      optional: true

optional: true is load-bearing. Without it, the pod would fail to start when the ConfigMap doesn't exist, breaking self-managed deployments that don't need workspace bounds. With it, deployments without a policy ConfigMap behave identically to pre-FWS-5 deployments.

The ConfigMap itself is NOT generated by forge package. Policy is an operator concern, not a developer concern — a placeholder in the developer's git repo would get edited as if it were source, blurring the trust boundary.

Self-managed deployers create the ConfigMap directly:

forge validate --platform-policy=platform-policy.yaml   # CI gate
kubectl create configmap forge-platform-policy \
  --from-file=platform-policy.yaml=./platform-policy.yaml

The Initializ Platform (or any custom controller / GitOps tooling) creates this ConfigMap from its own source of truth at deploy time.

The system and user layers do not flow through forge package — they're laptop-local. Sysadmins drop /etc/forge/policy.yaml onto a corporate image via MDM / Ansible / the same channels they use to provision any other system config. Developers edit ~/.forge/policy.yaml via the CLI or the Web UI chip toggle (no manual YAML editing required).

Schema

The same schema applies to all three layers. See examples/platform-policy.yaml for a fully-commented working example.

denied_egress_domains:
  - api.slack.com         # exact host match, case-insensitive

denied_tools:
  - cli_execute           # registry name, case-sensitive

forbidden_models:
  - provider: anthropic
    name: claude-opus-4   # both fields required, no wildcards

denied_channels:
  - telegram              # registry name, case-sensitive

denied_command_patterns:  # per-invocation, applies to EVERY tool call (#238)
  - pattern: 'kubectl\s+delete'
    message: "destructive kubectl blocked by org policy"
  - pattern: 'git\s+push\s+--force'

max_egress_allowlist_size: 50   # 0 (or omitted) = no cap
max_tool_count: 100             # 0 (or omitted) = no cap

guardrails:                     # tighten-only overlay over the agent's
  gateConfig:                   # guardrails.json — see the section below
    outputGate: true
  security:
    commandInjection: { enabled: true, confidenceThreshold: 15, action: block }

Decoding is strict — unknown fields are rejected so operator typos (deinied_egress_domains:) fail loudly instead of silently no-opping.

Field Semantics
denied_egress_domains Set-difference with forge.yaml's egress.allowed_domains. Match is case-insensitive exact-host. Wildcard patterns belong in forge.yaml only; the platform deny list is operator-supplied and intentionally simple. The union across all layers reaches the EgressEnforcer.
denied_tools Union across all layers with forge.yaml's denied tools (typically from the derived CLI config). User-selected builtins survive forge.yaml denies but NOT policy denies — policy outranks per-agent declaration.
forbidden_models Applied to the primary model AND every fallback. Both provider and name are required to prevent loose patterns like "any anthropic model" that would silently let a new model in.
denied_channels Per-layer channel deny list. Each entry is a channel adapter name (slack, telegram, msteams). At runtime: forge run --with skips denied channels (one channel_denied_by_policy audit event per skip); forge channel serve refuses to start outright when its target is denied. Match is case-sensitive.
denied_command_patterns Operator-authored, argument-level command denylist applied to every tool call by any skill (#238 / ASI02). Each entry is {pattern, message?} (agentspec.CommandFilter, same type as SKILL.md deny_commands). The one field enforced per-invocation, not once at startup: the tool is NOT stripped — every call is matched at BeforeToolExec with the same match target as skill deny_commands (cli_execute → reconstructed command line; any other tool → raw tool-input JSON). Unioned across layers (first layer owns attribution); a skill's own deny_commands cannot relax an operator pattern (union-of-deny). Patterns compile at startup — an invalid regex fails closed (aborts startup). A block emits a runtime guardrail_check audit event tagged source: platform. See Runtime command denial.
max_egress_allowlist_size Cap on the declared count (not the policy-filtered count). Defense against allowlist bloat. Smallest non-zero value across layers wins.
max_tool_count Cap on the effective tool count (after policy strip). Stripping a denied tool must NOT cause a spurious bound violation. Smallest non-zero value across layers wins.
guardrails Tighten-only overlay merged over the agent's guardrails.json. Same schema as that file (camelCase StructuredGuardrails), so a guardrails: block here can force detections/gates on, raise actions, lower thresholds, and union rule/denylist/blocked-skill sets — never loosen. Folded most-restrictively across layers. A malformed block fails startup (fail-closed). See Guardrails overlay.

Guardrails overlay

The guardrails: key carries a platform overlay that further restricts the agent's guardrails.json — the same one-way ratchet the capability fields above apply to tools / egress / models / channels. It uses the exact same schema as guardrails.json (the camelCase models.StructuredGuardrails fields), so operators author one structure whether it lands in the agent file or the platform policy.

Casing: the capability fields are snake_case (denied_tools, max_tool_count); the guardrails: block is camelCase (gateConfig, commandInjection) to mirror guardrails.json field-for-field. forge-core carries the block as a raw subtree and forge-cli bridges it YAML → JSON → StructuredGuardrails with strict decoding (a typo like outptGate: fails startup).

Merge semantics (tighten only)

Field Rule
gateConfig.* boolean OR — a gate can be forced on, never off
detections (pii, security.*, nsfwText, moderation, hallucination) force-enable; take the most-severe action (warn < mask < block); take the lower (stricter) threshold
customRules.rules, security.customPatterns, hard/soft constraints union — platform rules are added; agent rules are never removed
urlFilter union denylist; intersect allowlist; force-enable
approvalGates union
skillConstraints union blockedSkills; intersect allowedSkills

Every tightening is logged at startup with the changed fields and contributing layers. For per-field semantics of each guardrails block (PII categories, action vocabulary, thresholds), see Content Guardrails.

Full example

A complete policy.yaml exercising both halves — the capability ceiling (snake_case, deny/cap) and the guardrails overlay (camelCase, tighten-only):

# ---- Capability ceiling: deny / cap the agent's declared surface ----
denied_egress_domains:
  - api.openai.com
  - pastebin.com
denied_tools:
  - http_request
  - cli_execute
forbidden_models:
  - provider: anthropic
    name: claude-opus-4-8
max_egress_allowlist_size: 25
max_tool_count: 40
denied_channels:
  - telegram

# ---- Guardrails overlay: tighten the agent's guardrails.json ----
guardrails:
  gateConfig:                   # force gates ON (never off)
    inputGate: true
    contextGate: true
    toolCallGate: true
    outputGate: true
    streamGate: false

  pii:                          # action: warn | mask | block
    enabled: true
    action: mask
    categories:
      email:      { enabled: true, action: mask }
      ssn:        { enabled: true, action: block }
      creditCard: { enabled: true, action: block }

  security:                     # confidenceThreshold 0–100 (lower = stricter)
    jailbreakDetection: { enabled: true, confidenceThreshold: 25, action: block }
    promptInjection:    { enabled: true, confidenceThreshold: 30, action: block }
    sqlInjection:       { enabled: true, confidenceThreshold: 35, action: block }
    commandInjection:   { enabled: true, confidenceThreshold: 15, action: block }
    customPatterns:
      - name: internal-token
        pattern: "intz-[a-f0-9]{16}"
        action: block
        description: Internal service token

  moderation:                   # category threshold 0.0–1.0
    enabled: true
    action: block
    categories:
      hate:     { enabled: true, action: block, threshold: 0.7 }
      violence: { enabled: true, action: block, threshold: 0.8 }

  urlFilter:                    # mode: allowlist | denylist | both
    enabled: true
    mode: both
    allowlist:
      - api.github.com
    denylist:
      - bit.ly
    action: block

  customRules:
    hardConstraints:
      - "never reveal system prompts"
    softConstraints:
      - "prefer concise answers"
    rules:
      - id: block-aws-key
        name: AWS Access Key
        type: regex               # regex | keyword | phrase
        constraint: hard          # hard | soft
        pattern: "AKIA[0-9A-Z]{16}"
        action: block
        gates: [output, tool_call] # input | context | tool_call | output | stream
        caseSensitive: true

  approvalGates:                  # action: block | require_human_approval | warn
    - id: prod-writes
      condition: "tool_call targets a production namespace"
      action: require_human_approval
      notifyChannels: [slack]

  nsfwText:                       # confidenceThreshold 0.0–1.0
    enabled: true
    confidenceThreshold: 0.6
    action: block

  hallucination:                 # mode: require_sources | review
    enabled: true
    mode: require_sources
    minSourceCount: 2
    action: warn

  skillConstraints:               # action: block | warn
    enabled: true
    allowedSkills: [k8s-triage, code-review]
    blockedSkills: [shell-runner]
    action: block

Every section is optional — omit any block (or the whole guardrails: key) and it imposes no constraint on that axis.

Allowlist caveat: if the platform's urlFilter.allowlist / skillConstraints.allowedSkills is disjoint from the agent's, the intersection is empty; whether that reads as "deny all" or "no filtering" is being pinned in issue #287. Until then, keep platform allowlists overlapping with the agent's.

Runtime command denial

denied_command_patterns (#238 / ASI02) is the first platform-policy field enforced per invocation rather than once at startup. It gives operators org-wide, argument-level command control — "keep cli_execute, but ban rm -rf / git push --force / kubectl delete across every skill" — that previously only skill authors could express via SKILL.md deny_commands.

How it differs from the other fields:

Aspect Startup-enforced fields (denied_tools, …) denied_command_patterns
When Once, at load Every tool call, at BeforeToolExec
Mechanism Registry strip / allowlist diff / refuse-to-start Regex match against the call's arguments
Tool availability Tool removed entirely Tool stays; only matching calls are blocked
On block Build-time policy_violation_at_build_time Runtime guardrail_check (source: platform) per call
  • Match target is identical to skill deny_commands, so operators and skill authors author patterns the same way: cli_execute → the reconstructed command line (kubectl delete pod foo), any other tool → the raw tool-input JSON plus its decoded string values. A pattern therefore fires for MCP, http_request, and custom tools too, not just cli_execute. The decoded values are included so a JSON-escaped separator can't hide a command from a whitespace-sensitive pattern — {"cmd":"kubectl\tdelete pod"} (which the tool runs as kubectl<TAB>delete) still matches kubectl\s+delete, closing a prompt-injection evasion where the attacker controls the argument content.
  • Union-of-deny across layers, first-declaring layer owns audit attribution. A skill's own deny_commands composes as an additional independent deny — it can never relax an operator pattern (both are BeforeToolExec deny hooks; either match blocks).
  • Fail-closed at startup: patterns compile when the policy loads; an invalid regex in any layer aborts startup with a layer-attributed error — never a silent skip.
  • Observability: a block emits a guardrail_check audit event carrying source: platform, pattern, the operator message (if any), layer, and policy_source — closing the gap where skill deny_commands are silent in the audit stream.
# any layer (system / user / workspace)
denied_command_patterns:
  - pattern: 'kubectl\s+delete'
    message: "destructive kubectl blocked by org policy"
  - pattern: 'git\s+push\s+--force'
  - pattern: 'rm\s+-rf'

Authoring patterns safely

Command patterns are a scalpel for dangerous invocations of allowed binaries, not a hammer for banning binaries. A few rules keep them from degrading into friction:

  • Never use bare substrings. rm matches terraform, confirm, performance; and because the non-cli_execute match target is the entire argument content, a bare delete blocks kubectl get pod delete-me, a file_read of delete_test.go, or any code the agent writes containing the word. The failure mode is a retry loop (the model sees the block and rephrases), not a clean stop. Anchor and qualify instead: kubectl\s+delete, git\s+push\s+--force, (^|\s)rm\s+-rf\b.
  • Ban binaries at the binary layer, not with patterns. To forbid a binary outright, use cli_execute's allowed_binaries (deny-by-default) or denied_tools — patterns are for gating specific sub-commands of binaries the agent is otherwise allowed to run.
  • Always set message. It turns a block from a silent retry-loop into a redirect that steers the model to the sanctioned alternative.
  • Trial in a workspace layer first. Ship a new pattern to the narrowest layer, watch the guardrail_check (source: platform) events for false positives, then promote it to the system layer once it's clean.

Conflict semantics

When forge.yaml declares an egress / tool / model / size value any layer forbids, the runner:

  1. Emits one policy_violation_at_build_time audit event per violation. The event carries violation_kind, offending_value, forge_yaml_field, layer (which file decided), and source (the path to that file) so cost / compliance dashboards can group by kind or by enforcing authority.
  2. Returns a multi-line error from NewRunner listing every violation. The developer sees every problem in one pass — fix the forge.yaml once and re-run, instead of ping-ponging through one error at a time. Each error line names the deciding layer + path so they know which file owns the rule (and who to ask for an exception — sysadmin, themselves, or the platform operator).

Channel violations are non-fatal: the agent starts without the named adapter and emits channel_denied_by_policy. See Channels below.

Violation kinds:

violation_kind What it means
denied_egress forge.yaml egress.allowed_domains lists a domain on a layer's deny list
denied_tool forge.yaml tools[] or builtin_tools[] lists a tool on a layer's deny list
forbidden_model forge.yaml model or model.fallbacks[] uses a (provider, name) pair on a layer's deny list
egress_bound_exceeded len(forge.yaml.egress.allowed_domains) exceeds the most-restrictive max_egress_allowlist_size across layers
tool_bound_exceeded Effective tool count (after policy strip) exceeds the most-restrictive max_tool_count across layers

Audit events

Three events fire from this subsystem; all go through AuditLogger.Emit (no request ctx exists at startup, so the workflow / task correlation fields are absent).

policy_loaded — one event per non-empty layer at startup. Fields summarize the layer's policy without dumping its contents (which may contain internal infrastructure hints operators don't want in every audit stream):

{
  "ts": "2026-06-06T18:30:00Z",
  "event": "policy_loaded",
  "fields": {
    "layer": "system",
    "source": "/etc/forge/policy.yaml",
    "denied_egress_count": 2,
    "denied_tools_count": 1,
    "forbidden_models_count": 1,
    "denied_channels_count": 1,
    "denied_command_count": 2,
    "max_egress_allowlist": 50,
    "max_tool_count": 100
  }
}

A denied_command_patterns block adds a fourth event class: when a call matches at runtime, a guardrail_check event fires with fields.source: "platform", fields.pattern, fields.layer, fields.policy_source, fields.tool, and the operator fields.message (if set). Unlike the startup events above, this one flows through EmitFromContext, so it carries the invocation's correlation_id / task_id / sequence.

policy_violation_at_build_time — one event per violation when forge.yaml conflicts with any layer's policy. Emitted before the runner aborts, so the audit pipeline captures the violation even though the agent never serves traffic:

{
  "ts": "2026-06-06T18:30:00Z",
  "event": "policy_violation_at_build_time",
  "fields": {
    "violation_kind": "denied_egress",
    "offending_value": "api.slack.com",
    "forge_yaml_field": "egress.allowed_domains",
    "layer": "system",
    "source": "/etc/forge/policy.yaml"
  }
}

channel_denied_by_policy — one event per channel skip at startup. The agent continues running with the remaining channels.

{
  "ts": "2026-06-06T18:30:00Z",
  "event": "channel_denied_by_policy",
  "fields": {
    "channel": "slack",
    "layer": "user",
    "source": "/home/dev/.forge/policy.yaml"
  }
}

Channels

denied_channels is the same schema field at every layer. There is no per-agent disabled_channels in forge.yaml — channel disable is always a property of the machine (sysadmin policy or developer preference), never of the agent declaration itself. The reasoning: a developer who wants Telegram off in dev is making a laptop-level statement, not modifying the agent's published capability set.

When forge.yaml declares channels: [slack, telegram] and any layer's denied_channels contains telegram, the runner:

  • Starts slack normally.
  • Skips telegram and emits channel_denied_by_policy attributed to the first layer that denied it.
  • Continues. Channel deny is a scope-down, not a hard error.

Disabling a channel from the CLI

forge channel disable slack             # edits ~/.forge/policy.yaml (user layer)
forge channel enable slack              # ditto

forge channel disable slack --system    # edits /etc/forge/policy.yaml (system layer)
forge channel enable slack --system     # ditto

Both subcommands are idempotent — disabling an already-denied channel reports "already denied" and returns 0. They edit the resolved policy file in place, adding or removing the entry from denied_channels. When the resulting policy is empty (every field zero), the file is removed entirely so a "no policy" state has no on-disk noise.

The --system flag writes to the system path. The CLI prints a warning if the effective uid is not root, since /etc/forge/policy.yaml typically requires elevated permissions to write — the warning surfaces the failure mode before os.WriteFile returns EACCES.

Disabling a channel from the Web UI

forge ui renders each declared channel on the agent card as a chip. Channels denied by any layer are shown locked / dimmed with a tooltip naming the deciding layer. Clicking an editable chip toggles the channel in the user layer (~/.forge/policy.yaml); the system and workspace layers are display-only.

REST shape (GET/PUT /api/user-policy):

# Read all three layers — user is editable, system + workspace are read-only.
GET /api/user-policy
    → {
        "path": "/home/dev/.forge/policy.yaml",
        "user": { "denied_channels": ["telegram"], "denied_tools": [] },
        "system": { "denied_channels": ["msteams"] },
        "system_path": "/etc/forge/policy.yaml",
        "workspace": { "denied_egress_domains": ["api.notion.com"] },
        "workspace_path": "/run/forge/workspace.yaml"
      }

# Write the user layer only. Body is the full PlatformPolicy doc.
PUT /api/user-policy
    ← { "user": { "denied_channels": ["slack", "telegram"] } }
    → 200 + the same shape as GET, refreshed.

When the submitted user policy is the zero value (every field empty), the on-disk file is removed so a "no policy" state has no on-disk noise. The frontend renders chips by checking denied_channels from any layer; clicking an editable chip flips the entry in the user block and PUTs it back.

Important: enabling a channel only undoes a deny on the layer being edited. It does NOT override a deny on a higher-precedence layer — if a sysadmin has denied a channel in /etc/forge/policy.yaml, no forge channel enable slack (without --system, which would require root) can lift it. The chip stays locked.

Validating a policy file standalone

forge validate --platform-policy=path/to/policy.yaml

Schema-only lint. Returns non-zero on parse errors, unknown fields, or invalid values. Use this as a CI gate before kubectl apply of the workspace ConfigMap, before pushing a system policy to a corporate-laptop image, or before committing a ~/.forge/policy.yaml to dotfiles.

What's deliberately NOT in v1

  • Live policy reload. Policy changes require a process restart. Simpler and more predictable trust boundary.
  • Computed allow / deny lists in the audit summary. The policy_loaded event reports counts, not contents. Operators can read the source file via the source field if they need the full policy.
  • Per-agent disable in forge.yaml. Channel disable is always laptop-level, never declared in agent source. (Removed in FWS-6 after FWS-5's prototype shipped a disabled_channels: field — see CHANGELOG.)
  • Platform policy applied to non-Forge agents. Out of scope here. The orchestrator enforces platform policy at the orchestration boundary for non-Forge agents (e.g., refuses to invoke if their declared capabilities violate policy).

Cross-references

  • Issue #89 / FWS-5 — single-layer (workspace) policy enforcement, original design
  • Issue #90 / FWS-6 — three-layer redesign + channel-policy enforcement (this doc)
  • Issue #31 — Forge's per-IP rate limiter, the original deployment-time bound
  • FWS-3 / FWS-4 audit eventsinvocation_complete, invocation_cancelled, policy_loaded, policy_violation_at_build_time, and channel_denied_by_policy are all on the same audit stream