Skip to content

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.

Policy changes require alter CLI 0.10.0 or newer. An older CLI receives HTTP 428 with upgrade guidance. For an already-open dashboard tab, copy any unsaved draft before reloading the page, then review the change again. These checks prevent stale writes; they cannot be bypassed by retrying an old client.

Creating, editing, enabling, disabling, or deleting a runtime policy shows a review. Select Review, check its target and effect, then explicitly save. The review leads with What this change does: one plain sentence per change (for example, “The limit changes from 100 to 1000 requests per day”) and, where the rule’s meaning settles it, a rating of More restrictive, Less restrictive, or Mixed. Content rules, AI review, approver changes, and timezone changes are rated Impact uncertain rather than guessed. Any less-restrictive change carries a warning, not only disabling or deleting. The complete before/after definitions are under View full definitions. Editing a disabled rule does not enable it. Removing a restriction never overrides other policies or grants access by itself.

A concurrent edit makes your reviewed version stale; the save changes nothing. Refresh the current rule and review your intended change again. If a save’s response is lost, retrying the unchanged review uses the same retry key and does not create a second rule or success audit. Changing the draft requires a new review.

Every CLI example in this guide creates a rule, which prints its review and proceeds. Scripted updates and deletions require --yes and the explicitly reviewed --expected-version; see change safety and retries.

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.

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 Limits page and a Limits 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.

SurfaceUse it for
Dashboard (developer portal)Create, inspect, edit, toggle, simulate, and delete stored organization, application, provider, managed secret, group, user, agent, and connection rules, and attach one rule to several of them.
WalletEnd users manage their own account-wide and connection-specific rules, see the rules the application set on their account (read-only, marked Set by the application), and inspect disclosure-safe inherited controls.
CLI (alter policy)Scriptable application, provider, managed secret, group, user, agent, and connection rules (including attachments and exceptions) for CI and infrastructure-as-code. Organization rules stay dashboard-only; the limits an end user sets on their own account stay theirs.
SDK constrained clientA 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 are created at eight levels: organization, application, provider, managed secret, group (an identity-provider group), account (user), agent, and connection (grant). An application, provider, managed secret, group, agent, or connection rule, or an account rule the application authored, can also be attached to more places in the same application (see Apply one rule in several places). 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.

For Git-reviewed changes, use policy files: portable definitions, explicit environment bindings, before/after tests and a saved plan. Code-owned rules identify their manager in the viewer and cannot be edited, disabled or deleted interactively. Release ownership through a reviewed plan to return a rule to dashboard management without removing its restriction.

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 eight stored levels: organization, application, provider, managed secret, group, account, agent, and connection. Each row’s Applies to lists every place the rule is attached and every exception, with a link to each. Account (user) rules have two authors, and each edits only its own: rules the application set are editable in the portal and CLI, while the limits an end user set in the Wallet are shown read-only there. The Wallet’s Limits page separates the editable account-wide and connection-specific rules the end user owns from the application’s rules on their account (under Set by the application) and from disclosure-safe inherited developer controls.

For a composed view, open a connection’s Limits tab in the Wallet: it groups the effective rules into Your account-wide limits, Company and app restrictions, Your limits for this connection, provider and agent restrictions, and restrictions from the connection this was shared from. In the developer portal, the grant drawer’s Effective runtime policy chain lists the same rules with organization and application as separate groups. Explain a request tests a request shape, opens each visible rule’s definition, and links to its actual owner, including an inherited connection.

  • An app. App-wide policies can be authored before any agents or connections exist. A narrower target must exist before you attach a rule to it.
  • For the CLI, an authenticated alter session (see the CLI reference).

The portal’s Create a restriction action opens an authoring panel over the page you are on, so the list, simulator or drawer behind it stays as you left it. Choose a target, then fill in the panel and select Review; on a wide screen, a summary of what the policy does stays beside its definition. Every Add runtime policy and Edit action opens the same panel. Wallet personal limits use the same panel; Connect contains the builder in a compact island. Existing restrictions remain available to inspect, enable, disable or delete through Manage existing restrictions.

Search the catalog or choose a goal: Require approval, Restrict access, Limit usage, or Control content. AI risk review · JEV is a policy option alongside deterministic restrictions and human approval, not an AI authoring assistant. Only types supported by your surface appear. The panel starts with Applies to, the places the policy is created for. When creating a policy for an application in the portal (the entire application, a provider, a managed secret, a group, a user, an agent or a connection; every managed secret of the application is listed, including internal APIs stored with the Custom template), Change returns to choosing targets, starting from the current ones, and asks first if the draft has changed. Organization policies, Wallet and Connect have a fixed target. An existing application policy keeps its home; attach it to more places from its detail dialog (see Apply one rule in several places). Name and purpose follow and are optional. After choosing a type, its definition appears as numbered sections, required ones first. Sections marked Optional stay collapsed until you select Add. Collections are paginated; edit one condition, time window or review question at a time. Guided and JSON edit the same draft. When a new policy can be tried against the application before saving, Try an example appears at the end of the panel. If a value is invalid when you select Review, the builder opens the section that holds it. Closing the panel with unsaved changes asks before discarding them.

Guided and JSON are two views of one draft. JSON contains only the body, not the target, rule type, name, or enabled state. Switching views neither saves nor requires a valid definition. If JSON is incomplete, its exact text stays visible until you repair it; stale guided fields never replace it. Guided collections preserve individual literal values: a comma inside an item is not a separator. Unsupported shapes remain available in JSON rather than being overwritten. Full validation runs before examples, review and saving. Leaving a changed draft asks for confirmation.

An app with no connections can still save a complete policy. Testing an example request is optional and requires a real applicable connection; an empty connection picker does not mean the policy allows access. Editing a disabled rule keeps it disabled.

In Connect, Review does not save or stage anything. Add to connection stages the reviewed personal limit; it takes effect only when the user approves the connection. A failed staging request leaves the draft open for retry or cancel, and connection approval remains blocked while an unsaved draft is open.

Goal: block DELETE for two agents and for every request through one managed secret with one rule instead of three copies, then exempt another agent from the rule.

A rule has one home, the first place you choose, and can be attached to any number of other places in the same application: the entire application, providers, managed secrets, groups, users, agents and connections, in any mix. It can also carry exceptions, agents, users, or groups it does not apply to. A rule applies to a request when one of its attachments matches and none of its exceptions does, and it is evaluated once per request. An agent exception also covers every agent the excepted agent delegates to, even where a more specific attachment matches, since a delegate never has more access than the agent it delegates from. See Where a rule applies.

Dashboard

Open Create a restriction. In the target step, check every place the rule should apply: each level (application, providers, managed secrets, groups, users, agents, connections) has a searchable list, and the first place you check becomes the home. At least one is required. To exempt agents, users, or groups, expand Exclude and check them. Continue, choose the rule type and fill in its definition. The review lists every attachment and every exception before you save. After saving, the rule’s card shows Applies to with each place, and the rule appears in the policy list of every attached provider, managed secret, group, user, agent, and connection.

To change where an existing rule applies, open it and use the Applies to section of its dialog. Adding a place or removing an exception is saved after the usual review. Removing a place or adding an exception loosens the rule, so it asks for confirmation first. The home cannot be removed; delete the rule instead.

CLI

Name the home with a target flag on create and add each other place with --also (app, provider:<provider> such as provider:google, secret:<secret-id>, group:<group-id>, user:<app-user-id>, agent:<agent-id>, or grant:<grant-id>, repeatable). Exempt agents, users, or groups with --exclude agent:<agent-id>, --exclude user:<app-user-id>, or --exclude group:<group-id>:

Terminal window
echo '{"when": {"method": ["DELETE"]}, "effect": "deny"}' | \
alter policy rules create --agent <agent-a-id> --also agent:<agent-b-id> \
--also secret:<secret-id> --body - --name "No deletes"

Attach an existing rule to another place, or add an exception with --exclude. Adding an exception loosens the rule, so it needs --yes:

Terminal window
alter policy rules bind --agent <agent-a-id> --rule <rule-id> --target agent:<agent-c-id>
alter policy rules bind --agent <agent-a-id> --rule <rule-id> --target provider:google
alter policy rules bind --agent <agent-a-id> --rule <rule-id> --target agent:<agent-d-id> --exclude --yes

A provider target takes the provider id you pass to --provider (provider:google) or the provider configuration’s id from alter providers list --output json. Resolving a provider id reads the application’s providers, so a token also needs dashboard_providers:read for that form.

Remove an attachment or an exception with unbind, naming it by its target (add --exclude for an exception) or with --binding and the attachment’s id from the rule’s bindings field. Removing an attachment loosens the rule and needs --yes:

Terminal window
alter policy rules unbind --agent <agent-a-id> --rule <rule-id> --target agent:<agent-c-id> --yes
alter policy rules unbind --agent <agent-a-id> --rule <rule-id> --target agent:<agent-d-id> --exclude

alter policy rules list and alter policy rules get print an APPLIES TO column (for example agent:<agent-a-id>, agent:<agent-b-id>, secret:<secret-id>; not agent:<agent-d-id>, home first, with ids shortened). With --output json, the rule’s bindings field lists each attachment and exception with its id, mode (include or exclude), level and target, and home names the home.

--secret <secret-id> targets a managed secret by the id field of alter managed-secrets list --output json, the same way --provider, --agent and --grant target the other levels. A managed secret rule applies to every request that uses that secret, directly or through a delegated connection, and never to OAuth requests. Each managed secret’s page in the developer portal also has its own Runtime policies section.

Adding an attachment or removing an exception tightens access and needs the permission to create rules (dashboard_app_policy:rules_create for a PAT). Removing an attachment or adding an exception loosens access and needs the permission to delete rules (dashboard_app_policy:rules_delete). Each change bumps the rule’s version, so a stale edit is refused, and each is recorded in the audit log. A place can have at most 100 rules applied to it, counting attachments, and one rule can have at most 100 attachments and exceptions. On a user, the application’s rules and the user’s own limits are counted separately, so each side has its own 100 and neither can use up the other’s. Across all identity-provider groups of one application together, at most 1,000 distinct rules can be applied; a create or attachment past that is refused with a rule_limit error naming the ceiling.

When an attached agent is revoked, an attached provider or managed secret is deleted, or the identity provider deletes an attached group, its attachment (or exception) is removed from the rule and the removal is recorded in the audit log; the rule keeps applying everywhere else. Deprovisioning an attached user removes nothing: the user’s attachments and exclusions stay on the rule and apply again if the user is re-provisioned. Revoking an attached connection leaves the attachment on the rule: a revoked connection can never make another request, so the attachment has no effect until you remove it. When the rule’s home itself is revoked or deleted this way, the rule moves to the most specific place it is still attached to (recorded in the audit log), and is deleted only when it is attached nowhere else.

A developer rule attached to a connection whose home is elsewhere (the application, an agent, or another place) is not the end user’s rule. In the Wallet it appears on that connection read-only, marked as inherited, with the same limited detail as other inherited developer controls; only rules created on the connection itself can be edited there.

Goal: block writes for one end user of the application, or for everyone in an identity-provider group except its lead, whichever connection or agent they use.

A user rule applies to every request one app user makes through the application, including requests through a connection their group holds. A group rule applies to every active member of one identity-provider group of the application, and to no one else; it works with every identity provider whose groups sync to the application. Pick a group over many users where one exists: membership changes then need no rule edits.

Dashboard

Open Create a restriction and choose User or Group in the target step, then search for the user (by email or name) or the group. A group whose identity provider sends names rather than stable ids is marked keyed by display name: renaming it at the provider starts a new group, so re-check its rules after a rename. Expand Exclude to exempt users (for a group rule) or groups (for an application rule). Each user’s and group’s detail page also has a Policies tab that lists and authors the rules on them.

CLI

Name the home with --user <app-user-id> (the id field of alter end-users list --output json) or --group <group-id> (the id field of alter managed-secrets groups list --output json):

Terminal window
echo '{"allowed_methods": ["GET"]}' | \
alter policy rules create --group <group-id> --exclude user:<lead-app-user-id> \
--type restriction --body - --name "Contractors read-only"
echo '{"when": {"method": ["DELETE"]}, "effect": "deny"}' | \
alter policy rules create --user <app-user-id> --body - --name "No deletes"
alter policy rules list --group <group-id>
alter policy simulate --grant <grant-id> --user <app-user-id>

simulate --user evaluates the user’s rules and the rules of every group they belong to. It prints an EFFECTIVE USER line naming the user the simulation ran for (the connection’s own user takes precedence over --user) and a GROUPS CONSIDERED line naming the user’s groups whose rules matched.

Each side edits only its own rules. The application’s rules on a user are shown to that user in the Wallet as Set by the application (for a group rule, Set by the application for the group), with a summary of what they restrict and no controls, so the user can see the limit but cannot change or remove it. The user’s own limits appear in the portal and in alter policy rules list --user (marked end user (wallet)), and the portal, CLI and policy files cannot change or remove them.

A group rule follows the membership the identity provider last reported. A new member is restricted from their first sign-in, because the sign-in that issues their token also records their groups. A removal takes effect when the identity provider reports it: through its group webhooks where it sends them, the next directory import, or the member’s next sign-in. A member removed at the provider who never signs in again keeps the restriction, which is the safe direction. An exclusion of a group from a wider rule is not: it exempts the members, so it is accepted only on an identity provider that revokes memberships in real time (Clerk, Okta or WorkOS with webhooks enabled, as for a group grant) and is removed if those webhooks are force-disabled. Deprovisioning a user keeps the restrictions on them: the rules on that user and the user’s attachments and exclusions stay in place (they apply again if the user is re-provisioned), and new rules, attachments, or exclusions on a deprovisioned user are refused (the portal and the CLI report the user as not found; a policy file is refused at plan time with the reason). When the identity provider deletes a group, its attachments and exceptions are removed from other rules, and a rule set on that group moves to another place it is attached to, or is deleted when it applies nowhere else. Each of these changes is recorded in the audit log.

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. Choose Limit request usage. In Usage limit, enter Maximum requests 1000 and Per calendar period hour. Optionally enter a Name at the top of the panel, then select Review and Save restriction. The new rule card shows the 1000 / hour badge.

CLI

Terminal window
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 Set allowed hours. In Timezone, enter America/New_York. In Time windows, add an item, select mon–fri, and set From 09:00 and Until 17:00. Select Review, then Save restriction.

CLI

Terminal window
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.

Goal: make an application read-only — deny every POST, PUT, PATCH, and DELETE, app-wide.

Dashboard

On the app’s Policies page, click Create a restriction, keep Entire application as the target, and click Continue with this target. Choose Block matching requests. In Conditions, add a request condition for method, choose Match any of several values, and add POST, PUT, PATCH, and DELETE as four separate items. Under Try an example, select Show and review the app-wide OAuth-provider, managed-secret, active-grant, and agent counts. If a representative connection exists, optionally select it and click Test unsaved runtime policy. The trace marks the candidate and is cleared if you edit the draft. Then select Review and Save restriction; a connection is not required to save.

CLI

Terminal window
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 Restrict request origins. In Networks, add one item per address or CIDR range — for example 203.0.113.5 and 10.0.0.0/8. Select Review, then Save restriction.

CLI

Terminal window
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.

Goal: confine a connection to read-only repository access.

Dashboard

In Runtime policies → Add runtime policy, choose Allow only selected requests. Add GET as an item in Methods and /repos/** as an item in Endpoints. Select Review, then Save restriction.

CLI

Terminal window
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 (remove 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.

Outbound email to internal recipients only (deny by parameter)

Section titled “Outbound email to internal recipients only (deny by parameter)”

Goal: allow Gmail send only when every recipient is @example.com.

Dashboard

Open Create a restriction, choose the target, click Continue with this target, then choose Control operations and content. In Operations, choose Browse operation catalog, select Google, and add gmail.users.messages.send from the catalog. Conditions is optional, so select Add on it first; then add an item with Parameter name recipients and Operator not all within; use a list of values and add *@example.com as one item. In Response, keep Deny the request. Select Review, then Save restriction.

CLI

Terminal window
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), the numeric gt/gte/lt/lte, and outside_windows (fires when a scheduled span — a calendar event’s event_time — is not wholly inside the listed time windows; see the next example). 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.

Calendar events only during business hours (deny by schedule)

Section titled “Calendar events only during business hours (deny by schedule)”

Goal: an agent may create or change Google Calendar events only Monday–Friday, 09:00–17:00 New York time, and must get approval before inviting anyone outside example.com.

The outside_windows operator takes the same windows-and-timezone value as a time-window rule, but judges the event’s scheduled span instead of when the request is made. The rule fires unless the whole span sits inside the windows (an event may end exactly at closing time). Back-to-back windows count as one stretch of time: under 00:00–24:00 every day, an event from 23:00 to 01:00 is inside, and so is a Monday-to-Wednesday all-day offsite under weekdays 00:00–24:00. All-day events span whole days in the rule’s timezone, so a daytime window refuses them. A span longer than ten years is outside. Daylight-saving transitions follow the local wall clock: nonexistent times are skipped, and both occurrences of repeated times must remain inside the union of windows.

Name every operation that carries an event’s times: calendar.events.insert, update, patch and import. calendar.events.quickAdd also schedules events, but it takes the time from free text, so it can never be proven inside the window and the schedule rule cannot name it. Deny it with a rule of its own, as below; otherwise an agent can schedule any time through it. calendar.events.move changes an event’s calendar, not its time, so it needs no rule.

Some event writes cannot be proven inside the window, and the rule denies them:

  • a recurring event (recurrence rules repeat it beyond the span the request shows);
  • an update or patch that does not state recurrence, since the event it changes may be recurring. Include "recurrence": [] on an update or patch of a one-off event. A single occurrence of a recurring event has no recurrence, and Google does not document whether it accepts one, so a change to a single occurrence that does not state it is denied too;
  • a patch that names only one of start or end, or neither. Restate both times on the patch.

Dashboard

Open Create a restriction, choose the Google connection as the target, and choose Control operations and content. In Operations, browse the Google catalog and add calendar.events.insert, calendar.events.update, calendar.events.patch, and calendar.events.import. In Conditions, add an item with Parameter name event_time and Operator falls outside the time windows, set Timezone to America/New_York, and add a window for Monday–Friday from 09:00 until 17:00. Keep Deny the request, then Review and Save restriction. Create a second Control operations and content restriction with only calendar.events.quickAdd in Operations, no conditions and Deny the request.

CLI

Terminal window
echo '{
"match": {"operations": ["calendar.events.insert", "calendar.events.update",
"calendar.events.patch", "calendar.events.import"]},
"params": [{"name": "event_time", "op": "outside_windows", "value": {
"windows": [{"days": ["mon", "tue", "wed", "thu", "fri"], "start": "09:00", "end": "17:00"}],
"timezone": "America/New_York"
}}],
"effect": "deny"
}' | alter policy rules create --type content_match --provider google \
--body - --name "Events in business hours"
echo '{"match": {"operations": ["calendar.events.quickAdd"]}, "effect": "deny"}' \
| alter policy rules create --type content_match --provider google \
--body - --name "No free-text event scheduling"
echo '{
"effect": "require_approval",
"when": {
"match": {"operations": ["calendar.events.insert", "calendar.events.update",
"calendar.events.patch", "calendar.events.import"]},
"params": [{"name": "recipients", "op": "not_subset_of", "value": ["*@example.com"]}]
},
"approval": {"approvers": ["lead@example.com"]}
}' | alter policy rules create --type require_approval --provider google \
--body - --name "Approve outside invitees"

Calendar event writes project recipients and event_time (the event’s span). recipients means who the request may email or invite. It uses the same name as Gmail’s, and every Google operation in the send family carries it: event writes (their attendees), deletes, moves and quick adds (whom they email), and sharing rules (the grantee). So a send-family rule on recipients covers email recipients, calendar invitees and calendar sharing (see Google policy parameters).

An event with no attendees invites nobody and passes a not_subset_of condition. So does a delete, a move, or a patch that leaves the guests unchanged, sent with sendUpdates=none and no sendNotifications other than false. An update or patch emails the guests it removes and the guests it keeps, and a delete or move emails the event’s current guests. The request lists none of these, so those writes can be judged only when sent with sendUpdates=none and no sendNotifications other than false. A quick add can never be judged: Google reads its guests from free text. Nor can an out-of-office or focus-time event that auto-declines (its declines go to the organizers of the meetings it overlaps), or an update or patch that sets any attendee’s response (it can notify the organizer). An update or patch that names neither start nor end keeps the stored time, so its event_time cannot be judged either: a schedule deny rule denies it and a schedule approval rule sends it to an approver.

Which calendar an agent writes to is a separate control from whom it invites. Every Calendar event operation carries calendarId, the calendar its path names, and a move also carries destination.

Name these operations in a calendarId rule:

  • calendar.events.insert, import and quickAdd, which create an event in that calendar;
  • update, patch and delete, which change an event that lives there.

Do not put a move in that rule. A move’s calendarId is the calendar it moves the event out of, so the rule would let a move from primary to an outside calendar pass and block a move back into primary. Govern moves with their own destination rule. The user’s address names their primary calendar just as primary does, and both compare case-insensitively.

These rules limit which calendars an agent writes events into or moves them to. They do not cover a calendar invited as an attendee, such as a group calendar (whose id ends in group.calendar.google.com) or a resource calendar that accepts invitations automatically. That attendee is a recipient, so keep the outside-invitee rule above alongside them:

Terminal window
echo '{
"match": {"operations": ["calendar.events.insert", "calendar.events.import",
"calendar.events.quickAdd", "calendar.events.update", "calendar.events.patch",
"calendar.events.delete"]},
"params": [{"name": "calendarId", "op": "not_subset_of", "value": ["primary", "alice@example.com"]}],
"effect": "deny"
}' | alter policy rules create --type content_match --provider google \
--body - --name "Only the user's calendars"
echo '{
"match": {"operations": ["calendar.events.move"]},
"params": [{"name": "destination", "op": "not_subset_of", "value": ["primary", "alice@example.com"]}],
"effect": "deny"
}' | alter policy rules create --type content_match --provider google \
--body - --name "Moves stay in the user's calendars"

What happens to a request a condition cannot judge depends on the rule. A deny rule such as the schedule rule denies it. A conditional approval such as the outside-invitee rule sends it to an approver. The approval page says which condition was not evaluated and why, and shows the values the request carries as it sent them (percent-encoding decoded).

  • For an update without sendUpdates=none, the page lists the guests the request names and the sendUpdates and sendNotifications values. It also says the event’s current guests, who may be emailed, are not in the request.
  • For a recurring event, it shows start, end and their time zones, and the recurrence rules.

Each listed address is checked against the rule’s addresses, and the result is shown next to it, and any address with a non-ASCII character is marked. Against an allowlist such as this rule’s, a look-alike domain shows as outside it rather than being left to the eye; a blocklist (any_in) does not catch look-alikes. If the values in the request cannot be shown, the request cannot be approved. A condition on a parameter the operation does not carry at all still denies.

To simulate the schedule rule, pass event_time as {"start": "2026-10-05T18:00:00-04:00", "end": "2026-10-05T19:00:00-04:00"} in the simulated parameters.

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.

Swap the effect to remove fields before the call is forwarded. A redacted field is removed outright — no placeholder text is written in its place — so the example below sends the message with no subject line. For Gmail, fields that decide who receives a message (the recipients and sender) cannot be redacted, only its content (such as the subject); use a deny rule for those. The call is refused if the removal cannot be provably applied:

Terminal window
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"
Terminal window
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.

Terminal window
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 choose Require human approval on the same Add runtime policy form. The stored rule type remains require_approval; the detail viewer also calls this human in the loop (HITL).

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:

Terminal window
# Only payments over 1,000 need sign-off
echo '{
"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.

An ai_review rule can add a human approval step to an otherwise permitted, classified proxy call. It never runs on reads and never weakens deterministic rules. A timeout or invalid vendor answer leaves the deterministic decision in place, with a skip reason in the request audit.

Before authoring a rule, an organization admin must turn on AI request review consent in Settings → Runtime policies. The plan must include AI review. The platform switch and mode must also be enabled by the operator after the published sub-processor notice period. Consent can be withdrawn from the same page; new requests then stop going to TypeSafe.

Terminal window
echo '{
"effect": "require_approval",
"review": {"threshold_level": 3, "families": ["payment", "delete"],
"triggers": [{"id": "large_refund", "label": "Large refund",
"question": "Is this refund unusually large for the account?"}]},
"approval": {"approvers": ["lead@example.com"]}
}' | alter policy rules create --type ai_review --body - --name "Review risky calls"

Threshold levels are 1–4; tail_mass and confidence_floor optionally tune the probability cutoff (both 0–1). At most eight triggers may be added. Each trigger has a unique lowercase ID, a question of 10–500 characters, an optional label shown to approvers, and an optional probability threshold in (0, 1]. Choose AI risk review · JEV in the dashboard to edit the same fields. The stored type remains ai_review; this runtime reviewer is separate from assisted policy drafting.

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).

Dry-run a request against the live rules without making a real call:

Terminal window
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’s Explain a request shows the same evidence: a block, required approval, fields to redact, recent sign-in, unsimulated AI review, or incomplete request context. These are policy checks, not authorization to execute. Missing API-key identity, client IP, or endpoint is identified instead of being presented as a reassuring no-match. Supply API-key identity with the CLI’s --api-key-id or the dashboard’s API-key selector, using the key ID, never its secret. No request is executed, approval created, quota consumed, or model called.

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:

Terminal window
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.json

One 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.

Terminal window
alter policy show-app # app-level rules at a glance
alter policy rules list --agent <agent-id> # list rules at a level
alter policy rules list --group <group-id> # list the rules on one identity-provider group
alter policy rules update --rule <rule-id> --disable # toggle without deleting
alter policy rules delete --rule <rule-id> # remove
alter policy rules bind --rule <rule-id> --target agent:<agent-id> # also apply an app-level rule to an agent
alter policy rules unbind --rule <rule-id> --target agent:<agent-id> --yes # stop applying it there

Report an issue with this page

Necessary

Required for sign-in, security, authorization, and remembering your choices.

Always active

Analytics

Helps us understand which product and documentation features are useful.

Performance diagnostics

Uses performance tracing and privacy-masked session replay to diagnose problems.

You can change these choices at any time from Cookie settings.