# Policy recipes

Each recipe is one policy (or a small set) to copy into `.swytchcode/integrations/policies.json`. Replace the targets with your own canonical IDs - `swy list tooling` shows them - and run `swy policy validate` after every change.

Most recipes use features of **version 2**, so the file starts with `"version": 2`:

```json
{
  "version": 2,
  "policies": [
    ...
  ]
}
```

Every field and operator used here is described in the [policies.json reference](/configuration/policy-json/).

---

## Email and messaging

### Block personal email domains on every provider

`$email_domains` holds the domain of every email address anywhere in the request, so one policy covers every email and messaging tool.

```json
{
  "version": 2,
  "lists": { "personal_domains": ["gmail.com", "yahoo.com", "outlook.com", "hotmail.com"] },
  "policies": [
    {
      "id": "no-personal-email",
      "target": ["*"],
      "when": { "field": "$email_domains", "operator": "in", "value": { "$list": "personal_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" }
      ]
    }
  ]
}
```

### Only send to internal recipients

`not_in` on a list of recipients matches when any address is outside the list.

```json
{
  "id": "internal-only",
  "target": ["sendgrid.mail.send", "slack.chat.postMessage"],
  "when": { "field": "$email_domains", "operator": "not_in", "value": ["acme.com", "acme.io"] },
  "action": { "type": "POLICY_BLOCKED", "message": "Messages may only go to acme.com addresses" }
}
```

### Limit recipients per message

```json
{
  "id": "max-recipients",
  "target": ["sendgrid.mail.send"],
  "when": { "field": "$emails", "transform": "count", "operator": ">", "value": 10 },
  "action": { "type": "POLICY_BLOCKED", "message": "At most 10 recipients per email" }
}
```

### No personal data or secrets in outgoing messages

```json
{
  "id": "no-sensitive-data-out",
  "target": ["slack.*", "sendgrid.*"],
  "when": {
    "operator": "any",
    "conditions": [
      { "field": "$strings", "operator": "contains_pii", "value": ["card", "ssn", "iban"] },
      { "field": "$strings", "operator": "contains_secret" }
    ]
  },
  "action": { "type": "POLICY_BLOCKED", "message": "The message contains card numbers, ID numbers, or secrets" }
}
```

Detectors catch common formats, not all of them. See [Detectors](/configuration/policy-json/#detectors).

### Limit emails per day

```json
{
  "id": "daily-email-limit",
  "target": ["sendgrid.mail.send"],
  "when": { "operator": "rate", "window": "24h", "max": 500 },
  "action": { "type": "RATE_LIMITED", "message": "Daily limit of {{$limit}} emails reached; try again in {{$retry_after}}s" }
}
```

### Always add a compliance BCC

```json
{
  "id": "compliance-bcc",
  "target": ["sendgrid.mail.send"],
  "when": { "field": "$tool", "operator": "exists" },
  "action": { "type": "MODIFY", "add": { "bcc": "compliance@acme.com" }, "message": "compliance copy added" }
}
```

---

## Payments

### Maximum amount and allowed currencies

These two work in version-1 files too.

```json
[
  {
    "id": "max-payment",
    "target": ["stripe.payment_intent.create"],
    "when": { "field": "amount", "operator": ">", "value": 100000 },
    "action": { "type": "POLICY_BLOCKED", "message": "Payments above 1000.00 are blocked" }
  },
  {
    "id": "allowed-currencies",
    "target": ["stripe.payment_intent.create"],
    "when": { "field": "currency", "operator": "not_in", "value": ["usd", "eur"] },
    "action": { "type": "POLICY_BLOCKED", "message": "Only USD and EUR payments are allowed" }
  }
]
```

### Daily spend cap

`sum` adds up `amount` over the window, counted separately per currency.

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

### Approval above an amount, reused for a day

After one refund to a customer is approved, more refunds for that customer go through for 24 hours.

```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": ["customer"] },
  "approval_timeout": "2h"
}
```

See [Human approval](/policies/human-approval/).

### Refund only charges the agent created

```json
{
  "id": "refund-own-charges",
  "target": ["stripe.refund.create"],
  "when": {
    "operator": "not",
    "conditions": [{
      "field": "charge",
      "operator": "returned_by",
      "value": { "tools": ["stripe.charge.create"], "field": "id" },
      "window": "30d"
    }]
  },
  "action": { "type": "POLICY_BLOCKED", "message": "Only charges created by this agent can be refunded" }
}
```

### No duplicate payment within 10 minutes

```json
{
  "id": "no-duplicate-payment",
  "target": ["stripe.payment_intent.create"],
  "when": { "operator": "repeat", "window": "10m", "max": 1, "per": ["customer", "amount"] },
  "action": { "type": "POLICY_BLOCKED", "message": "Same payment to the same customer within 10 minutes" }
}
```

---

## Data safety

### Read-only agent

`on_no_match: "deny"` blocks every call no `ALLOW` policy matches. Switch it to `"monitor"` first to see what would be blocked.

```json
{
  "version": 2,
  "defaults": { "on_no_match": "deny" },
  "policies": [
    {
      "id": "reads-only",
      "target": ["*"],
      "when": { "field": "$http_method", "operator": "==", "value": "GET" },
      "action": { "type": "ALLOW" }
    }
  ]
}
```

### No deletes in production

```json
{
  "id": "no-prod-deletes",
  "target": ["*"],
  "when": {
    "operator": "all",
    "conditions": [
      { "field": "$http_method", "operator": "==", "value": "DELETE" },
      { "field": "$env", "operator": "==", "value": "production" }
    ]
  },
  "action": { "type": "POLICY_BLOCKED", "message": "Deletes are disabled in production" }
}
```

### No bulk operations

`$item_count` is the length of the largest list in the request.

```json
{
  "id": "no-bulk",
  "target": ["hubspot.*"],
  "when": { "field": "$item_count", "operator": ">", "value": 50 },
  "action": { "type": "POLICY_BLOCKED", "message": "At most 50 records per call" }
}
```

### Cap page size instead of failing

```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" },
  "tests": [
    { "args": { "limit": 500 }, "expect": "modify", "modified_args": { "limit": 100 } }
  ]
}
```

### Limit records read per day

Each call adds the number of items its response returned.

```json
{
  "id": "daily-read-limit",
  "target": ["hubspot.contact.list"],
  "when": { "operator": "sum", "field": "$response_items", "window": "24h", "max": 5000 },
  "action": { "type": "QUOTA_EXCEEDED", "message": "At most 5000 contacts can be read per day" }
}
```

### Hide card numbers, ID numbers, and tokens from the agent

The response is changed before the agent - and the audit log - sees it.

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

To hide specific fields instead, use `"fields": ["$response.data[*].email"]`.

---

## Code and infrastructure

### No pushes or merges to main, and only one organization

```json
[
  {
    "id": "protect-main",
    "target": ["github.pull.merge.update", "github.git.refs.update"],
    "when": { "field": "ref", "operator": "matches", "value": "^(refs/heads/)?main$" },
    "action": { "type": "POLICY_BLOCKED", "message": "Changes to main go through a reviewed pull request" }
  },
  {
    "id": "only-acme-org",
    "target": ["github.*"],
    "when": { "field": "owner", "operator": "not_in", "value": ["acme"], "ignore_case": true },
    "action": { "type": "POLICY_BLOCKED", "message": "Only repositories in the acme organization" }
  }
]
```

### Deploy only after the tests passed

```json
{
  "id": "deploy-after-tests",
  "target": ["vercel.deployment.create"],
  "when": {
    "operator": "not",
    "conditions": [{ "operator": "called_before", "value": "github.action.dispatches.create", "window": "1h" }]
  },
  "action": { "type": "POLICY_BLOCKED", "message": "Run the test workflow before deploying" }
}
```

`called_before` only remembers calls that got a `2xx` response.

### Only delete sandboxes the agent created

```json
{
  "id": "delete-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 this agent created can be deleted" }
}
```

---

## Network

### Webhook and callback URLs only to allowed hosts

`$hosts` holds the host of every URL anywhere in the request. The list lives in a file so it can be reviewed separately.

```json
{
  "version": 2,
  "lists": { "allowed_hosts": { "file": "lists/allowed_hosts.txt" } },
  "policies": [
    {
      "id": "allowed-callback-hosts",
      "target": ["stripe.webhook_endpoint.create", "github.hook.create"],
      "when": { "field": "$hosts", "operator": "not_in", "value": { "$list": "allowed_hosts" } },
      "action": { "type": "POLICY_BLOCKED", "message": "{{$hosts}} is not an allowed host" }
    }
  ]
}
```

`.swytchcode/integrations/lists/allowed_hosts.txt`:

```text
# one host per line
hooks.acme.com
api.acme.com
```

---

## Time

Set `defaults.timezone` so times are read in your zone.

### Business hours and weekdays

`$time` is `HH:MM`, so it is compared as text with `matches`.

```json
{
  "id": "business-hours",
  "target": ["stripe.payout.create"],
  "when": {
    "operator": "any",
    "conditions": [
      { "field": "$weekday", "operator": "in", "value": ["saturday", "sunday"] },
      { "field": "$time", "operator": "matches", "value": "^(0[0-8]|1[89]|2[0-3]):" }
    ]
  },
  "action": { "type": "POLICY_BLOCKED", "message": "Payouts run Monday to Friday, 09:00-18:00" }
}
```

### Change freeze

The policy applies only between the two dates, then stops. `swy policy validate` warns once it has expired.

```json
{
  "id": "year-end-freeze",
  "target": ["github.*", "vercel.*"],
  "active_from": "2026-12-20",
  "expires_at": "2027-01-02",
  "when": { "field": "$http_method", "operator": "!=", "value": "GET" },
  "action": { "type": "POLICY_BLOCKED", "message": "Change freeze until January 2" }
}
```

---

## Customer separation

An agent working for one customer touches only that customer's records. The customer ID comes from an environment variable set where the agent runs; if it isn't set, calls stop.

```json
{
  "id": "one-customer-only",
  "target": ["stripe.*"],
  "when": { "field": "customer", "operator": "!=", "value": { "$env_var": "AGENT_CUSTOMER_ID" } },
  "action": { "type": "POLICY_BLOCKED", "message": "This agent may only act for its own customer" }
}
```

---

## Environment and caller

### Stricter over MCP: force a dry run for agents

```json
{
  "id": "agents-dry-run-payments",
  "target": ["stripe.payment_intent.create"],
  "when": { "field": "$caller", "operator": "==", "value": "mcp" },
  "action": { "type": "MODIFY", "dry_run": true, "message": "agents can only preview payments" }
}
```

---

## Tool access

### The agent may only call these tools

```json
{
  "version": 2,
  "defaults": { "on_no_match": "deny" },
  "policies": [
    {
      "id": "allowed-tools",
      "target": ["hubspot.contact.get", "hubspot.contact.list", "sendgrid.mail.send"],
      "when": { "field": "$tool", "operator": "exists" },
      "action": { "type": "ALLOW" }
    }
  ]
}
```

---

## Volume and loops

### Rate limit per tool, and stop an agent repeating itself

A rate limit counts calls from every process on this machine, so it also holds across several agents running side by side.

```json
[
  {
    "id": "crm-rate-limit",
    "target": ["hubspot.*"],
    "when": { "operator": "rate", "window": "1m", "max": 60, "per": ["$tool"] },
    "action": { "type": "RATE_LIMITED", "message": "At most 60 calls a minute per tool" }
  },
  {
    "id": "stop-loops",
    "target": ["*"],
    "when": { "operator": "repeat", "window": "5m", "max": 3 },
    "action": { "type": "POLICY_BLOCKED", "message": "The same call was made 3 times in 5 minutes", "hint": "Stop and tell the user what went wrong" }
  }
]
```

---

## Compliance

### PCI, GDPR deletes, and EU hosts

```json
{
  "version": 2,
  "lists": { "eu_hosts": ["api.eu.example.com", "eu.api.example.com"] },
  "policies": [
  {
    "id": "pci-no-card-numbers",
    "target": ["*"],
    "when": { "field": "$strings", "operator": "contains_pii", "value": ["card"] },
    "action": { "type": "POLICY_BLOCKED", "message": "Raw card numbers may not be sent" }
  },
  {
    "id": "gdpr-deletes-need-approval",
    "target": ["hubspot.contact.delete", "intercom.contact.delete"],
    "when": { "field": "$http_method", "operator": "==", "value": "DELETE" },
    "action": { "type": "REQUIRES_APPROVAL", "message": "Deleting personal data needs approval" }
  },
  {
    "id": "eu-hosts-only",
    "target": ["*"],
    "when": { "field": "$host", "operator": "not_in", "value": { "$list": "eu_hosts" } },
    "action": { "type": "POLICY_BLOCKED", "message": "{{$host}} is outside the EU" }
  }
  ]
}
```

---

## Alerts

### Allow, but tell a channel

A monitor-mode policy never blocks; with `notify`, each match also posts a message.

```json
{
  "id": "watch-large-refunds",
  "target": ["stripe.refund.create"],
  "mode": "monitor",
  "when": { "field": "amount", "operator": ">", "value": 20000 },
  "action": { "type": "POLICY_BLOCKED", "message": "Large refund" },
  "notify": {
    "tool": "slack.chat.postMessage",
    "args": { "channel": "#payments", "text": "Refund of {{amount}} on charge {{charge}}" }
  }
}
```

Monitor mode is also the safe way to try any new rule on real traffic: matches show as `monitored` in `swy audit policy`.

---

## Upkeep

- **Temporary rules**: set `expires_at` (see [Change freeze](#change-freeze)).
- **Tests in CI**: add `tests` to each policy and run `swy policy validate` in CI. See [Tests](/configuration/policy-json/#tests).
- **Provider updates**: `swy sync`, `swy get`, and `swy add` warn when a policy uses a field a tool no longer has.

---

## Next steps

- [policies.json reference](https://docs.swytchcode.com/configuration/policy-json/) - Every field, operator, and action.
- [Policy Rules](https://docs.swytchcode.com/policies/policy-rules/) - How policies are evaluated and how to design them.
- [Production Guardrails](https://docs.swytchcode.com/policies/production-guardrails/) - Recommended safeguards for running agents against production systems.
