Skip to main content

Policy Engine

The policy engine is a compiled declarative rule engine that evaluates every Model B request before it reaches the provider API and filters every response before it reaches the agent.

Why a Rule Engine

The naive approach -- classifying requests as "sensitive" purely by HTTP method -- cannot distinguish between POST /gmail/v1/users/me/messages/send (sending email to the CEO) and POST /gmail/v1/users/me/labels (creating a label). Both are POST requests, but they carry vastly different risk.

The policy engine adds:

  • Request evaluation based on method + URL pattern + body content
  • Response filtering with field allow/deny lists and PII redaction
  • Sub-100us overhead per request (provider API calls are 50-500ms)
  • Compile-once, evaluate-many semantics with in-memory caching

Pipeline

Request arrives
|
v
[1] Existing checks (connection lookup, time windows, allowlists)
|
v
[2] REQUEST RULES -- ordered, first match wins
| Actions: allow | deny | require_approval
v
[3] Execute provider API call (Model B flow)
|
v
[4] RESPONSE RULES -- ordered, first match wins
| Filter: allowFields | denyFields | redact patterns
v
Return filtered response

If no request rule matches, the existing step-up approval behavior is the fallback. Existing policies without rules continue to work unchanged.

Request Rules

Rule Structure

interface RequestRule {
label?: string;
match: {
methods?: string[]; // HTTP methods. Empty = match all
urlPattern?: string; // Regex against URL path. Omitted = match all
body?: BodyCondition[]; // JSON body conditions. All must match (AND)
};
action: "allow" | "deny" | "require_approval";
}

interface BodyCondition {
path: string; // Dot-notation into JSON body, e.g. "message.to"
op: "eq" | "neq" | "in" | "not_in" | "contains" | "matches" | "exists";
value?: string | number | boolean | string[];
}

Evaluation Model

Rules are evaluated top-to-bottom. The first matching rule determines the action. This is the firewall model -- ordering matters, and the most specific rules go first.

function evaluateRequestRules(
rules: CompiledRequestRule[],
method: string,
urlPath: string,
body: unknown,
): "allow" | "deny" | "require_approval" | null {
for (const rule of rules) {
if (rule.methodSet && !rule.methodSet.has(method)) continue;
if (rule.urlRegex && !rule.urlRegex.test(urlPath)) continue;
if (rule.bodyConditions.length > 0) {
if (!evaluateBodyConditions(rule.bodyConditions, body)) continue;
}
return rule.action;
}
return null; // No match -- fall through to legacy behavior
}

Example: Google Gmail Policy

{
"request": [
{
"label": "Allow reading messages",
"match": { "methods": ["GET"], "urlPattern": "^/gmail/v1/users/me/messages" },
"action": "allow"
},
{
"label": "Auto-approve label creation",
"match": { "methods": ["POST"], "urlPattern": "^/gmail/v1/users/me/labels$" },
"action": "allow"
},
{
"label": "Approve external emails",
"match": {
"methods": ["POST"],
"urlPattern": "^/gmail/v1/users/me/messages/send$",
"body": [
{ "path": "message.to", "op": "not_in", "value": ["*@mycompany.com"] }
]
},
"action": "require_approval"
},
{
"label": "Allow internal emails",
"match": {
"methods": ["POST"],
"urlPattern": "^/gmail/v1/users/me/messages/send$"
},
"action": "allow"
}
]
}

In this example, GET requests to the messages endpoint are always allowed. Label creation is auto-approved. Emails to external addresses require human approval, while internal emails are allowed. The ordering is critical -- the external email rule must come before the catch-all internal email rule.

Response Rules

Response rules run after the provider API call returns and before sending data to the agent. For streaming responses (stream: true), response rules are applied in real-time per event/chunk using a stateful Transform stream -- no buffering of the entire response:

  • SSE (text/event-stream): Each data: {...}\n\n event is buffered until the delimiter, parsed as JSON, filtered, and re-serialized
  • NDJSON (application/x-ndjson): Each newline-delimited JSON line is parsed and filtered
  • Text (text/*): PII redaction regex applied directly to each chunk
  • Binary/other: Passed through unmodified

If no response rules are configured, streaming chunks pass through with zero overhead.

Rule Structure

interface ResponseRule {
label?: string;
match: {
urlPattern?: string;
methods?: string[];
};
filter: {
allowFields?: string[]; // Only these dot-paths survive (allowlist)
denyFields?: string[]; // These dot-paths are removed (denylist)
redact?: RedactPattern[]; // PII patterns to redact in string values
};
}
warning

allowFields and denyFields are mutually exclusive. Use one or the other per rule.

Field Filtering

Allowlist mode keeps only the specified fields:

{
"filter": {
"allowFields": ["id", "name", "email"]
}
}

Denylist mode removes the specified fields:

{
"label": "Strip PII from contact reads",
"match": { "urlPattern": "/people/v1/people" },
"filter": {
"denyFields": ["phoneNumbers", "addresses", "birthdays"]
}
}

PII Redaction

Redaction patterns are applied to all string values in the response after field filtering. Supported built-in patterns:

PatternMatches
emailEmail addresses
phonePhone numbers (US format)
ssnSocial Security Numbers
credit_cardCredit card numbers
ip_addressIPv4 addresses
customUser-defined regex

All patterns for a rule are combined into a single alternation regex at compile time for a single-pass replacement:

{
"filter": {
"redact": [
{ "type": "email" },
{ "type": "phone" },
{ "type": "custom", "pattern": "\\bACCT-\\d{8}\\b", "replacement": "[ACCOUNT]" }
]
}
}

Redacted values are replaced with [REDACTED] by default, or a custom replacement string.

Compilation and Caching

Rules are compiled once when first accessed for a policy, then cached in-memory per Fastify replica. Cookie-based sticky sessions pin agents to replicas, so compile cost is amortized. Cache invalidation is broadcast to all replicas via PostgreSQL LISTEN/NOTIFY.

What Gets Compiled

SourceCompiled FormWhy
methods arraySet<string>O(1) lookup instead of array scan
urlPattern stringRegExpV8 compiles to native code
body[].pathstring[] (split on .)Fast object traversal
in / not_in valuesSet<string>O(1) membership check
matches / containsRegExpPre-compiled, reused across requests
PII patternsSingle alternation RegExpOne regex instead of N separate passes

Cache Invalidation

The cache is invalidated when:

  • A policy is updated via PUT /v1/policies/:id (explicit delete from cache)
  • The Fastify replica restarts (cache is empty on boot)

No TTL-based expiry is needed. Explicit invalidation on policy mutation is deterministic, and LISTEN/NOTIFY broadcasts invalidation to all replicas.

Performance

OperationTimeNotes
Method check (Set.has)~10 nsO(1) hash lookup
URL regex (regex.test)100-500 nsV8 native code
Body field traversal (3-deep)~50 nsArray index traversal
PII regex on 1KB string2-5 usSingle-pass alternation
Field filtering (10 fields)200-500 nsObject.entries + Set.has
Total request evaluation< 5 us3-5 rules, 1 body condition
Total response filtering< 10 usField filter + PII redact on 1KB
Combined overhead< 15 us0.003% of a 500ms provider call

Zod Schemas

The rule schemas are defined in packages/contracts for validation at write time:

packages/contracts/src/policy.ts
export const BodyConditionSchema = z.object({
path: z.string().min(1),
op: z.enum(["eq", "neq", "in", "not_in", "contains", "matches", "exists"]),
value: z.union([
z.string(), z.number(), z.boolean(), z.array(z.string()),
]).optional(),
});

export const RequestRuleSchema = z.object({
label: z.string().optional(),
match: z.object({
methods: z.array(z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"])).optional(),
urlPattern: z.string().optional(),
body: z.array(BodyConditionSchema).optional(),
}),
action: z.enum(["allow", "deny", "require_approval"]),
});

export const ResponseRuleSchema = z.object({
label: z.string().optional(),
match: z.object({
urlPattern: z.string().optional(),
methods: z.array(z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"])).optional(),
}),
filter: z.object({
allowFields: z.array(z.string()).optional(),
denyFields: z.array(z.string()).optional(),
redact: z.array(RedactPatternSchema).optional(),
}),
});

export const PolicyRulesSchema = z.object({
request: z.array(RequestRuleSchema).default([]),
response: z.array(ResponseRuleSchema).default([]),
});

Contextual Guards

Contextual guards are security controls organized by what the agent is doing, not which API it is calling. Each guard is a single toggle in the policy wizard that produces provider-specific rules when enabled.

Why Guards

Provider-specific rule templates treat rules as API plumbing. A user does not think "I want to match POST /v1.0/me/sendMail with a body condition on message.body.content." They think "I want a profanity filter on outbound messages."

The same security concern (profanity, PII, audience limits) applies across providers -- only the URL patterns and body paths differ.

Guard Categories

CategoryDescriptionExamples
Content SafetyCross-cutting content inspectionProfanity filter, PII outbound guard, PII redaction
MessagingOutbound message controlsSend approval, forward protection, attachment type guard
File SharingFile upload/share controlsPublic share block, external share guard, dangerous file types
CalendarEvent/invitation controlsExternal attendee guard, event cancellation guard
Data ReadingResponse filteringContact PII stripping
DestructiveDelete/kick/ban controlsDelete protection, member removal protection
AdminRole/settings controlsSettings change guard

How Guards Work

Each guard contains provider-specific rule implementations. When a guard is enabled for a provider, its request and response rules are merged into the policy's rules object. Multiple guards compose together -- the final rule ordering follows the firewall model.

interface ContextualGuard {
id: string; // e.g., "cs-profanity"
category: GuardCategory;
name: string; // e.g., "Profanity Filter"
description: string;
risk: "low" | "medium" | "high";
presetTier: "standard" | "strict";
providers: string[];
rules: Record<string, {
requestRules: RequestRule[];
responseRules: ResponseRule[];
}>;
}

Presets and Guards

Presets (minimal / standard / strict) and guards are complementary:

  1. Step 2 of the policy wizard -- the user picks a preset (sets baseline rules)
  2. Step 3 -- the user toggles individual guards on/off (adds or removes specific rules)

Guards enabled by a preset are pre-selected in the wizard. The user can deselect them or enable additional guards beyond the preset.

MVP Guard Inventory

IDNameCategoryRiskProviders
cs-profanityProfanity FilterContent SafetyHighMS, TG, SL
cs-pii-outboundPII Outbound GuardContent SafetyHighG, MS, TG, SL
cs-pii-redactPII Response RedactionContent SafetyMediumG, MS, TG, SL
msg-send-approvalSend ApprovalMessagingMediumG, MS, TG, SL
msg-forward-blockForward ProtectionMessagingMediumTG
fs-public-shareBlock Public SharingFile SharingHighG, MS
fs-external-shareExternal Sharing GuardFile SharingHighG, MS
fs-dangerous-fileDangerous File Type GuardFile SharingHighG, MS
cal-external-attendeeExternal Attendee GuardCalendarHighG, MS
dest-delete-protectDelete ProtectionDestructiveHighG, MS, TG, SL
adm-settings-guardSettings Change GuardAdminHighG, MS

G = Google, MS = Microsoft, TG = Telegram, SL = Slack

tip

For the full guard matrix including all providers and body content paths, see the OpenClaw contextual rules documentation.

Audit Trail

When a request rule determines the action, the audit event includes the matched rule label:

logExecutionDenied(sub, agentId, connectionId, {
model: "B",
method,
url,
reason: `Denied by policy rule: "${rule.label ?? "unnamed"}"`,
});

When response filtering is applied:

logResponseFiltered(sub, agentId, connectionId, {
model: "B",
method,
path: urlPath,
rule: rule.label,
fieldsRemoved: removedCount,
redactionsApplied: redactCount,
});

All audit events are fire-and-forget (async) -- they never block the response.

Migration Path

  1. Phase 1: Add rules JSONB column with empty default. Implement engine with fallback to existing behavior
  2. Phase 2: Create rule templates per provider (presets)
  3. Phase 3: Build rule authoring UI (contextual guards wizard)
  4. Phase 4: Deprecate legacy stepUpApproval enum in favor of rules (keep column for backward compatibility)

Phase 1 is zero-breaking-change: existing policies with empty rules behave identically to the current system.