Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 88 additions & 2 deletions POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,13 @@ The `action` field controls how the rule behaves:
|---|---|---|
| `"evaluate"` (default) | Checks conditions; denies the call if any condition fails | Required (at least one) |
| `"deny"` | Unconditionally blocks the tool call | Not allowed |
| `"require_approval"` | Blocks the call until a human approves it via CLI or admin API | Optional |

If `action` is omitted, it defaults to `"evaluate"`.

### on_deny

The `on_deny` field is an optional message returned to the AI agent when a rule denies a tool call. The agent sees it prefixed with `[INTERCEPT POLICY DENIED]`:
The `on_deny` field is an optional message returned to the AI agent when a rule denies a tool call. For `deny` and `evaluate` rules the agent sees it prefixed with `[INTERCEPT POLICY DENIED]`. For `require_approval` rules the prefix is `[INTERCEPT APPROVAL REQUIRED]`:

```yaml
on_deny: "Hourly limit of 5 new issues reached. Wait before creating more."
Expand Down Expand Up @@ -155,6 +156,79 @@ Restrictions:

For wildcard (`"*"`) tools, the counter is scoped to `_global` as usual.

## Approval rules

Rules with `action: "require_approval"` pause tool calls until a human explicitly approves or denies them. The agent receives a JSON-RPC error (`-32003`) with the approval ID and instructions.

```yaml
tools:
create_charge:
rules:
- name: "large charge approval"
action: "require_approval"
conditions:
- path: "args.amount"
op: "gt"
value: 50000
on_deny: "Charges over $500.00 require human approval"
approval_timeout: "30m"
```

When a matching call arrives, Intercept creates a pending approval record. The agent is told to wait. A human can then approve or deny via:

```sh
intercept approvals approve <id>
intercept approvals deny <id> --reason "too expensive"
```

If the same tool call (identical arguments and rule) is retried while a pending approval exists, Intercept reuses the existing record rather than creating a duplicate.

Once approved, the next identical call is allowed through and the approval is consumed (single use). If the approval expires before being consumed, the next call creates a fresh pending record.

### approval_timeout

Per-rule timeout controlling how long an approval stays valid. Accepts Go duration strings (`5m`, `1h`, `24h`). Defaults to the global `approvals.default_timeout`, which itself defaults to `15m`.

```yaml
- name: "destructive action"
action: "require_approval"
approval_timeout: "1h"
```

### Top-level approvals block

```yaml
approvals:
default_timeout: 10m
dedupe_window: 10m
notify:
webhook:
url: "https://your-bot.com/intercept"
secret: "your-hmac-secret"
```

| Field | Required | Description |
|---|---|---|
| `default_timeout` | no | Default approval lifetime (Go duration, default 15m) |
| `dedupe_window` | no | Window for deduplicating identical retry attempts |
| `notify.webhook.url` | no | URL to POST when an approval is requested |
| `notify.webhook.secret` | no | HMAC-SHA256 secret for signing webhook payloads |

### Webhook notifications

When configured, Intercept POSTs a JSON payload to the webhook URL each time a new approval is requested. The payload includes the approval ID, tool name, rule, reason, expiry, and a safe preview of the arguments.

Webhook deliveries are signed with HMAC-SHA256. Headers: `X-Intercept-Signature` (hex-encoded) and `X-Intercept-Timestamp`.

Webhooks are fire-and-forget: delivery failure is logged but never blocks the tool call or causes an accidental allow.

### Restrictions

- `rate_limit` cannot be combined with `action: require_approval` on the same rule (use separate rules)
- `state` blocks should not be placed on `require_approval` rules (use a separate rate limit rule)
- Hidden tools (`hide` list) cannot be rescued by approval rules
- Approval fingerprints include a policy revision hash -- changing the policy file invalidates pending approvals

## Conditions

Conditions compare a value at a given `path` against an expected `value` using an `op` (operator). All conditions within a rule are ANDed: every condition must pass for the rule to allow the call.
Expand Down Expand Up @@ -321,6 +395,7 @@ When a `tools/call` request arrives, Intercept processes it as follows:
2. Look up wildcard (`"*"`) rules.
3. Evaluate all matching rules in order (tool-specific first, then wildcard):
- If a rule has `action: "deny"`, the call is denied immediately.
- If a rule has `action: "require_approval"`, and conditions match (or no conditions), the call is held for approval.
- If a rule has `action: "evaluate"`, all its conditions must pass.
4. If any rule denies the call, the `on_deny` message from that rule is returned to the agent.
5. If all rules pass, reserve any stateful counters atomically.
Expand Down Expand Up @@ -453,7 +528,7 @@ Run `intercept validate -c policy.yaml` to check a policy file for errors. Every
| `version must be "1", got "<x>"` | The `version` field is missing or not `"1"` | Set `version: "1"` |
| `default must be "allow" or "deny", got "<x>"` | The `default` field has an unrecognised value | Use `"allow"` or `"deny"`, or omit the field |
| `rule must have a name` | A rule is missing the `name` field | Add a `name` to the rule |
| `action must be "evaluate" or "deny", got "<x>"` | Unrecognised action value | Use `"evaluate"` or `"deny"` |
| `action must be "evaluate", "deny", or "require_approval", got "<x>"` | Unrecognised action value | Use `"evaluate"`, `"deny"`, or `"require_approval"` |
| `deny rules must not have conditions` | A rule with `action: "deny"` has a `conditions` list | Remove `conditions` from deny rules |
| `evaluate rules must have at least one condition` | A rule with `action: "evaluate"` has no conditions | Add at least one condition |
| `path must start with "args." or "state.", got "<x>"` | Condition path does not reference tool arguments or state | Use `args.<field>` or `state.<scope>.<counter>` |
Expand All @@ -471,4 +546,15 @@ Run `intercept validate -c policy.yaml` to check a policy file for errors. Every
| `rate_limit window must be "minute", "hour", or "day", got "<x>"` | Unrecognised window in `rate_limit` | Use `minute`, `hour`, or `day` |
| `rate_limit cannot be combined with conditions or state` | A rule uses `rate_limit` alongside `conditions` or `state` | Use separate rules, or switch to the full syntax |
| `rate_limit cannot be used with action "deny"` | A deny rule also has `rate_limit` | Remove `rate_limit` or change the action |
| `rate_limit cannot be used with action "require_approval"` | An approval rule also has `rate_limit` | Use separate rules for rate limiting and approval |
| `require_approval rules must not have a state block` | An approval rule has a `state` block | Move the state block to a separate evaluate rule |
| `invalid approval_timeout "<x>"` | The `approval_timeout` value is not a valid Go duration | Use a duration like `5m`, `1h`, `24h` |
| `approval_timeout must be positive` | The `approval_timeout` resolves to zero or negative | Use a positive duration |
| `approvals.default_timeout: invalid duration "<x>"` | The global default timeout is not a valid Go duration | Use a duration like `15m` |
| `approvals.default_timeout must be positive` | The global default timeout is zero or negative | Use a positive duration |
| `approvals.dedupe_window: invalid duration "<x>"` | The dedupe window is not a valid Go duration | Use a duration like `10m` |
| `approvals.dedupe_window must be positive` | The dedupe window is zero or negative | Use a positive duration |
| `approvals.notify.webhook.url must not be empty` | Webhook URL is blank | Provide a URL or remove the webhook block |
| `approvals.notify.webhook.url must use http or https` | Webhook URL uses an unsupported scheme | Use an `http://` or `https://` URL |
| `approvals.notify.webhook.secret must not be empty` | Webhook secret is blank | Provide a secret for HMAC-SHA256 signing |
| `duplicate state counter "<name>" (also used by rules[N])` | Two rules on the same tool define the same counter name | Use different counter names or different windows |
52 changes: 34 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,36 +20,37 @@ Intercept is the open-source control layer for AI agents in production. It sits

## Try it in 30 seconds

See every tool your AI agent has access to before adding any rules:
See every tool your AI agent has access to, before adding any rules:

```sh
npx -y @policylayer/intercept scan -- npx -y @modelcontextprotocol/server-github
```

This connects to the server, discovers all available tools, and shows you the full attack surface. Then add a policy to lock it down.
Connects to the server, discovers all available tools, and shows the full attack surface. Add a policy to lock it down.

## Why Intercept, not system prompts?

| | System prompt | Intercept |
|---|---|---|
| **Enforcement** | Probabilisticmodel can ignore | Deterministic blocked at transport layer |
| **Bypassable** | Yes injection, reasoning, context overflow | No agent never sees the rules |
| **Stateful** | Nono memory of previous calls | Yes counters, spend tracking, sliding windows |
| **Auditable** | Nono structured log of decisions | Yes every allow/deny logged with reason |
| **Enforcement** | Probabilistic, model can ignore | Deterministic, blocked at transport layer |
| **Bypassable** | Yes, via injection, reasoning, context overflow | No, agent never sees the rules |
| **Stateful** | No, no memory of previous calls | Yes, counters, spend tracking, sliding windows |
| **Auditable** | No, no structured log of decisions | Yes, every allow/deny logged with reason |
| **Latency** | N/A | <1ms per evaluation |

Prompts tell the agent what it should do. Intercept defines what it is allowed to do.

## What it does

- **Block tool calls** — deny dangerous tools unconditionally (e.g. `delete_repository`)
- **Validate arguments** — enforce constraints on tool arguments (`amount <= 500`, `currency in [usd, eur]`)
- **Rate limit** — cap calls per minute, hour, or day with `rate_limit: 5/hour` shorthand
- **Track spend** — stateful counters with dynamic increments (e.g. sum `args.amount` across calls)
- **Hide tools** — strip tools from `tools/list` so the agent never sees them, saving context window tokens
- **Default deny** — allowlist mode where only explicitly listed tools are permitted
- **Hot reload** — edit the policy file while running; changes apply immediately without restart
- **Validate policies** — `intercept validate -c policy.yaml` catches errors before deployment
- **Block tool calls.** Deny dangerous tools unconditionally (e.g. `delete_repository`)
- **Validate arguments.** Enforce constraints on tool arguments (`amount <= 500`, `currency in [usd, eur]`)
- **Rate limit.** Cap calls per minute, hour, or day with `rate_limit: 5/hour` shorthand
- **Track spend.** Stateful counters with dynamic increments (e.g. sum `args.amount` across calls)
- **Hide tools.** Strip tools from `tools/list` so the agent never sees them, saving context window tokens
- **Require approval.** Hold tool calls for human approval via CLI or local HTTP API before execution
- **Default deny.** Allowlist mode where only explicitly listed tools are permitted
- **Hot reload.** Edit the policy file while running; changes apply immediately without restart
- **Validate policies.** `intercept validate -c policy.yaml` catches errors before deployment

## Real-world examples

Expand Down Expand Up @@ -83,6 +84,21 @@ create_resource:
on_deny: "Resource creation rate limit reached"
```

**It issued a $4,200 refund without asking anyone**

```yaml
create_refund:
rules:
- name: "large-refund-approval"
action: require_approval
conditions:
- path: "args.amount"
op: "gt"
value: 10000
on_deny: "Refunds over $100.00 require human approval"
approval_timeout: "30m"
```

**It emailed 400,000 customers a test template**

```yaml
Expand Down Expand Up @@ -127,7 +143,7 @@ Download from [GitHub Releases](https://github.com/policylayer/intercept/release
intercept scan -o policy.yaml -- npx -y @modelcontextprotocol/server-stripe
```

This connects to the server, discovers all available tools, and writes a commented YAML file listing each tool with its parameters.
Connects to the server, discovers all tools, and writes a commented YAML file listing each tool with its parameters.

**2. Edit the policy to add rules:**

Expand Down Expand Up @@ -181,11 +197,11 @@ tools:
intercept -c policy.yaml --upstream https://mcp.stripe.com --header "Authorization: Bearer sk_live_..."
```

Intercept proxies all MCP traffic and enforces your policy on every tool call. Hidden tools are stripped from the agent's view entirely.
Intercept proxies all MCP traffic and enforces your policy on every tool call. Hidden tools are stripped from the agent's view.

## Example policies

The `policies/` directory contains ready-made policy scaffolds for 130+ popular MCP servers including GitHub, Stripe, AWS, Notion, Slack, and more. Each file lists every tool with its description, grouped by category (Read, Write, Execute, Financial, Destructive).
The `policies/` directory contains ready-made scaffolds for 130+ MCP servers including GitHub, Stripe, AWS, Notion, and Slack. Each file lists every tool with its description, grouped by risk category.

Copy one as a starting point:

Expand Down Expand Up @@ -252,7 +268,7 @@ intercept -c policy.yaml --state-dsn redis://localhost:6379 --upstream https://m

## Contributing

Contributions welcome — open an issue to discuss what you'd like to change.
Contributions welcome. Open an issue to discuss what you'd like to change.

## License

Expand Down
61 changes: 61 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ intercept -c <policy-file> [flags] --upstream <url>
| `--header` | | string[] | | Custom headers for upstream requests (e.g. `"Authorization: Bearer tok"`) |
| `--bind` | | string | `127.0.0.1` | Bind address for HTTP/SSE listener |
| `--port` | | int | `0` (auto) | Port for HTTP/SSE listener |
| `--enable-admin-api` | | bool | `false` | Start the local admin API for managing approvals |
| `--admin-addr` | | string | `127.0.0.1:9111` | Bind address for the admin API server |

`--state-dir` and `--state-dsn` are mutually exclusive.

Expand Down Expand Up @@ -153,6 +155,65 @@ No flags. The command reads event files from `~/.intercept/events/` and prints:

Sections with no data are omitted.

## Approvals command

Manages pending approval requests created by `require_approval` rules. See [POLICY.md, Approval rules](POLICY.md#approval-rules) for the policy format.

```
intercept approvals <subcommand> [flags]
```

### Subcommands

| Subcommand | Description |
|---|---|
| `list` | List all pending approval requests |
| `show [id]` | Show full details of a single approval |
| `approve [id]` | Approve a pending request (allows the next matching call) |
| `deny [id]` | Deny a pending request |
| `expire --all-stale` | Expire all approvals past their timeout |

### Flags

| Flag | Subcommand | Type | Default | Description |
|---|---|---|---|---|
| `--state-dir` | all | string | `~/.intercept/state` | Directory for persistent state |
| `--actor` | `approve` | string | | Identity of the approver |
| `--reason` | `deny` | string | | Reason for denial |
| `--all-stale` | `expire` | bool | `false` | Required flag to confirm expiry |

### Examples

```sh
# List pending approvals
intercept approvals list

# Show details of a specific approval
intercept approvals show apr_abc123

# Approve a request
intercept approvals approve apr_abc123 --actor "liad"

# Deny with a reason
intercept approvals deny apr_abc123 --reason "amount too high"

# Expire stale approvals
intercept approvals expire --all-stale
```

### Admin API endpoints

When `--enable-admin-api` is set, the proxy exposes a local HTTP API for approval management:

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/pending` | List all pending approvals |
| `GET` | `/api/pending/{id}` | Get a single approval by ID |
| `POST` | `/api/approve/{id}` | Approve (body: `{"actor": "..."}`) |
| `POST` | `/api/deny/{id}` | Deny (body: `{"reason": "..."}`) |

Expired approvals return `409 Conflict`. Already-decided approvals return the current state (idempotent).

## MCP client integration

To use Intercept with Claude Code or any MCP client that reads `.mcp.json`, point the server's `command` at Intercept.
Expand Down
4 changes: 1 addition & 3 deletions cmd/approvals.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"time"

Expand All @@ -29,8 +28,7 @@ func newApprovalsCmd(opener storeOpener) *cobra.Command {
Short: "Manage pending approval requests",
}

home, _ := os.UserHomeDir()
defaultStateDir := filepath.Join(home, ".intercept", "state")
defaultStateDir, _ := interceptSubdir("state")
approvalsCmd.PersistentFlags().StringVar(&approvalStateDir, "state-dir", defaultStateDir, "directory for persistent state")

if opener == nil {
Expand Down
5 changes: 3 additions & 2 deletions cmd/approvals_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ func execApprovals(t *testing.T, store approvals.Store, args ...string) (string,
}

func seedPendingRecord() approvals.Record {
now := time.Now().UTC()
return approvals.Record{
ID: "apr_test001",
Fingerprint: "fp_abc123",
Expand All @@ -69,8 +70,8 @@ func seedPendingRecord() approvals.Record {
Status: approvals.StatusPending,
Reason: "PR merges require human approval",
ArgsHash: "argshash",
CreatedAt: time.Date(2026, 3, 26, 12, 15, 0, 0, time.UTC),
ExpiresAt: time.Date(2026, 3, 26, 12, 30, 0, 0, time.UTC),
CreatedAt: now,
ExpiresAt: now.Add(15 * time.Minute),
}
}

Expand Down
Loading
Loading