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:
{ "version": 2, "policies": [ ... ]}Every field and operator used here is described in the policies.json reference.
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.
{ "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": [ ] } ]}Only send to internal recipients
not_in on a list of recipients matches when any address is outside the list.
{ "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
{ "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
{ "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.
Limit emails per day
{ "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
{ "id": "compliance-bcc", "target": ["sendgrid.mail.send"], "when": { "field": "$tool", "operator": "exists" }, "action": { "type": "MODIFY", "add": { "bcc": "[email protected]" }, "message": "compliance copy added" }}Payments
Maximum amount and allowed currencies
These two work in version-1 files too.
[ { "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.
{ "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.
{ "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.
Refund only charges the agent created
{ "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
{ "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.
{ "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
{ "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.
{ "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
{ "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.
{ "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.
{ "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
[ { "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
{ "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
{ "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.
{ "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:
# one host per linehooks.acme.comapi.acme.comTime
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.
{ "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.
{ "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.
{ "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
{ "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
{ "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.
[ { "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
{ "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.
{ "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). - Tests in CI: add
teststo each policy and runswy policy validatein CI. See Tests. - Provider updates:
swy sync,swy get, andswy addwarn when a policy uses a field a tool no longer has.