Writing policies
A policy is a list of rules plus a default. Each tool call is checked against the rules from top to bottom; the first rule that matches decides what happens.
Anatomy of a rule
{ "match": "send_*", "action": "require_approval", "if_args_match": "(?i)refund", "reason": "Refund emails need a person" }
| Field | Meaning |
|---|---|
match | Glob against the tool name. Required. |
action | allow, require_approval or deny. Required. |
if_args_match | Optional regular expression tested against the JSON-encoded arguments. The rule only applies if it matches. |
reason | Shown to approvers, returned to the agent on denial, and written to the log. |
The three actions
allow forwards the call to the upstream immediately and logs call.allowed, then call.completed or call.failed.
require_approval holds the call and logs call.pending. It appears in the inbox and, if configured, in Slack. The agent's request stays open until a person approves (call.approved, then the call runs), rejects (call.rejected), or the timeout passes (call.timeout).
deny never reaches the upstream. The agent gets a tool result marked as an error that says the call was blocked and why, so it can tell its user instead of retrying blindly. Logged as call.denied.
Matching tool names
*matches every tool.send_*matchessend_email,send_sms, and so on.delete_usermatches exactly that tool.stripe/*matches every tool from the upstream namedstripe.db/querymatches onlyqueryondb.
If two upstreams expose a tool with the same name, SpotAgentIQ exposes both as upstream__tool (for example crm__search and docs__search) so the agent is never ambiguous about which one it's calling. Scope your rules with upstream/tool to stay independent of that.
Matching on arguments
if_args_match is a Go regular expression (RE2 syntax) run against the arguments as the raw JSON the client sent. Use it to treat the same tool differently depending on what it's being asked to do.
Because rules are first-match, put the narrow rule above the broad one:
"rules": [
{ "match": "db/query", "action": "deny", "if_args_match": "(?i)\\b(insert|update|delete|drop|alter|truncate)\\b", "reason": "Read-only database access" },
{ "match": "db/query", "action": "allow" }
]
Remember that the pattern sees JSON: keys are included, quotes inside values are escaped, and clients differ on whitespace, so allow for it with \s*."amount":\s*[0-9]{4,} matches amounts of 1000 or more.
Patterns are a tripwire, not a parser. A determined prompt injection can rephrase an SQL statement. For anything that really matters, require approval for the whole tool and use patterns to deny the obvious cases early.
Choosing a default
default_action applies when no rule matches. It defaults to allow.
allowis the easiest to adopt: list the risky verbs (send_*,delete_*,pay_*,refund_*) and everything else keeps working.require_approvalis the safest: every unknown tool is held, so a newly added upstream can't do anything until you've written rules for it. Addallowrules for reads as you learn what the agent does.denymakes the policy an explicit allowlist.
Redaction
redact lists argument keys whose values are replaced with [redacted] in the audit log, the inbox and Slack. Matching is case-insensitive and applies at any depth in nested objects. The upstream still receives the real value.
"redact": ["password", "token", "api_key", "ssn", "card_number", "authorization"]
Recipes
Reads are free, writes need a person
"default_action": "require_approval",
"rules": [
{ "match": "get_*", "action": "allow" },
{ "match": "list_*", "action": "allow" },
{ "match": "search_*", "action": "allow" },
{ "match": "read_*", "action": "allow" }
]
Never let secrets or card numbers out
{ "match": "*", "action": "deny", "if_args_match": "\\b\\d{13,19}\\b", "reason": "Looks like a card number" },
{ "match": "*", "action": "deny", "if_args_match": "(sk_live_|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY)", "reason": "Looks like a secret" }
Small refunds go through, big ones wait
{ "match": "stripe/create_refund", "action": "require_approval", "if_args_match": "\"amount\":\\s*[0-9]{5,}", "reason": "Refund of $100 or more" },
{ "match": "stripe/create_refund", "action": "allow" }
Only email your own domain without asking
{ "match": "send_email", "action": "allow", "if_args_match": "\"to\":\\s*\"[^\"@]+@yourcompany\\.com\"" },
{ "match": "send_email", "action": "require_approval", "reason": "Email to an outside address" }
Check what a policy compiles to on a running instance with GET /api/policy (see the HTTP API).