Skip to content

Latest commit

 

History

History
700 lines (538 loc) · 27.9 KB

File metadata and controls

700 lines (538 loc) · 27.9 KB

Policy Reference

This document covers the YAML policy file format used by Intercept to enforce rules on MCP tool calls. For CLI commands and flags, see USAGE.md.

Overview

A policy file defines which tool calls are allowed, denied, or rate-limited. Intercept loads the policy on startup and evaluates every incoming tools/call request against it. Calls that pass all rules are forwarded to the upstream MCP server. Calls that fail any rule receive a denial message instead.

Quick start: The policies/ directory contains ready-made scaffolds for 349 MCP servers. Copy one and add your rules.

Top-level structure

version: "1"
description: "Human-readable description of this policy"

tools:
  <tool_name>:
    rules:
      - name: "rule name"
        # ...rule definition...
  "*":
    rules:
      - name: "applies to all tools"
        # ...
Field Required Description
version yes Must be "1"
description no Human-readable description of the policy
default no Default posture: "allow" (default) or "deny"
hide no List of tool names to hide from the agent (removed from tools/list, denied on tools/call)
tools yes Map of tool names to their rule definitions

Tool name keys must match the exact tool names exposed by the MCP server. The special key "*" defines wildcard rules that apply to every tool call.

Hiding tools

The hide list removes tools entirely from the agent's view. Hidden tools are stripped from tools/list responses so the agent never sees them, and any tools/call attempt is denied as a safety net.

version: "1"
description: "GitHub MCP server policies"

hide:
  - delete_repository
  - list_webhooks
  - transfer_repository

tools:
  create_issue:
    rules:
      - name: "hourly issue limit"
        rate_limit: 5/hour

Each entry is a tool name. The special value "*" hides all tools.

Hidden tools receive the denial message: Tool "<name>" is hidden by policy.

This is useful when an MCP server exposes many tools (30-50+) but only a few are relevant to the task. Removing irrelevant tools saves context window tokens and prevents the agent from attempting calls that would be denied.

Default policy posture

By default, Intercept uses an allow posture: any tool call that doesn't match a rule is permitted. Setting default: deny flips to an allowlist model where only tools explicitly listed under tools are permitted. Unlisted tools are rejected before any rules (including wildcard rules) are evaluated.

version: "1"
default: deny

tools:
  read_file:
    rules: []          # allowed with no additional checks

  create_issue:
    rules:
      - name: "hourly limit"
        rate_limit: 5/hour

  # Any tool not listed above is automatically denied.

When default: deny is active:

  • A tool listed under tools with rules: [] is allowed unconditionally.
  • A tool listed under tools with rules is evaluated normally.
  • Wildcard ("*") rules still apply to listed tools, but do not rescue unlisted tools.

Rules

Each tool entry contains a list of rules. Every rule must have a name.

tools:
  create_issue:
    rules:
      - name: "hourly issue limit"
        conditions:
          - path: "state.create_issue.hourly_count"
            op: "lte"
            value: 5
        on_deny: "Hourly limit of 5 new issues reached"
        state:
          counter: "hourly_count"
          window: "hour"

Action

The action field controls how the rule behaves:

Action Behaviour Conditions allowed?
"evaluate" (default) Checks conditions; denies the call if any condition fails Required (at least one)
"deny" Unconditionally blocks the tool call Not allowed
"require_approval" Blocks the call until a human approves it via CLI or admin API Optional

If action is omitted, it defaults to "evaluate".

on_deny

The on_deny field is an optional message returned to the AI agent when a rule denies a tool call. For deny and evaluate rules the agent sees it prefixed with [INTERCEPT POLICY DENIED]. For require_approval rules the prefix is [INTERCEPT APPROVAL REQUIRED]:

on_deny: "Hourly limit of 5 new issues reached. Wait before creating more."

The agent receives:

[INTERCEPT POLICY DENIED] Hourly limit of 5 new issues reached. Wait before creating more.

Rate limit shorthand

For simple "N calls per window" limits, use the rate_limit shorthand instead of writing conditions and state blocks manually:

tools:
  create_issue:
    rules:
      - name: "hourly issue limit"
        rate_limit: 5/hour
        on_deny: "Hourly limit of 5 new issues reached"

The format is <count>/<window>, where count is a positive integer and window is minute, hour, or day.

This expands internally to an evaluate rule with a lte condition on an auto-generated counter (_rate_<window>) and a matching state block. If on_deny is omitted, a default message is generated: "Rate limit of N per window reached. Try again later."

Restrictions:

  • Cannot be combined with conditions or state (use the full syntax for advanced cases like dynamic increments or custom counters)
  • Cannot be used with action: "deny"
  • Two rules with the same window on the same tool produce a duplicate counter error (use different windows, or combine into a single rule)

For wildcard ("*") tools, the counter is scoped to _global as usual.

Spend enforcement

When an MCP server charges for a tool call using the Machine Payments Protocol (MPP), Intercept can enforce budget limits before any money moves.

MPP-enabled servers return error code -32042 ("Payment Required") with the price in the response. Intercept reads this price, evaluates it against your spend rules, and either allows the payment challenge through to the agent or replaces it with a policy denial.

Spend shorthand

The spend shorthand works like rate_limit -- it expands into conditions and state blocks automatically:

tools:
  generate_image:
    rules:
      - name: "image budget"
        spend:
          per_call: 5.00
          daily: 50.00

This enforces two limits:

  • No single call can cost more than $5.00
  • Total daily spend on generate_image cannot exceed $50.00

Available fields:

Field Description
per_call Maximum cost per individual tool call (USD)
hourly Maximum cumulative spend per hour (USD)
daily Maximum cumulative spend per day (USD)

All amounts are in USD. Internally, amounts are stored as int64 microdollars (1 USD = 1,000,000 microdollars) to avoid floating-point drift.

How it works

  1. Agent calls a tool (e.g. generate_image)
  2. Intercept forwards the call to the MCP server
  3. MCP server returns -32042 with the price (e.g. $2.50)
  4. Intercept reads the price and checks spend rules
  5. If within budget: the -32042 passes through to the agent, which pays normally
  6. If over budget: Intercept replaces the response with [INTERCEPT SPEND DENIED] -- the agent never sees the payment challenge and no money moves

Spend counters use the same state backend as rate limits (SQLite or Redis) and reset at window boundaries (midnight UTC for daily, top of hour for hourly).

Global spend limits

Use the wildcard tool "*" to cap total spend across all tools:

"*":
  rules:
    - name: "global safety net"
      spend:
        daily: 100.00

Spend with approval escalation

Combine spend rules with require_approval to escalate over-budget calls to a human instead of hard-denying them:

generate_image:
  rules:
    - name: "auto-approve cheap"
      spend:
        per_call: 5.00
        daily: 50.00

    - name: "expensive needs approval"
      action: require_approval
      conditions:
        - path: spend.amount
          op: gt
          value: 5000000
      on_deny: "Image generation over $5 requires approval"

Shadow mode

Set mode: "shadow" at the top level to evaluate spend policies without enforcing them. Denied calls are logged but the -32042 passes through unchanged:

version: "1"
mode: "shadow"

tools:
  generate_image:
    rules:
      - name: "image budget"
        spend:
          per_call: 5.00
          daily: 50.00

This is useful for testing spend policies against real traffic before turning on enforcement.

Restrictions

  • spend cannot be combined with rate_limit on the same rule (use separate rules)
  • spend cannot be combined with conditions or state on the same rule (the shorthand generates these automatically)
  • spend cannot be used with action: "deny" (deny rules are unconditional)
  • Spend enforcement only activates when the upstream server returns a -32042 MPP response. For servers that don't charge via MPP, spend rules are harmless no-ops
  • USD only in v1. Multi-currency support is planned

Approval rules

Rules with action: "require_approval" pause tool calls until a human explicitly approves or denies them. The agent receives a JSON-RPC error (-32003) with the approval ID and instructions.

tools:
  create_charge:
    rules:
      - name: "large charge approval"
        action: "require_approval"
        conditions:
          - path: "args.amount"
            op: "gt"
            value: 50000
        on_deny: "Charges over $500.00 require human approval"
        approval_timeout: "30m"

When a matching call arrives, Intercept creates a pending approval record. The agent is told to wait. A human can then approve or deny via:

intercept approvals approve <id>
intercept approvals deny <id> --reason "too expensive"

If the same tool call (identical arguments and rule) is retried while a pending approval exists, Intercept reuses the existing record rather than creating a duplicate.

Once approved, the next identical call is allowed through and the approval is consumed (single use). If the approval expires before being consumed, the next call creates a fresh pending record.

approval_timeout

Per-rule timeout controlling how long an approval stays valid. Accepts Go duration strings (5m, 1h, 24h). Defaults to the global approvals.default_timeout, which itself defaults to 15m.

- name: "destructive action"
  action: "require_approval"
  approval_timeout: "1h"

Top-level approvals block

approvals:
  default_timeout: 10m
  dedupe_window: 10m
  notify:
    webhook:
      url: "https://your-bot.com/intercept"
      secret: "your-hmac-secret"
Field Required Description
default_timeout no Default approval lifetime (Go duration, default 15m)
dedupe_window no Window for deduplicating identical retry attempts
notify.webhook.url no URL to POST when an approval is requested
notify.webhook.secret no HMAC-SHA256 secret for signing webhook payloads

Webhook notifications

When configured, Intercept POSTs a JSON payload to the webhook URL each time a new approval is requested. The payload includes the approval ID, tool name, rule, reason, expiry, and a safe preview of the arguments.

Webhook deliveries are signed with HMAC-SHA256. Headers: X-Intercept-Signature (hex-encoded) and X-Intercept-Timestamp.

Webhooks are fire-and-forget: delivery failure is logged but never blocks the tool call or causes an accidental allow.

Restrictions

  • rate_limit cannot be combined with action: require_approval on the same rule (use separate rules)
  • state blocks should not be placed on require_approval rules (use a separate rate limit rule)
  • Hidden tools (hide list) cannot be rescued by approval rules
  • Approval fingerprints include a policy revision hash -- changing the policy file invalidates pending approvals

Idempotency

The idempotency_window field on a tool definition prevents duplicate real-world actions when agents retry tool calls. If the same tool call (identical name and arguments) succeeds upstream within the configured window, subsequent identical calls skip policy evaluation and counter increments, forwarding directly to the upstream server.

tools:
  book_ride:
    idempotency_window: "5m"
    rules:
      - name: "daily booking limit"
        rate_limit: 20/day

The window accepts Go duration strings (30s, 5m, 1h). Exact tool config takes precedence over wildcard ("*").

How it works

  1. On first call, Intercept checks the state store for an existing mark (tool name + canonical args + policy revision).
  2. If the call succeeds upstream, a mark is written with the configured TTL.
  3. If the upstream call fails, no mark is written. The next retry evaluates normally.
  4. Denied calls are never marked. The caller can fix the issue and retry.

Idempotency marks are stored in the same state backend as counters (SQLite or Redis). If no state backend is configured, idempotency is silently disabled.

Interaction with approvals

When an approved call succeeds, it is also marked idempotent. Retries after approval do not re-consume additional approvals.

Conditions

Conditions compare a value at a given path against an expected value using an op (operator). All conditions within a rule are ANDed: every condition must pass for the rule to allow the call.

conditions:
  - path: "args.amount"
    op: "lte"
    value: 50000

Path syntax

Paths use dot-notation to reach into data:

  • args.<field>: reads from the tool call arguments (e.g., args.amount, args.metadata.key)
  • state.<scope>.<counter>: reads a stateful counter value (e.g., state.create_issue.hourly_count)
  • spend.<field>: reads from the MPP payment challenge (e.g. spend.amount, spend.currency). Only available during response-time evaluation when a -32042 is received; auto-passes at request time.

For nested arguments, each dot descends one level. Given tool arguments {"metadata": {"key": "val"}}, the path args.metadata.key resolves to "val".

Operators

Operator Value type Description Example
eq any Equal (numeric or string) op: "eq", value: "main"
neq any Not equal op: "neq", value: "draft"
in list Value is in list op: "in", value: ["usd", "eur"]
not_in list Value is not in list op: "not_in", value: ["admin"]
lt numeric Less than op: "lt", value: 100
lte numeric Less than or equal op: "lte", value: 50000
gt numeric Greater than op: "gt", value: 0
gte numeric Greater than or equal op: "gte", value: 1
regex string Matches regular expression op: "regex", value: "^feat/"
contains any Substring match (strings) or element match (lists) op: "contains", value: "test"
exists boolean Field is present (true) or absent (false) op: "exists", value: true

AND logic

All conditions within a single rule are ANDed. Both must pass:

- name: "safe charge"
  conditions:
    - path: "args.amount"
      op: "lte"
      value: 50000
    - path: "args.currency"
      op: "in"
      value: ["usd", "eur"]

Across rules, all rules for a tool must also pass. If any single rule denies the call, the call is denied.

Stateful counters

Rules can track call counts or accumulated values across a time window using a state block:

- name: "daily spend cap"
  conditions:
    - path: "state.create_charge.daily_spend"
      op: "lt"
      value: 1000000
  on_deny: "Daily spending cap of $10,000.00 reached"
  state:
    counter: "daily_spend"
    window: "day"
    increment_from: "args.amount"

Fields

Field Required Default Description
counter yes Name of the counter. Referenced in conditions as state.<tool>.<counter>.
window yes Time window: "minute", "hour", or "day".
increment no 1 Fixed amount to add on each allowed call.
increment_from no Path to a tool argument (e.g., args.amount) whose value is used as the increment instead of the fixed increment.

Windows

Windows are calendar-aligned in UTC:

Window Resets at
"minute" The start of each UTC minute (e.g., 10:31:00.000Z)
"hour" The top of each UTC hour (e.g., 11:00:00.000Z)
"day" Midnight UTC (00:00:00.000Z)

A counter's value resets to zero when a new window begins. For example, a "day" counter that reached 47 at 23:59 UTC will read as 0 at 00:00 UTC.

Reserve and rollback

When a tool call is evaluated, Intercept uses a two-phase model:

  1. Reserve: the counter is atomically read and tentatively incremented. If the post-increment value would exceed the limit, the call is denied immediately without changing the counter.
  2. Forward: the call is sent to the upstream MCP server.
  3. Commit or rollback: if the upstream call succeeds, the reservation stands. If the upstream call fails, the increment is rolled back so a failed call does not consume quota.

Counter scoping

For tool-specific rules, counters are scoped as state.<tool_name>.<counter>:

tools:
  create_issue:
    rules:
      - name: "hourly limit"
        conditions:
          - path: "state.create_issue.hourly_count"
            op: "lte"
            value: 5
        state:
          counter: "hourly_count"
          window: "hour"

For wildcard ("*") rules, counters use the _global scope:

"*":
  rules:
    - name: "global rate limit"
      conditions:
        - path: "state._global.calls_per_minute"
          op: "lte"
          value: 60
      state:
        counter: "calls_per_minute"
        window: "minute"

Dynamic increments

Use increment_from to increment a counter by the value of a tool argument instead of a fixed amount. This is useful for tracking accumulated totals like spending:

- name: "daily spend cap"
  conditions:
    - path: "state.create_charge.daily_spend"
      op: "lt"
      value: 1000000
  on_deny: "Daily spending cap of $10,000.00 reached"
  state:
    counter: "daily_spend"
    window: "day"
    increment_from: "args.amount"

Each allowed create_charge call increments the daily_spend counter by whatever args.amount is (e.g., 5000 for a $50.00 charge in cents).

Wildcard rules

The special tool name "*" defines rules that apply to every tool call, in addition to any tool-specific rules. Wildcard rules are evaluated after tool-specific rules.

State counters under wildcard rules use the _global scope, so the condition path is state._global.<counter>.

Rule evaluation order

When a tools/call request arrives, Intercept processes it as follows:

  1. If default: deny is set and the tool is not listed under tools, deny immediately. Wildcard rules do not apply to unlisted tools.
  2. Look up rules for the specific tool name.
  3. Look up wildcard ("*") rules.
  4. Evaluate all matching rules in order (tool-specific first, then wildcard):
    • If a rule has action: "deny", the call is denied immediately.
    • If a rule has action: "require_approval", and conditions match (or no conditions), the call is held for approval.
    • If a rule has action: "evaluate", all its conditions must pass.
  5. If any rule denies the call, the on_deny message from that rule is returned to the agent.
  6. If all rules pass, reserve any stateful counters atomically.
  7. Forward the call to the upstream server.
  8. On success, commit the counter increments. On failure, roll back.
  9. If the upstream returns a -32042 MPP payment challenge, the spend filter evaluates spend rules against the challenge amount. If over budget, the response is replaced with a spend denial before the agent sees it.

If default is "allow" (or omitted) and no rules match a tool, the call is allowed.

Complete examples

GitHub MCP server

Rate-limits issue and PR creation, blocks repository deletion, and enforces a global rate limit across all tools.

version: "1"
description: "GitHub MCP server policies"

hide:
  - transfer_repository
  - list_webhooks

tools:
  create_issue:
    rules:
      - name: "hourly issue limit"
        rate_limit: 5/hour
        on_deny: "Hourly limit of 5 new issues reached"

  create_pull_request:
    rules:
      - name: "hourly pr limit"
        rate_limit: 3/hour
        on_deny: "Hourly limit of 3 new pull requests reached"

  delete_repository:
    rules:
      - name: "block repo deletion"
        action: "deny"
        on_deny: "Repository deletion is not permitted via AI agents"

  "*":
    rules:
      - name: "global rate limit"
        rate_limit: 60/minute

Stripe MCP server

Caps individual charge amounts, tracks daily spending with dynamic increments, restricts currencies, limits refunds, and blocks customer deletion.

version: "1"
description: "Stripe MCP server policies"

tools:
  create_charge:
    rules:
      - name: "max single charge"
        conditions:
          - path: "args.amount"
            op: "lte"
            value: 50000
        on_deny: "Single charge cannot exceed $500.00"

      - name: "daily spend cap"
        conditions:
          - path: "state.create_charge.daily_spend"
            op: "lte"
            value: 1000000
        on_deny: "Daily spending cap of $10,000.00 reached"
        state:
          counter: "daily_spend"
          window: "day"
          increment_from: "args.amount"

      - name: "allowed currencies"
        conditions:
          - path: "args.currency"
            op: "in"
            value: ["usd", "eur"]
        on_deny: "Only USD and EUR charges are permitted"

  create_refund:
    rules:
      - name: "refund limit"
        conditions:
          - path: "args.amount"
            op: "lte"
            value: 10000
        on_deny: "Refunds over $100.00 require manual processing"

      - name: "daily refund count"
        conditions:
          - path: "state.create_refund.daily_count"
            op: "lte"
            value: 10
        on_deny: "Daily refund limit (10) reached"
        state:
          counter: "daily_count"
          window: "day"

  delete_customer:
    rules:
      - name: "block destructive action"
        action: "deny"
        on_deny: "Customer deletion is not permitted via AI agents"

  "*":
    rules:
      - name: "global rate limit"
        conditions:
          - path: "state._global.calls_per_minute"
            op: "lte"
            value: 60
        on_deny: "Rate limit: maximum 60 tool calls per minute"
        state:
          counter: "calls_per_minute"
          window: "minute"

Validation errors

Run intercept validate -c policy.yaml to check a policy file for errors. Every possible error is listed below with its cause and fix.

Error Cause Fix
hide[N]: entry must not be empty An entry in the hide list is blank Remove the empty entry or provide a tool name
hide: duplicate entry "<name>" The same tool name appears more than once in hide Remove the duplicate
version must be "1", got "<x>" The version field is missing or not "1" Set version: "1"
default must be "allow" or "deny", got "<x>" The default field has an unrecognised value Use "allow" or "deny", or omit the field
rule must have a name A rule is missing the name field Add a name to the rule
action must be "evaluate", "deny", or "require_approval", got "<x>" Unrecognised action value Use "evaluate", "deny", or "require_approval"
deny rules must not have conditions A rule with action: "deny" has a conditions list Remove conditions from deny rules
evaluate rules must have at least one condition A rule with action: "evaluate" has no conditions Add at least one condition
path must start with "args." or "state.", got "<x>" Condition path does not reference tool arguments or state Use args.<field> or state.<scope>.<counter>
unknown operator "<op>" The op field contains an unrecognised operator Use one of: eq, neq, in, not_in, lt, lte, gt, gte, regex, contains, exists
operator "<op>" requires a list value in or not_in was given a non-list value Provide a YAML list (e.g., ["a", "b"])
operator "<op>" requires a numeric value lt, lte, gt, or gte was given a non-numeric value Provide a number
operator "exists" requires a boolean value exists was given a non-boolean value Use true or false
regex value must be a string The regex operator was given a non-string value Provide a string pattern
invalid regex "<pattern>": <error> The regex pattern does not compile Fix the regular expression syntax
counter must not be empty The state.counter field is blank Provide a counter name
window must be "minute", "hour", or "day", got "<x>" Unrecognised window value Use "minute", "hour", or "day"
increment_from must start with "args.", got "<x>" Dynamic increment does not reference a tool argument Use a path like args.amount
condition references state.<tool>.<counter> but no matching state block found A condition reads a counter that no rule defines with a state block Add a state block with the matching counter name to a rule for that tool
rate_limit count must be a positive integer, got "<x>" The count in rate_limit is not a positive number Use a positive integer (e.g., 5/hour)
rate_limit window must be "minute", "hour", or "day", got "<x>" Unrecognised window in rate_limit Use minute, hour, or day
rate_limit cannot be combined with conditions or state A rule uses rate_limit alongside conditions or state Use separate rules, or switch to the full syntax
rate_limit cannot be used with action "deny" A deny rule also has rate_limit Remove rate_limit or change the action
rate_limit cannot be used with action "require_approval" An approval rule also has rate_limit Use separate rules for rate limiting and approval
require_approval rules must not have a state block An approval rule has a state block Move the state block to a separate evaluate rule
invalid approval_timeout "<x>" The approval_timeout value is not a valid Go duration Use a duration like 5m, 1h, 24h
approval_timeout must be positive The approval_timeout resolves to zero or negative Use a positive duration
approvals.default_timeout: invalid duration "<x>" The global default timeout is not a valid Go duration Use a duration like 15m
approvals.default_timeout must be positive The global default timeout is zero or negative Use a positive duration
approvals.dedupe_window: invalid duration "<x>" The dedupe window is not a valid Go duration Use a duration like 10m
approvals.dedupe_window must be positive The dedupe window is zero or negative Use a positive duration
approvals.notify.webhook.url must not be empty Webhook URL is blank Provide a URL or remove the webhook block
approvals.notify.webhook.url must use http or https Webhook URL uses an unsupported scheme Use an http:// or https:// URL
approvals.notify.webhook.secret must not be empty Webhook secret is blank Provide a secret for HMAC-SHA256 signing
tools.<name>: invalid idempotency_window "<x>" The idempotency_window value is not a valid Go duration Use a duration like 5m, 1h
tools.<name>: idempotency_window must be positive The idempotency_window resolves to zero or negative Use a positive duration
spend cannot be combined with rate_limit A rule has both spend and rate_limit Use separate rules for spend and rate limiting
spend cannot be combined with conditions or state A rule has spend alongside existing conditions Use the full syntax or separate rules
mode must be "enforce" or "shadow", got "<x>" The mode field has an unrecognised value Use "enforce", "shadow", or omit the field
duplicate state counter "<name>" (also used by rules[N]) Two rules on the same tool define the same counter name Use different counter names or different windows