Wardgate Policy System¶
This document explains how to write and configure policy rules in Wardgate.
How Policies Work¶
When an agent makes a request, Wardgate first checks dynamic grants. If a matching active grant exists, the request is allowed immediately. Otherwise, static rules are evaluated in order until one matches. The first matching rule determines the action. If no rules match, the request is denied by default.
Request → Dynamic Grant Check → Static Rule Evaluation → Default Deny
↓ (match) ↓ (first match)
Allow allow / deny / ask
Request: POST /tasks/123/close
↓
Rule 1: GET /tasks* → allow (no match - wrong method)
↓
Rule 2: POST /tasks/*/close → allow (match!)
↓
Action: allow
Rule Structure¶
Each rule has three parts:
rules:
- match: # Conditions that must be true
method: GET
path: "/tasks*"
action: allow # What to do when matched
message: "" # Optional message (for deny)
rate_limit: # Optional rate limiting
max: 100
window: "1m"
time_range: # Optional time restrictions
hours: ["09:00-17:00"]
days: ["mon", "tue", "wed", "thu", "fri"]
Match Conditions¶
Method Matching¶
Match HTTP methods:
Supported methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, *
Path Matching¶
Wardgate supports several path matching patterns:
Exact Match¶
Trailing Wildcard¶
Single Segment Wildcard¶
- match: { path: "/tasks/*/close" }
# Matches: /tasks/123/close, /tasks/abc/close
# Does not match: /tasks/close, /tasks/a/b/close
Multi-Segment Wildcard¶
- match: { path: "/api/**/status" }
# Matches: /api/status, /api/v1/status, /api/v1/tasks/123/status
Combined Conditions¶
Match conditions are AND-ed together:
Actions¶
allow¶
Permits the request to proceed to the upstream service.
The agent receives the upstream response as-is.
deny¶
Blocks the request and returns an error to the agent.
Returns HTTP 403 Forbidden with the message.
ask¶
Requires human approval before proceeding. The request blocks until approved, denied, or timeout.
When an ask rule matches:
- Request is held pending
- Notification sent to configured channels (Slack, webhook)
- Human clicks approve or deny link
- Request proceeds or returns 403
Note: The agent blocks while waiting. Configure notify.timeout to limit wait time.
Rate Limiting¶
Prevent agents from making too many requests:
- match: { method: GET }
action: allow
rate_limit:
max: 100 # Maximum requests allowed
window: "1m" # Time window
Window Format¶
- Seconds:
"30s" - Minutes:
"5m" - Hours:
"1h"
Rate Limit Behavior¶
- Limits are per-agent (identified by agent ID from config)
- Each rule has independent limits
- When exceeded, returns HTTP 429 Too Many Requests
Retry-Afterheader indicates when to retry
Examples¶
# 100 requests per minute for reads
- match: { method: GET }
action: allow
rate_limit: { max: 100, window: "1m" }
# 10 writes per hour
- match: { method: POST }
action: allow
rate_limit: { max: 10, window: "1h" }
# Strict limit on sensitive endpoint
- match: { method: GET, path: "/admin*" }
action: allow
rate_limit: { max: 5, window: "1m" }
Time-Based Rules¶
Restrict when rules apply:
- match: { method: POST }
action: allow
time_range:
hours: ["09:00-17:00"]
days: ["mon", "tue", "wed", "thu", "fri"]
Hours Format¶
24-hour format ranges:
Multiple ranges can be specified (OR logic).
Days Format¶
Three-letter day abbreviations:
days: ["mon", "tue", "wed", "thu", "fri"] # Weekdays
days: ["sat", "sun"] # Weekends
days: ["mon", "wed", "fri"] # Specific days
Time Range Behavior¶
When a request arrives outside the specified time range: - The rule is skipped (not matched) - Evaluation continues to the next rule - This is NOT a deny - it just doesn't match
Examples¶
# Allow writes only during business hours
- match: { method: POST }
action: allow
time_range:
hours: ["09:00-18:00"]
days: ["mon", "tue", "wed", "thu", "fri"]
# Catch-all deny outside business hours
- match: { method: POST }
action: deny
message: "Writes only allowed during business hours"
# Allow reads anytime
- match: { method: GET }
action: allow
Common Policy Patterns¶
Read-Only Access¶
rules:
- match: { method: GET }
action: allow
- match: { method: HEAD }
action: allow
- match: { method: "*" }
action: deny
message: "Read-only access"
Allow Specific Operations¶
rules:
# Allow reading tasks
- match: { method: GET, path: "/tasks*" }
action: allow
# Allow creating tasks
- match: { method: POST, path: "/tasks" }
action: allow
# Allow closing tasks
- match: { method: POST, path: "/tasks/*/close" }
action: allow
# Deny everything else
- match: { method: "*" }
action: deny
Approval for Sensitive Operations¶
rules:
# Auto-allow reads
- match: { method: GET }
action: allow
# Auto-allow common writes
- match: { method: POST, path: "/tasks" }
action: allow
# Require approval for updates
- match: { method: PUT }
action: ask
# Require approval for deletes
- match: { method: DELETE }
action: ask
Rate-Limited API Access¶
rules:
# Generous limit for reads
- match: { method: GET }
action: allow
rate_limit: { max: 1000, window: "1h" }
# Strict limit for writes
- match: { method: POST }
action: allow
rate_limit: { max: 100, window: "1h" }
# Very strict limit for deletes
- match: { method: DELETE }
action: allow
rate_limit: { max: 10, window: "1h" }
Business Hours Only¶
rules:
# Allow during business hours
- match: { method: "*" }
action: allow
time_range:
hours: ["09:00-18:00"]
days: ["mon", "tue", "wed", "thu", "fri"]
# Require approval outside business hours
- match: { method: "*" }
action: ask
Tiered Access by Sensitivity¶
rules:
# Public endpoints - allow freely
- match: { method: GET, path: "/public*" }
action: allow
# Normal operations - allow with rate limit
- match: { method: GET }
action: allow
rate_limit: { max: 100, window: "1m" }
# Sensitive operations - require approval
- match: { method: "*", path: "/admin*" }
action: ask
# Everything else - deny
- match: { method: "*" }
action: deny
Conclave Policies (Remote Execution)¶
Each conclave has its own rules: section that evaluates shell commands sent via wardgate-cli exec.
Exec Match Fields¶
| Field | Type | Description |
|---|---|---|
command |
glob | Command name (e.g., rg, python*, *) |
args_pattern |
regex | Joined argument string (e.g., ^(status\|log\|diff)) |
cwd_pattern |
glob | Working directory (e.g., /data/vault/**) |
All fields are optional and AND-ed together.
Conclave Policy Examples¶
conclaves:
code:
key_env: WARDGATE_CONCLAVE_CODE_KEY
rules:
# Read-only tools
- match: { command: "rg" }
action: allow
- match: { command: "cat" }
action: allow
# Git: allow reads, ask for writes
- match: { command: "git", args_pattern: "^(status|log|diff|show)" }
action: allow
- match: { command: "git", args_pattern: "^(push|commit|rebase)" }
action: ask
# Restrict to a directory
- match: { command: "git", cwd_pattern: "/home/dev/project/**" }
action: allow
# Default deny
- match: { command: "*" }
action: deny
Shell Pipelines¶
When agents run piped commands (rg TODO | head -20), wardgate-cli parses the pipeline and evaluates each segment individually. All must pass.
Supported: pipes (|), chains (&&, ||, ;), redirections (>, >>, <).
Rejected: command substitution ($(), backticks), process substitution (<(), >()), subshells ((cmd)).
See Conclaves for full documentation.
Output Filtering¶
Conclave output (stdout/stderr) can be filtered for sensitive data using the same filter engine as endpoint filtering. Configure filter: on the conclave for a default, and optionally override per-command. Supported actions: block, redact, log. See Conclaves - Output Filtering and Config - Conclave Output Filtering for details.
Command Template Rules¶
Command templates (commands: in conclave config) support per-argument policy rules, evaluated independently from conclave-level exec rules. This lets you set different actions for different argument values - for example, allowing reads in one directory while requiring approval for another.
Two Layers¶
- Path validation (
type: path+allowed_pathson args) - hard boundary, rejects immediately - Command rules (
ruleson the command) - first-match-wins policy (allow/ask/deny)
Example¶
commands:
read:
template: "python3 /usr/local/lib/wardgate-tools/file_read.py {file}"
args:
- name: file
type: path
allowed_paths: ["notes/**", "config/**"]
rules:
- match: { file: "notes/**" }
action: allow
- match: { file: "config/**" }
action: ask
# no catch-all -> unmatched paths default deny
patch:
template: "python3 /usr/local/lib/wardgate-tools/file_patch.py {file} {old_text} {new_text}"
args:
- name: file
type: path
allowed_paths: ["notes/**"]
- name: old_text
- name: new_text
action: ask # no rules, static action for all paths
Rule Evaluation¶
- Rules are evaluated in order (first match wins)
- Match keys are arg names, values are glob patterns
- Multiple match keys in one rule are AND-ed (all must match)
- If no rule matches, the request is denied (consistent with conclave exec rules)
- When no
rulesare present, theactionfield is used directly (default:allow)
Path Validation¶
Args with type: path are validated before rules are evaluated:
- Absolute paths (
/etc/passwd) are rejected - Path traversal (
../secret) is rejected - The value must match at least one
allowed_pathsglob pattern - If
allowed_pathsis omitted, no gateway-level restriction is applied (only the script-level CWD check)
Debugging Policies¶
Policy Evaluation Order¶
Rules are evaluated top-to-bottom. Put more specific rules first:
# CORRECT: Specific rules first
rules:
- match: { method: GET, path: "/tasks/123" } # Specific
action: deny
- match: { method: GET, path: "/tasks*" } # General
action: allow
# WRONG: General rule matches first
rules:
- match: { method: GET, path: "/tasks*" } # Matches first!
action: allow
- match: { method: GET, path: "/tasks/123" } # Never reached
action: deny
Default Deny¶
If no rules match, requests are denied. Always add a catch-all rule at the end:
rules:
- match: { method: GET }
action: allow
# ... other rules ...
# Catch-all at the end
- match: { method: "*" }
action: deny
message: "No matching rule"
Audit Logs¶
Check audit logs to see which rules were evaluated:
{
"method": "POST",
"path": "/tasks",
"decision": "allow",
"rules_evaluated": ["rule_1", "rule_2"]
}
Migration Guide¶
From No Access Control¶
Start with deny-all, add specific allows:
# Step 1: Deny everything
rules:
- match: { method: "*" }
action: deny
# Step 2: Add specific allows
rules:
- match: { method: GET, path: "/tasks" }
action: allow
- match: { method: "*" }
action: deny
# Step 3: Expand as needed
rules:
- match: { method: GET }
action: allow
- match: { method: POST, path: "/tasks" }
action: allow
- match: { method: "*" }
action: deny
From Allow-All¶
Gradually restrict access:
# Step 1: Log everything (still allow)
rules:
- match: { method: "*" }
action: allow
# Step 2: Deny dangerous operations
rules:
- match: { method: DELETE }
action: deny
- match: { method: "*" }
action: allow
# Step 3: Add rate limits
rules:
- match: { method: DELETE }
action: deny
- match: { method: "*" }
action: allow
rate_limit: { max: 100, window: "1m" }