| 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 |
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.
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.
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.
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.
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: trueoptional: 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.yamlThe 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).
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. |
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); theguardrails:block is camelCase (gateConfig,commandInjection) to mirrorguardrails.jsonfield-for-field. forge-core carries the block as a raw subtree and forge-cli bridges it YAML → JSON →StructuredGuardrailswith strict decoding (a typo likeoutptGate:fails startup).
| 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.
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: blockEvery 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.allowedSkillsis 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.
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 justcli_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 askubectl<TAB>delete) still matcheskubectl\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_commandscomposes as an additional independent deny — it can never relax an operator pattern (both areBeforeToolExecdeny 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_checkaudit event carryingsource: platform,pattern, the operatormessage(if any),layer, andpolicy_source— closing the gap where skilldeny_commandsare 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'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.
rmmatchesterraform,confirm,performance; and because the non-cli_executematch target is the entire argument content, a baredeleteblockskubectl get pod delete-me, afile_readofdelete_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'sallowed_binaries(deny-by-default) ordenied_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.
When forge.yaml declares an egress / tool / model / size value any layer forbids, the runner:
- Emits one
policy_violation_at_build_timeaudit event per violation. The event carriesviolation_kind,offending_value,forge_yaml_field,layer(which file decided), andsource(the path to that file) so cost / compliance dashboards can group by kind or by enforcing authority. - Returns a multi-line error from
NewRunnerlisting every violation. The developer sees every problem in one pass — fix theforge.yamlonce 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 |
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"
}
}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
slacknormally. - Skips
telegramand emitschannel_denied_by_policyattributed to the first layer that denied it. - Continues. Channel deny is a scope-down, not a hard error.
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 # dittoBoth 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.
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.
forge validate --platform-policy=path/to/policy.yamlSchema-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.
- 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_loadedevent reports counts, not contents. Operators can read the source file via thesourcefield 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 adisabled_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).
- 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 events —
invocation_complete,invocation_cancelled,policy_loaded,policy_violation_at_build_time, andchannel_denied_by_policyare all on the same audit stream