# Writing policies

> Use the visual policy builder or raw JSON to allow, deny, or hide MCP tools, add argument-level conditions, and set per-grant quota limits.

Canonical: https://policylayer.com/docs/writing-policies

A policy is one JSON document attached to grants on an MCP server. It decides which tools a grant can call, which tools are hidden, and which calls consume quota.

Use the dashboard's visual policy builder first. It writes the same policy JSON shown below, but it is easier for day-to-day policy work.

## Start with the visual builder

The visual builder loads the server's tool list and gives each tool four states:

| State | Meaning |
|---|---|
| Allow | The tool can be called. |
| Deny | The tool stays visible, but every call is blocked. |
| Hide | The tool is removed from `tools/list` and blocked. |
| Custom | Add argument rules and limits for that tool. |

Use **Custom** for rules like "require `args.reason` on refunds", "deny charges over 10000", or "limit this grant to 30 calls per minute."

Switch to **Raw JSON** when you want to inspect the exact document, paste in a policy, or add `all_tools` limits. `all_tools` limits are not exposed in the visual builder today.

## Policy shape

```json
{
  "version": "1",
  "default": "deny",
  "hide": ["delete_account"],
  "all_tools": {
    "limits": []
  },
  "tools": {
    "create_charge": {
      "require": [],
      "deny_if": [],
      "limits": []
    }
  }
}
```

| Field | Required | Meaning |
|---|---|---|
| `version` | Yes | Schema version. Only `"1"` is recognised. |
| `default` | Yes | `"allow"` or `"deny"`. This controls tools not listed under `tools`. |
| `hide` | No | Tool names removed from streamable HTTP `tools/list` responses and denied at call time. The wildcard `"*"` hides everything. |
| `all_tools.limits` | No | Static quota limits that apply across every tool call. |
| `tools[name]` | No | Per-tool rules for a named MCP tool. Under `default: deny`, listing a tool with `{}` allows it. |

A grant with no policy denies every call before the document is evaluated.

## Choose the default

`default: deny` is the safer starting point. Any tool you do not list is blocked.

```json
{
  "version": "1",
  "default": "deny",
  "tools": {
    "list_customers": {}
  }
}
```

`default: allow` is permissive. Use it when most tools should remain available and you only need to constrain specific tools.

```json
{
  "version": "1",
  "default": "allow",
  "tools": {
    "force_push": {
      "deny_if": [{ "conditions": [] }]
    }
  }
}
```

That `deny_if` rule has no conditions, so it always matches. The tool remains visible, but every call is denied.

## Hide or deny

Use `hide` when the agent should not see a tool in `tools/list`.

Use `deny_if` when the agent may see the tool, but calls should fail with a policy denial.

Hidden tools are filtered from streamable HTTP `tools/list` responses and denied on every supported call path. A denied tool stays visible and returns a policy-denied tool result when called.

## Evaluation order

For each `tools/call`, PolicyLayer evaluates the policy in this order. The first denial wins.

1. `hide`: hidden tool names, or `"*"`, deny immediately.
2. `default`: `default: deny` blocks tools not listed under `tools`.
3. `require`: every `require` predicate for the tool must match.
4. `deny_if`: any matching `deny_if` predicate denies.
5. `limits`: PolicyLayer reserves quota counters before forwarding the call.

Steps 1 to 4 only inspect the call arguments. Step 5 is stateful and uses atomic quota counters.

## Custom rules

Custom rules use predicates. A predicate contains conditions, and the conditions inside one predicate are ANDed together.

```json
{
  "conditions": [
    { "path": "args.amount", "op": "gt", "value": 10000 },
    { "path": "args.currency", "op": "eq", "value": "USD" }
  ],
  "on_deny": "USD amount is above policy."
}
```

When a predicate denies, `on_deny` is the message returned to the client and recorded with the decision. If it is omitted, PolicyLayer uses a default policy-denied message.

`require` and `deny_if` use predicates differently:

| Section | Meaning |
|---|---|
| `require` | Every predicate must match. The first unmet predicate denies. |
| `deny_if` | Any matching predicate denies. The first matching predicate wins. |

`require` predicates must have at least one condition. `deny_if` predicates may have an empty `conditions` array, which means unconditional deny.

## Conditions

A condition reads one value from the tool call arguments and compares it.

```json
{ "path": "args.amount", "op": "lte", "value": 5000 }
```

| Field | Notes |
|---|---|
| `path` | Must start with `args.`. The rest is a dotted path into the JSON arguments. |
| `op` | One of the operators below. |
| `value` | The literal value to compare against. |

| Operator | Meaning | Example value |
|---|---|---|
| `eq` | Equality. Type-strict, except numbers compare across integer and float forms. | `"production"` |
| `neq` | Not equal. | `"prod"` |
| `in` | Argument value is in a list. | `["main", "release"]` |
| `not_in` | Argument value is not in a list. | `["main"]` |
| `lt`, `lte`, `gt`, `gte` | Numeric comparison. | `5000` |
| `regex` | Go regexp matches a string argument. | `"^prod-"` |
| `contains` | String contains substring, or list contains element. | `"DROP"` |
| `exists` | Argument is present and non-null when `value` is `true`; absent or null when `value` is `false`. | `true` |

`args.` is the only path namespace. Dotted object paths are supported:

- `args.amount` reads the top-level `amount` field.
- `args.recipient.email` reads a nested `email` field.

Array indexes are not supported in argument paths today. If you need to write policy against a value inside a list, expose that value as a named object field or top-level argument.

If a path does not resolve, the condition is unmet. The `exists` operator is the exception because it explicitly checks presence.

## Limits

A limit reserves quota before the call is forwarded. If the increment would exceed `max`, the call is denied and earlier reservations from the same call are rolled back.

If the upstream response shows the tool call failed, the reservation is rolled back, so quota tracks successful tool calls.

```json
{
  "counter": "create_charge_per_minute",
  "window": "minute",
  "max": 30,
  "scope": "grant",
  "increment": 1
}
```

| Field | Required | Default | Meaning |
|---|---|---|---|
| `counter` | Yes | | Counter name. Distinguishes limits sharing a scope and window. |
| `window` | Yes | | `minute`, `hour`, or `day`. Windows are calendar-aligned in UTC. |
| `max` | Yes | | Maximum cumulative increments per window. Must be at least 1. |
| `scope` | No | `grant` | `global`, `server`, `policy`, or `grant`. |
| `increment` | No | `1` | Units consumed per successful call. |
| `increment_from` | No | | Args path. When set, the counter consumes that argument value instead of `increment`. |
| `on_deny` | No | | Custom denial message for this limit. |

## Limit scopes

| Scope | Meaning |
|---|---|
| `grant` | One counter per grant. Use this for per-token rate limits. |
| `policy` | All grants attached to the same policy share the counter. |
| `server` | All grants on the server share the counter. |
| `global` | One counter across the whole deployment. Use sparingly. |

`minute`, `hour`, and `day` windows are calendar-aligned in UTC. Minute windows reset at every `:00`, hour windows reset at every `:00:00`, and day windows reset at midnight UTC.

Within one section, every `(scope, counter, window)` triple must be unique. A `30/minute` limit and a `1000/day` limit can share the same counter name because the windows differ.

## Spend caps

Use `increment_from` when the cost of a call comes from its arguments.

```json
{
  "version": "1",
  "default": "deny",
  "tools": {
    "create_charge": {
      "limits": [
        {
          "counter": "daily_charge_total",
          "window": "day",
          "max": 50000,
          "scope": "grant",
          "increment_from": "args.amount",
          "on_deny": "Daily charge limit exceeded."
        }
      ]
    }
  }
}
```

A `create_charge` call with `amount: 12000` consumes 12000 from the daily 50000 cap.

`increment_from` must resolve at runtime to an integer of at least 1. Missing, fractional, non-numeric, and negative values fail policy evaluation. Use smaller units, such as cents instead of dollars, if you need fractional precision.

`all_tools.limits` cannot use `increment_from` because argument fields are tool-specific. Use static `increment` for `all_tools` limits.

## Cross-tool limits

`all_tools` carries `limits` only. It does not support cross-tool `require` or `deny_if` rules.

That is deliberate: condition paths only read from `args.<path>`, and argument names are tool-specific. A cross-tool requirement on `args.amount` would deny every call to tools that do not have an `amount` argument.

If you need the same predicate on multiple tools, repeat it under each tool. The visual builder covers per-tool rules. Edit `all_tools` limits in Raw JSON.

## Saving policies

Schema validation runs on save. The validator rejects unknown operators, malformed regexes, invalid limits, duplicate hide entries, empty defaults, and any `version` other than `"1"`.

Body edits create a new immutable policy version. Renaming a policy is metadata-only and does not create a new version. After a policy changes, the proxy invalidates its policy cache; connected clients do not need to reconnect.
