# policies.json

`policies.json` defines guard policies for your Swytchcode project.

Unlike `tooling.json`, which controls **what can execute**, `policies.json` controls **whether a call may execute, and in what form**, based on the call being made. Policies can block a call, hold it for [human approval](/policies/human-approval/), change its arguments, or - once the call is made - hide or cut parts of its response before the agent sees it.

The file lives at:

```
.swytchcode/integrations/policies.json
```

Policies apply to every call made through Swytchcode: `swy exec` and the `swytchcode_exec` MCP tool.

> **How it works**
>
> Before a method runs, Swytchcode checks the policies that target it. If a policy's condition is `true`, its action is applied. Response policies run again after the call, before the result is returned.

---

## Managing Policies

### Add a policy

```bash
swy policy add
```

Creates a policy interactively: target, one condition, and an action. Every prompt also has a flag, so it can run in scripts with `--non-interactive`:

```bash
swy policy add --non-interactive --name "Payment limit" \
  --target stripe.payment_intent.create \
  --field amount --operator ">" --value 100000 \
  --action POLICY_BLOCKED --message "Payments above 1000.00 are blocked"
```

| Flag | Description |
|------|-------------|
| `--name`, `--id` | Policy name and id (default: generated from the name). |
| `--target` | Canonical ID(s) the policy guards. Repeatable or comma-separated. |
| `--field`, `--operator`, `--value` | The condition. |
| `--action`, `--message` | What happens when it matches, and the message shown. |
| `--list <name>` | Compare against a named [list](#lists) instead of `--value`. *Version 2.* |
| `--ignore-case` | Compare text without regard to case. *Version 2.* |
| `--transform <names>` | Change the field before comparing, e.g. `domain` or `count`. See [transform](#transform). *Version 2.* |
| `--quantifier any\|all` | When the field has several values, whether any or all must match. *Version 2.* |
| `--mode monitor` | Record matches only, never act on them. See [mode](#mode). *Version 2.* |
| `--hint <text>` | Tells the agent what to do instead. See [hint](#hint). *Version 2.* |

Flags marked *Version 2* need a [version-2 file](#file-versions). A new `policies.json` gets version 2 automatically when the first policy needs it. `swy policy add` never upgrades an existing version-1 file: it refuses and tells you to add `"version": 2` first.

For policies with several conditions or targets, write them by hand or with a coding agent through the [MCP policy tools](/policies/overview/#write-policies-with-a-coding-agent).

---

### List policies

```bash
swy policy list [--json]
```

Shows every policy with its targets, condition, and action. Policies in monitor mode, switched off, not yet active, or expired are marked.

---

### Remove a policy

```bash
swy policy remove <policy-id>
```

Deletes a policy by its ID.

---

### Validate policies

```bash
swy policy validate
```

Validation checks for:

- Unknown operators, actions, or fields
- Missing required fields
- Duplicate IDs
- Empty targets
- Invalid value types, windows, dates, and time zones
- Malformed nested conditions
- List files that can't be read
- Conditions whose value can't match the field's type in the tool's input schema (these make every call to that tool fail)

It also warns about things that are valid but probably not what you meant: a field the target tool doesn't have, a target pattern that matches no installed tool, an expired policy, or `ALLOW` policies that have no effect.

Then it runs the [tests](#tests) written in the file. `swy policy validate` exits with code `1` when there is an error or a test fails, so it can run in CI.

After `swy sync`, `swy get`, and `swy add`, the same schema checks run again, because a provider update can remove or change a field a policy uses. Up to five problems are printed, with a pointer to `swy policy validate`. Nothing is printed when all is well.

---

### Show counts for limits

```bash
swy policy state [<policy-id>] [--json]
swy policy state [<policy-id>] --reset
```

Shows what the [conditions on earlier calls](#conditions-on-earlier-calls) have counted for this project on this machine, and the approvals that can still be [reused](#reuse). `--reset` clears them, for one policy or all of them.

---

### Review past decisions

`swy policy` manages the *rules*. Every time a policy blocks, holds, changes, or hides something, Swytchcode writes an entry to a local, read-only history:

```bash
swy audit policy            # recent entries
swy audit policy -n 20      # last 20 entries
swy audit policy --info <id>  # full detail for one entry (id looks like pol_...)
```

Entries show what happened: `blocked`, `monitored`, `modified`, `redacted`, `truncated`, `response_withheld`, `approval_reused`, `alert_sent`, and `alert_failed`. Approval requests show as `hitl` (waiting), `approved`, `rejected`, `expired`, or `failed`; `swy audit policy <status>` lists one kind, e.g. `swy audit policy hitl`. Request arguments are stored redacted. See the [Command Reference](/reference/commands/#system) for the full `swy audit` surface.

---

## Fail-Closed Behavior

`policies.json` follows a fail-closed model: a policy that can't be checked stops the call instead of letting it through.

| Situation | Behavior |
|-----------|----------|
| File does not exist | No policies are applied. Execution continues normally. |
| File exists and is valid | Policies are evaluated for every call. |
| File exists but is malformed or invalid | Every call stops with a validation error. |
| A [list file](#lists) can't be read | Every call stops. |
| A condition can't be evaluated for this call (for example a non-numeric value for `>`) | The call stops, with the reason. |
| A condition's value doesn't fit the field's type in the tool's schema | Calls to that tool stop. |

This ensures invalid policy definitions never silently bypass security checks.

---

## File Versions

`policies.json` has two versions.

- **Version 1** (no `"version"` key) is the original format. Everything on this page not marked *Version 2* works in it, and existing files keep working exactly as before.
- **Version 2** (`"version": 2` at the top) adds everything marked *Version 2*: wildcard targets, field paths, built-in fields, lists, conditions on earlier calls, `ALLOW`, `MODIFY`, response policies, tests, and more.

A version-1 file that uses a version-2 key is a validation error, so nothing is silently ignored. Nothing converts a file for you. To upgrade, add `"version": 2` and run `swy policy validate`.

---

## File Structure

A minimal version-1 file:

```json
{
  "defaults": {
    "on_violation": "fail",
    "evaluation": "pre_execution"
  },
  "policies": [
    {
      "id": "block-large-payments",
      "target": ["stripe.payment_intent.create"],
      "when": { "field": "amount", "operator": ">", "value": 100000 },
      "action": {
        "type": "POLICY_BLOCKED",
        "message": "Payments above the allowed limit are blocked."
      }
    }
  ]
}
```

A version-2 file:

```json
{
  "version": 2,
  "defaults": {
    "on_no_match": "allow",
    "timezone": "Asia/Kolkata"
  },
  "lists": {
    "blocked_domains": ["gmail.com", "yahoo.com", "outlook.com"]
  },
  "policies": [
    {
      "id": "no-personal-email",
      "description": "Outbound email only to business addresses",
      "target": ["*"],
      "when": {
        "field": "$email_domains",
        "operator": "in",
        "value": { "$list": "blocked_domains" },
        "ignore_case": true
      },
      "action": {
        "type": "POLICY_BLOCKED",
        "message": "Emails to {{$email_domains}} are not allowed",
        "hint": "Use the customer's business email address"
      },
      "tests": [
        { "tool": "sendgrid.mail.send", "args": { "to": "a@Gmail.com" }, "expect": "block" },
        { "tool": "sendgrid.mail.send", "args": { "to": "a@acme.com" }, "expect": "allow" }
      ]
    }
  ]
}
```

The file has up to four top-level keys: `version`, `defaults`, `lists` (version 2), and `policies`.

---

## defaults

The `defaults` object sets file-wide behavior.

| Key | Values | Description |
|-----|--------|-------------|
| `on_violation` | `fail` | What a match does. Only `fail` is supported. |
| `evaluation` | `pre_execution` | When request policies run. Only `pre_execution` is supported. |
| `on_no_match` | `allow` (default), `deny`, `monitor` | What happens to a call no `ALLOW` policy matches. *Version 2.* |
| `timezone` | A time zone name, e.g. `Asia/Kolkata`, `UTC` | The zone for `$time`, `$date`, `$weekday`, and dates in `active_from` / `expires_at`. Default: the machine's local zone. *Version 2.* |

### on_no_match

`on_no_match` turns a file into an allowlist:

- `allow` - calls run unless a policy stops them. `ALLOW` policies have no effect.
- `deny` - a call runs only when an `ALLOW` policy matches it. Every other call is blocked.
- `monitor` - like `deny`, but calls that would be blocked still run and are recorded in `swy audit policy`. Use it to try an allowlist on real traffic before switching to `deny`.

---

## lists

*Version 2.* Named lists of values, used in conditions with `{"$list": "<name>"}`. Write the values inline, or keep them in a file:

```json
{
  "lists": {
    "blocked_domains": ["gmail.com", "yahoo.com"],
    "allowed_hosts": { "file": "lists/allowed_hosts.txt" }
  }
}
```

A list file has one value per line. Blank lines and lines starting with `#` are skipped. Its path is relative to `.swytchcode/integrations/` and must stay inside that folder. Swytchcode only reads list files; it never creates them. A list file that can't be read stops every call, so a guard never silently matches nothing.

---

## Policy Objects

Every entry in the `policies` array is one guard. Four keys are required:

| Key | Description |
|-----|-------------|
| `id` | A unique identifier. Two policies with the same `id` are a validation error. |
| `target` | The methods the policy guards. |
| `when` | The condition that triggers the policy. |
| `action` | What happens when the condition matches. |

*Version 2* adds optional keys:

| Key | Description |
|-----|-------------|
| `description`, `owner`, `tags` | Notes for people. Not used when deciding. |
| `stage` | `request` (default) or `response`. See [Response policies](#response-policies). |
| `mode` | `enforce` (default) or `monitor`. See [mode](#mode). |
| `enabled` | `false` switches the policy off without deleting it. |
| `active_from`, `expires_at` | When the policy applies. See [Schedule](#schedule). |
| `notify` | A call to make when the policy matches, e.g. a Slack message. See [Alerts](#alerts). |
| `reuse`, `approval_timeout` | `REQUIRES_APPROVAL` only. See [Approval options](#approval-options). |
| `tests` | Example calls and what should happen to them. See [Tests](#tests). |

---

### target

`target` is always an array. A policy only runs for calls to one of its targets.

```json
{ "target": ["stripe.payment_intent.create", "stripe.refund.create"] }
```

*Version 2* also accepts patterns: `"*"` matches every tool, and `*` inside a canonical ID matches any text, e.g. `"stripe.*"` or `"github.repo.*"`. In a version-1 file `"stripe.*"` is an exact ID, so it matches nothing; `swy policy validate` warns about it.

---

### when

The condition. If it is `true` for the call, the action is applied. See [Conditions](#conditions).

---

### action

```json
{
  "action": {
    "type": "POLICY_BLOCKED",
    "message": "Payments above the limit are blocked.",
    "hint": "Split the payment or ask the account owner"
  }
}
```

`type` is required. `message` is required for the actions that stop a call.

---

## Action Types

### Actions that stop a call

| Action | What happens |
|--------|--------------|
| `POLICY_BLOCKED` | The call is blocked. |
| `AUTH_FAILED` | The call is blocked as not authorized. |
| `RATE_LIMITED` | The call is blocked as over a rate limit. |
| `QUOTA_EXCEEDED` | The call is blocked as over a quota. |
| `REQUIRES_APPROVAL` | The call is held until a person approves or denies it. See [Human approval](/policies/human-approval/). |

When a policy blocks a call:

- the request is never sent
- Swytchcode exits with code **6**
- an error is written as JSON, naming the policy:

```json
{
  "error": "blocked by policy \"block-large-payments\": Payments above the limit are blocked.",
  "category": "policy_denied",
  "suggested_action": "this call was blocked by a configured guard and was not executed - do not retry it or try to work around the guard; report to the user which guard blocked it. ...",
  "hint": "Split the payment or ask the account owner",
  "source": "policy",
  "docs_url": "https://docs.swytchcode.com/policies/policy-rules/"
}
```

`"source": "policy"` tells an agent that a guard stopped the call, not the provider. A policy block is never `retryable`. A `RATE_LIMITED` or `QUOTA_EXCEEDED` block from a [limit on earlier calls](#conditions-on-earlier-calls) adds `retry_after` (seconds until the window frees up); it is `retryable` only when that wait is 30 seconds or less.

A `REQUIRES_APPROVAL` match exits with code **7**: the call is neither run nor rejected yet.

When several policies match, a block wins over an approval, and an approval over an `ALLOW`.

### Actions that let a call through

*Version 2.*

| Action | What happens |
|--------|--------------|
| `ALLOW` | Marks the call as allowed. Only matters with [`on_no_match`](#on_no_match) `deny` or `monitor`. A block still wins. |
| `MODIFY` | Changes the call's arguments, then lets it continue. See [MODIFY](#modify). |
| `REDACT` | Response policies only. Hides values in the response. See [Response policies](#response-policies). |
| `TRUNCATE` | Response policies only. Cuts lists in the response. |

### message placeholders

*Version 2.* `message`, `hint`, and `notify.args` can include values from the call with `{{field}}`, using the same names as [conditions](#conditions): `{{amount}}`, `{{customer.email}}`, `{{$email_domains}}`. A field whose name looks secret (token, password, key, ...) shows as `[REDACTED]`. Policies with a limit can also use `{{$count}}`, `{{$sum}}`, `{{$limit}}`, and `{{$retry_after}}`.

### hint

*Version 2.* `hint` tells the agent what to do instead, for example "use the customer's business email address". It is returned in the error's `hint` field.

---

## Conditions

Conditions determine when a policy applies. There are two types:

- **Leaf conditions** compare one field: `{ "field": "amount", "operator": ">", "value": 100000 }`
- **Group conditions** combine others with `all`, `any`, or `not`.

### Where fields are found

A field name is looked up in the call's arguments, in this order: the top level, then `params`, then `body`. Case is ignored. If the same name appears in two places with different values, the condition can't be evaluated and the call stops, so an agent can't hide a value in another place.

When a field holds a **list** (for example several recipients), operators check each item: `==`, `in`, `starts_with`, `ends_with`, and `matches` match when any item matches. `not_in` on an empty list doesn't match. `contains` on a list matches whole items.

A missing field matches `not_exists`, `empty`, and `not_in`, and never matches the other comparisons. `>`, `>=`, `<`, and `<=` on a missing field stop the call with `field "amount" not present in request`.

---

## Group Conditions

| Operator | Description |
|----------|-------------|
| `all` | Every condition must match (AND). |
| `any` | At least one condition must match (OR). |
| `not` | Inverts a condition (NOT). |

```json
{
  "operator": "all",
  "conditions": [
    { "field": "currency", "operator": "==", "value": "usd" },
    { "field": "amount", "operator": ">", "value": 5000 }
  ]
}
```

The policy matches only if **both** conditions are `true`. Groups can nest:

```json
{
  "when": {
    "operator": "all",
    "conditions": [
      { "field": "amount", "operator": ">", "value": 100000 },
      { "field": "currency", "operator": "in", "value": ["usd", "eur"] },
      {
        "operator": "not",
        "conditions": [{ "field": "test_mode", "operator": "exists" }]
      }
    ]
  }
}
```

This matches when the amount is over `100000`, the currency is `usd` or `eur`, and `test_mode` is **not** present.

---

## Comparison Operators

| Operators | Value | Notes |
|-----------|-------|-------|
| `==`, `!=` | A scalar | |
| `>`, `>=`, `<`, `<=` | A number | |
| `in`, `not_in` | An array (or a [list](#lists)) | |
| `contains`, `not_contains` | A string | Substring. On a list, a whole item. |
| `starts_with`, `ends_with` | A string | |
| `matches` | A regular expression, up to 512 characters | |
| `exists`, `not_exists`, `empty`, `not_empty` | None | |
| `before`, `after` | A timestamp (RFC3339) | |
| `between`, `outside` | Exactly two timestamps | |

```json
{ "field": "branch", "operator": "starts_with", "value": "release/" }
```

```json
{
  "field": "created_at",
  "operator": "between",
  "value": ["2026-01-01T00:00:00Z", "2026-12-31T23:59:59Z"]
}
```

*Version 2* adds operators for [earlier calls](#conditions-on-earlier-calls) (`rate`, `sum`, `repeat`, `called_before`, `returned_by`) and [detectors](#detectors) (`contains_pii`, `contains_secret`).

---

## Field Paths

*Version 2.* A field can be a path into nested arguments:

| Path | Means |
|------|-------|
| `customer.email` | `email` inside `customer` |
| `items[*].price` | `price` of every item in the `items` list |
| `items[0].price` | `price` of the first item |
| `recipients.email` | A key applied to a list applies to each item, like `recipients[*].email` |

Paths follow the same lookup order (top level, `params`, `body`) and conflict check as plain names.

---

## Built-in Fields

*Version 2.* Fields starting with `$` describe the call itself. Swytchcode fills them in; they are never read from the request arguments, so an agent can't set them.

| Field | Value |
|-------|-------|
| `$tool` | The canonical ID being called |
| `$provider` | The provider (first part of the canonical ID) |
| `$http_method` | `GET`, `POST`, ... |
| `$host`, `$path`, `$url` | Where the request goes (`$url` has no query string) |
| `$env` | `sandbox` or `production` |
| `$workspace` | The linked workspace name |
| `$caller` | `cli` or `mcp` |
| `$mcp_profile` | `agent` or `full` (MCP calls only) |
| `$mcp_client` | The name the MCP client reports when it connects. Self-reported, so don't rely on it for security. |
| `$tenant` | The end user of your app the call is for (`--tenant` / `tenantId`); empty for your own calls. Use `per: ["$tenant"]` to give each end user their own limit. See [Multi-tenant apps](/guides/multi-tenant/#policies). |
| `$time`, `$date`, `$weekday` | `HH:MM`, `YYYY-MM-DD`, and `monday` ... `sunday`, in [`defaults.timezone`](#defaults) |
| `$emails` | Every email address anywhere in the request |
| `$email_domains` | The lowercase domain of each address in `$emails` |
| `$urls`, `$hosts` | Every http(s) URL anywhere in the request, and their lowercase hosts |
| `$strings` | Every string value in the request |
| `$body_bytes` | Size of the request body in bytes |
| `$item_count` | Length of the largest list in the request |

```json
{ "field": "$http_method", "operator": "==", "value": "DELETE" }
```

---

## Condition Options

*Version 2.*

### Value references

Instead of writing a value out, a condition can point to one:

| Value | Compares against |
|-------|------------------|
| `{"$list": "blocked_domains"}` | A named [list](#lists) |
| `{"$field": "account.owner_id"}` | Another field of the same call |
| `{"$env_var": "ALLOWED_CUSTOMER"}` | An environment variable of the process running Swytchcode |

```json
{ "field": "customer_id", "operator": "!=", "value": { "$env_var": "ALLOWED_CUSTOMER" } }
```

### ignore_case

`"ignore_case": true` compares text without regard to case: `Gmail.com` matches `gmail.com`.

### transform

Changes the field's values before comparing. One name, or a list applied in order:

| Transform | Result |
|-----------|--------|
| `lower`, `upper`, `trim` | Text changed accordingly |
| `length` | Length of each value |
| `count` | Number of values (e.g. recipients) |
| `domain` | Domain of an email address or host of a URL |
| `abs` | Absolute value of a number |

```json
{ "field": "to", "transform": "count", "operator": ">", "value": 10 }
```

### quantifier

When a field has several values, `"quantifier": "any"` (the default) matches if any value matches, and `"all"` only if every value matches.

---

## Conditions on Earlier Calls

*Version 2.* These operators look at calls made earlier from this project on this machine.

| Operator | Matches when | Keys |
|----------|--------------|------|
| `rate` | `max` or more calls were made in the `window` | `window`, `max`, `per` |
| `sum` | The total of `field` over the `window`, including this call, would be over `max` | `field`, `window`, `max`, `per` |
| `repeat` | The same call (same tool and arguments) was made `max` or more times in the `window` | `window`, `max`, `per` |
| `called_before` | One of the tools in `value` was called successfully in the `window` | `value`, `window` |
| `returned_by` | The request's `field` has a value that one of `value.tools` returned in its `value.field` | `field`, `value`, `window` (default 30 days) |

- `window` is a number with `s`, `m`, `h`, or `d`: `"30s"`, `"10m"`, `"24h"`, `"7d"`.
- `per` counts separately for each value of the listed fields, e.g. `"per": ["customer"]` for a limit per customer. For `repeat`, `per` narrows "the same call" to those fields.
- `rate`, `sum`, and `repeat` count every sent call to the policy's targets, whatever the rest of the condition says. A call that got no response still counts. `called_before` and `returned_by` remember only calls that got a `2xx` response.
- Dry runs and policy checks read the counts but never add to them.

```json
{
  "id": "daily-spend-cap",
  "target": ["stripe.payment_intent.create", "stripe.charge.create"],
  "when": { "operator": "sum", "field": "amount", "window": "24h", "max": 50000, "per": ["currency"] },
  "action": { "type": "QUOTA_EXCEEDED", "message": "Daily spend of {{$sum}} {{currency}} would exceed {{$limit}}" }
}
```

```json
{
  "id": "delete-only-own-sandboxes",
  "target": ["e2b.sandbox.delete"],
  "when": {
    "operator": "not",
    "conditions": [{
      "field": "sandbox_id",
      "operator": "returned_by",
      "value": { "tools": ["e2b.sandbox.create"], "field": "sandbox_id" },
      "window": "7d"
    }]
  },
  "action": { "type": "POLICY_BLOCKED", "message": "Only sandboxes the agent created can be deleted" }
}
```

A response-count limit uses `sum` with `"field": "$response_items"`: each call adds the number of items its response returned.

### Where the counts are kept

Counts are kept **per project, per machine**, in two tables of `~/.swytchcode/usage.db` (the local usage database Swytchcode already uses). They store hashes of the counted values, times, policy IDs, and summed amounts - never request bodies. A project is the folder that holds `.swytchcode/`, so a second clone, or the same project on another machine, has its own counts.

This means a limit applies to one machine. Five deployed agents on five machines each get their own daily cap. Editing a condition starts its counts over. Use [`swy policy state`](#show-counts-for-limits) to see or reset them.

---

## Detectors

*Version 2.* Two operators look for sensitive data in text:

| Operator | Finds |
|----------|-------|
| `contains_pii` | Email addresses, phone numbers (10-15 digits), card numbers (checked with the Luhn digit), US social security numbers, and IBANs (checked with their checksum). A `value` such as `["card", "ssn"]` narrows the kinds. |
| `contains_secret` | Stripe live keys (`sk_live_`, `rk_live_`), GitHub and Slack tokens, AWS access keys (`AKIA...`), PEM private keys, and JWTs. |

Use them on `$strings` for the whole request, on one field, or on a `$response` field.

```json
{ "field": "$strings", "operator": "contains_secret" }
```

Detectors are pattern-based. They catch common formats and will miss others; treat them as a safety net, not a guarantee.

---

## MODIFY

*Version 2.* `MODIFY` changes the call instead of blocking it:

```json
{
  "id": "cap-page-size",
  "target": ["stripe.customer.list"],
  "when": { "field": "limit", "operator": ">", "value": 100 },
  "action": { "type": "MODIFY", "set": { "limit": 100 }, "message": "limit capped at 100" }
}
```

| Key | Does |
|-----|------|
| `set` | Sets fields to values. A field is changed wherever it already is; a missing one is added to `body` when the call has one, otherwise to the top level. |
| `remove` | Deletes fields. |
| `add` | Appends a value (or each value of a list) to a list field, skipping values already there. A missing field is set. |
| `dry_run` | `true` turns the call into a dry run. |

`MODIFY` policies run first. The changed call is checked against the tool's input schema again, and every other policy - including approval - sees the changed call. The output's `policy_notes` tell the agent what changed; the audit log keeps the original and the changed arguments.

`MODIFY` can't change auth, header, host, or idempotency fields, or fields named like tokens, secrets, passwords, or API keys.

---

## Response Policies

*Version 2.* A policy with `"stage": "response"` runs after the call, on its final response, before anyone sees it: the CLI output, the MCP result, and the audit log's copy.

Fields:

| Field | Value |
|-------|-------|
| `$response` | The whole response body |
| `$response.<path>` | A path into the body, e.g. `$response.data[*].email` |
| `$status_code` | The HTTP status |
| `$response_bytes` | Size of the body in bytes |
| `$response_items` | Length of the largest list in the body |

Request fields and built-in fields work too. Conditions on earlier calls don't.

Actions:

| Action | Does |
|--------|------|
| `REDACT` | Replaces the values at `fields` (`$response` paths) and everything `detect` finds (`email`, `phone`, `card`, `ssn`, `iban`, `pii` for all of these, `secret`) with `[REDACTED]`. |
| `TRUNCATE` | Keeps at most `max_items` items of the lists at `fields`, or - without `fields` - of the body when it is a list and of each list directly inside it (such as `data` or `items`). |
| `POLICY_BLOCKED` | Withholds the whole response. Exit code **6**. The error says the call **was made** and must not be repeated. |

```json
{
  "id": "hide-card-numbers",
  "target": ["stripe.*"],
  "stage": "response",
  "when": { "field": "$status_code", "operator": "<", "value": 300 },
  "action": { "type": "REDACT", "detect": ["card", "secret"] }
}
```

A response policy can't undo the call - the provider has already acted. It only controls what is shown.

The output gets `policy_notes` saying what was hidden or cut. Some limits apply:

- A response cut off at the provider's [`max_response_bytes`](/configuration/manifest-json/#max_response_bytes) is withheld when a response policy needs its structure (a `$response` path, `fields`, or `TRUNCATE`). A `detect`-only `REDACT` still runs on the text.
- `swy exec --output <file>` is refused for a tool with response policies, before the call is made, because a downloaded file can't be checked.

---

## mode

*Version 2.* `"mode": "monitor"` records matches in `swy audit policy` but never acts on them. Use it to try a rule on real traffic before enforcing it. A monitor-mode policy that can't be evaluated never stops a call.

---

## Schedule

*Version 2.* `active_from` and `expires_at` limit when a policy applies. Each is a date (`YYYY-MM-DD`) or an RFC3339 time. A date is read in [`defaults.timezone`](#defaults); `expires_at` with a date still applies on that day.

```json
{ "id": "change-freeze", "active_from": "2026-12-20", "expires_at": "2026-12-31", "...": "..." }
```

`swy policy validate` warns about expired policies so they can be removed. `"enabled": false` switches a policy off at any time.

---

## Alerts

*Version 2.* `notify` makes a call through Swytchcode when the policy matches - usually a message to Slack or email. It uses a tool installed in the project and its connected account:

```json
{
  "notify": {
    "tool": "slack.chat.postMessage",
    "args": { "channel": "#payments", "text": "Blocked a payment of {{amount}} to {{customer}}" }
  }
}
```

- An alert is sent for blocks, monitor-mode matches, `MODIFY`, `REDACT`, and `TRUNCATE`. For `REQUIRES_APPROVAL`, it is sent once, when the approval request is created.
- A failed alert never changes the outcome of the call. It is recorded as `alert_failed`.
- Dry runs, `--demo` calls, policy checks, and approved calls being replayed send no alerts.

---

## Approval Options

*Version 2.* `REQUIRES_APPROVAL` policies take two more keys.

### reuse

One approval covers later matching calls for a while:

```json
{
  "id": "big-refunds",
  "target": ["stripe.refund.create"],
  "when": { "field": "amount", "operator": ">", "value": 50000 },
  "action": { "type": "REQUIRES_APPROVAL", "message": "Refunds over 500.00 need approval" },
  "reuse": { "for": "24h", "same": ["charge"] }
}
```

After a call is approved and run, later calls to the same tool with the same values in the `same` fields go through without a new request until `for` runs out. Without `same`, any call to the tool is covered.

### approval_timeout

`"approval_timeout": "30m"` sets how long an approval request waits. After that, a late approval never runs the call. Requests always expire after 48 hours.

---

## Tests

*Version 2.* A policy can carry example calls and what should happen to them. `swy policy validate` runs them, and so do the MCP policy check and add tools.

```json
{
  "id": "hourly-email-limit",
  "target": ["sendgrid.mail.send"],
  "when": { "operator": "rate", "window": "1h", "max": 100 },
  "action": { "type": "RATE_LIMITED", "message": "At most 100 emails an hour" },
  "tests": [
    { "name": "under the limit", "args": { "to": "a@acme.com" }, "expect": "allow" },
    {
      "name": "over the limit",
      "args": { "to": "a@acme.com" },
      "before": [{ "tool": "sendgrid.mail.send", "times": 100, "ago": "10m" }],
      "expect": "block"
    }
  ]
}
```

| Key | Description |
|-----|-------------|
| `name` | Optional; shown when the test fails. |
| `tool` | The tool called. Default: the policy's first target. Required when that is a pattern. |
| `args` | The call's arguments. `"$x"` keys set built-in fields (e.g. `"$env": "production"`), and `"$response"` / `"$status_code"` give a response for response policies. |
| `before` | Earlier calls: `tool`, `args`, `response`, `times` (default 1), and `ago` (default 1s). Each counts as sent with a `2xx` response. |
| `expect` | `allow`, `block`, `approval`, `modify`, or - with `"$response"` in `args` - `redact` or `truncate`. |
| `modified_args` | With `modify`: the arguments the call must be sent with. |
| `response` | With `redact` or `truncate`: the response the agent must see. |

A test runs against **the whole file**, the way a real call is decided: `MODIFY` first, then the other policies on the changed call, then response policies, including `on_no_match`. So a test can fail because another policy blocks the call; the failure names it:

```text
× validation errors:
  • policy "no-personal-email" test 2: expected allow, got block by on_no_match
error: 1 of 4 policy test(s) failed
```

Tests start from no earlier calls and never send, count, alert, or log anything.

---

## policies.json vs execution_policy

Two files decide how a call runs. They don't overlap:

| | `policies.json` | `execution_policy` in [manifest.json](/configuration/manifest-json/#execution-policy) |
|---|---|---|
| Question it answers | *Should* this call run, and in what form? | *How* is it sent? |
| Scope | Any call, matched by target and condition | One integration |
| Examples | Block, hold for approval, limit per day, change arguments, hide response data | Retries, timeouts, concurrency, idempotency, response size |
| Who writes it | You | Comes with the integration; you can override fields |
| When it runs | Before the call (request policies) and after the final response (response policies) | While the call is sent, including retries |

A few interactions to know:

- A retried call counts once for `rate`, `sum`, and `repeat`.
- A policy's `RATE_LIMITED` error has `"source": "policy"`; a provider's 429 doesn't.
- `max_response_bytes` can cut a response a response policy needs; see [Response Policies](#response-policies).

---

## Best Practices

- Keep policies focused on a single responsibility, with descriptive IDs.
- Add `tests` to each policy and run `swy policy validate` in CI.
- Try new rules with `"mode": "monitor"` before enforcing them.
- Store policies in version control alongside your project, and review changes like code.
- Test policies in sandbox mode before deploying to production.

---

Now that you understand guard policies, continue with:

## Next Steps

- [Policy recipes](https://docs.swytchcode.com/policies/recipes/) - Ready-to-use policies for email, payments, data safety, and more.
- [manifest.json](https://docs.swytchcode.com/configuration/manifest-json/) - Configure endpoints, retries, authentication, and execution behavior.
- [Production Guardrails](https://docs.swytchcode.com/policies/production-guardrails/) - Build secure execution policies for production environments.
