Guides
Set Policies
Author policy rules — attribute matches, IP allowlists, method/endpoint restrictions, rate limits, time windows, operation and parameter rules, and approvals — from the dashboard or the CLI, plus non-stored per-request rules via an SDK constrained client.
By the end of this guide you can restrict what an agent or connection is allowed to do: deny requests by attribute, confine traffic to an IP range or an endpoint allowlist, cap its request rate, confine it to business hours, allow a provider operation only when its parameters look right (or redact fields / force a fresh sign-in), and hold sensitive calls for human approval — every rule type the policy engine supports.
A policy is a narrowing-only layer on top of a default-deny base. A request already needs ownership of the connection, the right scope, and a valid credential before any rule runs. A rule can only further restrict that base — creating one never grants or widens access. When a rule matches, it denies the request, rate-limits it, holds it for approval, or redacts part of it.
Where policies are authored
Section titled “Where policies are authored”One feature, several labels: the developer portal calls this surface Runtime policies (every app, provider, agent, and grant page has a Runtime policies section with an Add runtime policy button), the Wallet has a Runtime Policy page and a Runtime policy tab on each connection, the CLI is alter policy, and this documentation says policy or policy rule. They all author the same stored rules, evaluated by the same engine.
| Surface | Use it for |
|---|---|
| Dashboard (developer portal) | Create, inspect, edit, toggle, simulate, and delete stored organization, application, provider, agent, and connection rules. |
| Wallet | End users manage account-wide and connection-specific rules and inspect disclosure-safe inherited controls. |
CLI (alter policy) | Scriptable application, provider, agent, and connection rules for CI and infrastructure-as-code. Organization rules stay dashboard-only; account rules stay Wallet-authored. |
| SDK constrained client | A non-stored rule carried by every credential request made through the returned with_constraints / withConstraints sub-client. |
| Connect popup (end users) | While authorizing an OAuth connection or delegating existing managed-secret access to an agent, the end user can set usage limits in the popup — the same self-scoped, narrowing-only rules available later in the Wallet. On by default; an application can hide the step with the Connect-session option (allowUserPolicyRules in the TypeScript SDK, allow_user_policy_rules in the Python SDK). |
Stored rules attach at six levels — organization, application, provider, agent, connection (grant), and account (user). End users can add connection rules in the Wallet or during Connect authorization. An SDK constrained-client rule can narrow calls without storing the rule. A request is evaluated against every applicable rule across the delegation chain, and a denial at any level wins.
View and understand a policy
Section titled “View and understand a policy”Select a policy anywhere it appears to open the shared detail viewer. Owned rules expand the complete definition: effect, target and source, operations, every parameter/operator/value condition, approval configuration, status, and timestamps. The developer portal’s organization-level Runtime policies page indexes all six stored levels — organization, application, provider, agent, connection, and account. Account (user) rules are authored by the end user in the Wallet and shown read-only (disclosure-limited) in the portal; the Wallet’s Runtime Policy page separates the editable account-wide and connection-specific rules the end user owns from disclosure-safe inherited developer controls.
For a composed view, open a connection’s Runtime policy tab in the Wallet: it groups the effective rules by account, an Inherited organization and application controls group, connection, provider, and agent. In the developer portal, the grant drawer’s Effective runtime policy chain lists the same rules with organization and application as separate groups, and the Simulator tests a request shape and links every trace entry to the same full viewer.
Prerequisites
Section titled “Prerequisites”- An app with at least one agent or connection to attach a rule to.
- For the CLI, an authenticated
altersession (see the CLI reference).
Rate-limit an agent (quota)
Section titled “Rate-limit an agent (quota)”Goal: allow at most 1,000 requests per hour, counted per caller principal (the agent when the request runs as one, otherwise the connection).
Dashboard
Open the agent (or app), go to its Runtime policies section, and click Add runtime policy. Give it a name, choose Quota as the type, enter a limit of 1000, pick a period of hour, and click Create Rule. The new rule card shows the 1000 / hour badge.
CLI
echo '{"limit": 1000, "period": "hour"}' | \ alter policy rules create --type quota --agent <agent-id> \ --body - --name "1000/hour per agent"Over the limit, the call is denied with a retry-after until the window resets (top of the minute/hour, UTC midnight, or the first of the month). This is the only rule type that responds with a rate-limit signal rather than a plain denial — the SDKs surface it as QuotaExceededError (see errors) and never auto-retry it.
Confine access to business hours (time window)
Section titled “Confine access to business hours (time window)”Goal: allow requests only Mon–Fri, 09:00–17:00, US Eastern.
Dashboard
In Runtime policies → Add runtime policy, choose the Time window type. Toggle the days Mon–Fri, set start 09:00 and end 17:00, and enter a timezone of America/New_York. Click Create Rule.
CLI
echo '{ "windows": [{"days": ["mon","tue","wed","thu","fri"], "start": "09:00", "end": "17:00"}], "timezone": "America/New_York"}' | alter policy rules create --type time_window --agent <agent-id> \ --body - --name "Business hours"The request is denied unless its local time (in the required timezone) falls inside a window. A window is [start, end) — start inclusive, end exclusive; use "24:00" for end-of-day. A start later than end is a valid overnight window (e.g. fri 22:00 → sat 06:00). Up to 20 windows per rule; the timezone is required.
Deny requests by attribute (match rule)
Section titled “Deny requests by attribute (match rule)”Goal: make an application read-only — deny every POST, PUT, PATCH, and DELETE, app-wide.
Dashboard
Open the app’s Runtime policies section and click Add runtime policy. Choose the Request match (deny) type and add a condition with the attribute method and the values POST, PUT, PATCH, DELETE. Before saving, review the app-wide OAuth-provider, managed-secret, active-grant, and agent counts, select a representative grant, and click Test unsaved runtime policy. The trace marks the candidate and is cleared if you edit the draft. Then click Create Rule.
CLI
echo '{"when": {"method": ["POST", "PUT", "PATCH", "DELETE"]}, "effect": "deny"}' | \ alter policy rules create --body - --name "Read-only app"A match rule denies when ALL of its conditions match; a condition value is an exact string or a list (membership). Conditions can name request metadata (method, provider_id, agent_id, client_ip, …) or the classified operation vocabulary — operation (a catalog operation id) and family (send, read, write, delete, admin, payment) — so {"when": {"family": ["payment"]}, "effect": "deny"} blocks everything that moves money. Two fail-closed behaviors to know: a method condition also gates raw-token retrievals (a raw token confers every method), and an operation/family condition also fires on requests that cannot be classified. client_ip is an exact-IP match — for ranges, use an IP allowlist rule instead.
One operation can belong to several families, and a family condition matches if ANY of them is listed. Many provider APIs multiplex on the request body: the same endpoint that edits a resource also deletes it, selected by a field rather than by the HTTP method. Notion trashes a page by PATCHing it with {"in_trash": true}; a Git-host commit endpoint deletes files through its actions[]; a search index’s alias endpoint removes aliases through its body. Those operations therefore carry both write and delete, so {"when": {"family": ["delete"]}, "effect": "deny"} blocks them — including a routine edit through the same endpoint. That is deliberate: the alternative is a deny an agent can dodge by expressing the delete through the update call. When an agent legitimately needs to edit but never delete on such an endpoint, allow it by operation (the specific catalog operation id) rather than widening the family rule.
Confine traffic to an IP range (IP allowlist)
Section titled “Confine traffic to an IP range (IP allowlist)”Goal: only accept requests originating from the office egress range.
Dashboard
In Runtime policies → Add runtime policy, choose the IP allowlist type and enter one address or CIDR range per line — for example 203.0.113.5 and 10.0.0.0/8. Click Create Rule.
CLI
echo '{"allow": ["203.0.113.5", "10.0.0.0/8"]}' | \ alter policy rules create --type ip_allowlist --body - --name "Office egress only"Requests from any source IP not on the list are denied. A /0 catch-all — or a set of entries that together cover the whole address space — is rejected at authoring: an allowlist that matches every IP is not a restriction. Multiple IP allowlist rules at different levels compose by intersection (a request must satisfy all of them), and a request whose source IP cannot be determined is denied, never waved through. This is an operator rule type (dashboard/CLI); end users cannot author it from the Wallet.
Limit methods and endpoints (restriction)
Section titled “Limit methods and endpoints (restriction)”Goal: confine a connection to read-only repository access.
Dashboard
In Runtime policies → Add runtime policy, choose the Method/endpoint restriction type. Enter the allowed methods (GET) and the allowed endpoint paths (/repos/**), and click Create Rule.
CLI
echo '{"allowed_methods": ["GET"], "allowed_endpoints": ["/repos/**"]}' | \ alter policy rules create --type restriction --grant <grant-id> \ --body - --name "Read-only repos"A request is denied unless its method is in allowed_methods (when set) AND its provider API path matches allowed_endpoints (when set) — at least one of the two must be present. An endpoint is an exact path (/user) or a trailing /** prefix wildcard (/repos/** matches /repos, /repos/, and everything under it); no other wildcard form exists. Exact entries are byte-exact including the trailing slash — /user does not admit /user/; list both forms, or use a /** wildcard, when both should pass. Non-canonical alias spellings (duplicate slashes, . segments, backslashes, percent-encoded separators) are denied outright under an endpoint allowlist — their provider-side resolution is ambiguous — and are rejected when authoring patterns. Restrictions are enforced on proxied calls, so a connection carrying one refuses raw-token retrieval — a raw token could not be held to the allowlist after handoff. Like the IP allowlist, this is an operator rule type.
Restrict a specific operation (content rule)
Section titled “Restrict a specific operation (content rule)”Content rules bind reviewed provider operations — or whole semantic families (send, read, write, delete, admin, payment) — from the operation catalog, optionally narrowed by conditions over the request’s parameters. They have three effects: deny, redact (strip named fields before the call), or step_up (require a recent end-user sign-in).
Content rules apply only to providers with an operation catalog. A custom managed secret has no catalog, so its traffic can never classify — a content rule that reaches it denies every request rather than ignoring it. Use a method/endpoint restriction to scope what a custom API’s traffic may do.
Deny by parameter — outbound email to internal recipients only
Section titled “Deny by parameter — outbound email to internal recipients only”Goal: allow Gmail send only when every recipient is @example.com.
Dashboard
In Runtime policies → Add runtime policy, choose the Content rule type. Pick the provider (Google), search the catalog and select the operation (gmail.users.messages.send). Click Add condition, set the parameter name to recipients, the operator to not all within, and the value to *@example.com. Leave the effect on Deny and click Create Rule.
CLI
echo '{ "match": {"operations": ["gmail.users.messages.send"]}, "params": [{"name": "recipients", "op": "not_subset_of", "value": ["*@example.com"]}], "effect": "deny"}' | alter policy rules create --type content_match --provider google \ --body - --name "Internal recipients only"Condition operators are equals, any_in (fires if any value matches), not_subset_of (fires when values are NOT fully covered by the allowlist — the recipient-allowlist shape), and the numeric gt/gte/lt/lte. List values support per-entry globs (*@example.com). A few providers canonicalize a path parameter so that equivalent spellings of the same key match one rule (SAP S/4HANA, where '0000500001' and '500001' are the same sales order). A glob cannot be compared against a canonicalized value, so a pattern on one of those parameters is rejected when the rule is saved — use equals, or any_in with the exact key values.
A condition’s name can reference a parameter from the operation’s catalog entry (like recipients above) or any query-string parameter the request carries — for example visibility on GitHub’s list-repositories operation or limit on Slack’s list-users operation. Query parameters are evaluated identically whether the caller embeds them in the request URL or passes them through the SDK’s separate query-parameters argument, and the policy simulator projects a simulated URL’s query string the same way. The numeric operators (gt/gte/lt/lte) require a cataloged numeric parameter; an ad-hoc query value compares as a string, so a numeric condition on it denies. Ambiguous spellings fail closed: when an uncataloged query key is repeated with different values, or a query key shadows a differently-sourced cataloged parameter, any condition naming that parameter denies rather than guessing which value the provider would honor — conditions on other parameters are unaffected. A cataloged scalar query parameter repeated with different values cannot be projected at all, so every parameter condition on that operation denies.
String comparisons are normalized: equals and the any_in/not_subset_of pattern matching compare case-insensitively with surrounding whitespace trimmed (internal whitespace is significant). This is deliberate anti-bypass behavior — a rule denying blocked also denies BLOCKED and blocked (a padded variant), so a condition cannot be dodged by changing case or padding. Number-typed parameters compare numerically (a string-typed parameter compares as normalized text). On a set-shaped parameter (for example recipients), equals matches only an exact single value — a multi-member set never matches; use any_in to match a member.
Redact instead of deny
Section titled “Redact instead of deny”Swap the effect to strip fields before the call is forwarded (the call is refused if the redaction cannot be provably applied):
echo '{ "match": {"operations": ["gmail.users.messages.send"]}, "effect": "redact", "redact": {"fields": ["subject"]}}' | alter policy rules create --type content_match --provider google \ --body - --name "Strip subject on sends"Require a fresh sign-in (step-up)
Section titled “Require a fresh sign-in (step-up)”echo '{ "match": {"operations": ["gmail.users.messages.send"]}, "effect": "step_up", "step_up": {"max_session_age_seconds": 300}}' | alter policy rules create --type content_match --provider google \ --body - --name "Recent sign-in to send"Hold sensitive calls for a human (require approval)
Section titled “Hold sensitive calls for a human (require approval)”Goal: pause every write for an approver to review.
echo '{ "effect": "require_approval", "when": {"method": ["POST", "DELETE"]}, "approval": {"approvers": ["lead@example.com"], "channels": ["email"]}}' | alter policy rules create --type require_approval --agent <agent-id> \ --body - --name "Approve writes"In the dashboard this is the Human in the loop (HITL) type on the same Add runtime policy form — “human in the loop” and require_approval are the same rule type, one label for operators and one identifier on the wire.
The same gate also has a second, simpler authoring surface: the grant editor’s Human-in-the-loop approval block (CLI: --grant-policy on alter managed-secrets grants create / update). That block is the grant’s baseline — one unconditional requirement on every call through that grant, replaced as a whole each time the grant’s settings are saved — while a require_approval rule is named, optionally conditional, and can sit above one grant (agent-, provider-, or app-wide). Use the block for “every call on this credential needs a human”; use a rule for “only these calls need a human”. Both appear in the policy viewer, and authoring both for the same condition produces two gates. The end-to-end approval flow — how the request pauses, how the application receives the outcome, and how the two surfaces interact — is covered in Add Human-in-the-Loop Approvals.
One rule produces one approval gate and currently designates the first address in its approver list. For multi-party N-of-N approval, author distinct applicable rules with distinct approver sets; Alter combines them into separate gates and requires every gate to approve.
The when condition also accepts the content-rule vocabulary — operations/families plus parameter conditions — so approval can hinge on what the request carries, not just its shape:
# Only payments over 1,000 need sign-offecho '{ "effect": "require_approval", "when": { "match": {"families": ["payment"]}, "params": [{"name": "amount", "op": "gt", "value": 1000}] }, "approval": {"approvers": ["cfo@example.com"]}}' | alter policy rules create --type require_approval --body - --name "Approve large payments"Omit when entirely to require approval on every request at the target. An operation-scoped approval condition on a request that cannot be classified is denied — it is never silently approved around.
How rules combine
Section titled “How rules combine”Every applicable rule — at every level, across the whole delegation chain — is evaluated on every request, and they compose as AND: a request must satisfy all of them, and a denial at any level wins. There is no rule ordering to reason about and no way for one rule to override another, because no rule can widen access — each one only narrows. So a stack like:
- app-wide:
time_window(business hours) - agent:
quota(1,000/hour) - provider:
content_match(internal recipients only) - connection:
require_approval(writes need sign-off)
means a request must be inside the window AND under the quota AND pass the content conditions, and a matching write still waits for its approval. Multiple windows inside ONE time-window rule are a union (any window admits); multiple time-window rules across levels intersect (all must admit).
Test before you commit (simulate)
Section titled “Test before you commit (simulate)”Dry-run a request against the live rules without making a real call:
alter policy simulate --grant <grant-id> \ --operation gmail.users.messages.send \ --params '{"recipients": ["someone@example.com"]}'The output shows the verdict, the per-rule trace, and which operation the request classified to. The dashboard has the same policy explorer.
To test an unsaved app-wide rule, put the complete create object in a file and add --candidate. The rule is validated and overlaid in memory for this one request; it is marked candidate in the trace and is never created. A candidate with enabled set to false is not overlaid and gets no trace row, matching the fact that a disabled rule is never enforced once saved:
cat > candidate-rule.json <<'JSON'{"rule_type":"json_match","rule_body":{"when":{"method":"POST"},"effect":"deny"},"name":"Block POST"}JSON
alter policy simulate --grant <grant-id> --method POST \ --candidate @candidate-rule.jsonOne simulation is one scenario, not proof over every possible request shape. Test the methods, endpoints, agents, IPs, times, operations, and parameters that matter for the rule before you save it.
Attach a non-stored rule with a constrained SDK client
Section titled “Attach a non-stored rule with a constrained SDK client”Stored rules are the durable controls. with_constraints / withConstraints returns a sibling client whose credential requests all carry the additional rule. Use that sibling for constrained calls and retain the original client for calls that should not carry the rule:
from alter_sdk import content_match_rule
# Require a recent end-user sign-in for calls through this sibling:fresh = app.with_constraints( rule=content_match_rule( operations=["gmail.users.messages.send"], effect="step_up", max_session_age_seconds=300, ),)await fresh.proxy_request( "POST", provider_url, grant_id=grant_id, reason="Send a reviewed message",)import { contentMatchRule } from "@alter-ai/alter-sdk";
const fresh = app.withConstraints({ rule: contentMatchRule({ operations: ["gmail.users.messages.send"], effect: "step_up", maxSessionAgeSeconds: 300, }),});await fresh.proxyRequest({ method: "POST", url: providerUrl, grantId, reason: "Send a reviewed message",});The builders validate the rule locally (effect-specific requirements included) before it is sent.
Manage existing rules
Section titled “Manage existing rules”alter policy show-app # app-level rules at a glancealter policy rules list --agent <agent-id> # list rules at a levelalter policy rules update --rule <rule-id> --disable # toggle without deletingalter policy rules delete --rule <rule-id> # remove