Reference
alter policy
Manage policy rules and read the app-level policy posture from the command line.
Manage policy rules — rules that restrict or gate what requests are allowed (deny a request, rate-limit it, or hold it for human approval) — at the app, provider, managed secret, group, user, agent, or grant level, apply one rule at several of those places, and summarize an app’s app-level rules at a glance. App-level policy is rules-based: IP and time-of-day restrictions are ordinary policy rules (the ip_allowlist and time_window rule types below), per-agent rate limits are the quota rule type, operation- and parameter-level content rules (deny, redact, or step-up a specific provider operation by its parameters) are the content_match rule type, and “require human approval” is the require_approval rule type, all managed with the same commands as every other rule.
alter policy show-appalter policy simulatealter policy validatealter policy exportalter policy testalter policy planalter policy show-planalter policy applyalter policy rules createalter policy rules listalter policy rules getalter policy rules updatealter policy rules deletealter policy rules bindalter policy rules unbindCommands are app-scoped. Interactive rule commands and show-plan accept --app <id> (or the app’s name), a linked workspace, or ALTER_APP_ID. File commands take the app UUID from bindings. apply takes it exclusively from the saved plan and has no --app flag.
Policy files
Section titled “Policy files”The policy-as-code guide contains complete manifest, binding and before/after test examples, ownership rules and protected CI guidance.
alter policy validate --manifest @policy.json --bindings @bindings.jsonalter policy export --manifest @export-targets.json --bindings @bindings.json --adopt-existingalter policy test --manifest @policy.json --bindings @bindings.json --tests @tests.jsonalter policy plan --manifest @policy.json --bindings @bindings.json --tests @tests.json --save reviewed-plan.jsonalter policy show-plan --app <app-id> --plan-id <plan-id>alter policy apply --plan @reviewed-plan.json --yesFile operations require dashboard_app_policy:read. plan also requires
dashboard_app_policy:rules_create, because it saves a reviewed artifact that a
later apply executes. apply additionally checks the saved plan’s current
mutation permissions, even on a replay. For
validate/export/test/plan, optional --app must match the bindings.
Source flags accept @file or - (only one stdin input). --save
creates a private new file and refuses overwrite. --adopt-existing is opt-in;
export itself never adopts or applies. Apply prompts unless --yes is supplied.
All six file commands accept --output json|jsonl|table (default json).
plan emits the exact plan_id field for show-plan --plan-id; the saved file
also contains app_id and digest required by apply. If saving fails, the new
plan remains on stdout and stderr names its recovery command; the existing file
is untouched. test exits 1 for failed fixtures or uncovered changes and 0
for passing evidence. Human review on stderr shows each fixture’s pass/fail and
uncovered rule keys; it does not attest live authorization.
For each refusal in the table below, a refused apply names its code on
stderr, followed by the remedy, and does not print the server’s own error text.
plan names policy_plan_busy the same way. For policy_plan_stale the CLI
also prints the server’s reason, for example
(reason: preserved_layers_changed). apply compares the saved digest with the
server plan itself, so policy_plan_digest_mismatch is reported before any
apply is sent. Nothing is saved or applied in any of these cases:
| Code | Exit | Meaning and remedy |
|---|---|---|
policy_plan_stale | 5 | Policy state changed after the plan was reviewed. The reason is bound_rules_changed, preserved_layers_changed (a personal rule on a bound target) or ownership_changed. Run plan again and review the new plan. |
policy_plan_conflict | 5 | Policy state changed while applying. Run plan again and review the new plan. |
policy_plan_busy | 75 | Another policy change held a lock. Retry the same command, plan or apply, unchanged, with backoff. A retried plan is safe because the refused one saved nothing, and --save writes the file only after a success. A retried apply is safe because a plan that already committed returns its committed result; after a busy apply, do not re-plan or edit the saved file. |
policy_plan_digest_mismatch | 5 | The plan file was modified, or is not the file plan --save wrote. Apply the unmodified saved file, or run plan again and review the new plan. |
policy_plan_expired | 1 | The plan expired. Run plan again and review the new plan. |
policy_manager_changed | 5 | The plan’s policy manager changed while applying. Run plan again and review the new plan. |
policy_tests_failed | 5 | The plan has no passing fixture results. Fix the manifest or tests, then run plan again. |
Reading app policy
Section titled “Reading app policy”alter policy show-appalter policy show-app --app <app-id-or-name> --output jsonSummarizes the app-level policy rules — the rules that apply to every request in the app — as a table of id, type, enabled, and name (--output json emits the full rows). It is the read-only posture view of the same rules alter policy rules list manages; needs dashboard_app_policy:read.
Rules that apply across an entire organization are dashboard-only by design — like org-wide key policy (where even reading is dashboard-only), a single scripted call should never see or weaken org-wide security, so there is deliberately no CLI surface for them.
Simulating a request
Section titled “Simulating a request”# "If a request used this grant, what gates it?" — verdict + per-rule tracealter policy simulate --grant <grant-id>
# Method + endpoint, so method/endpoint restriction rules are evaluatedalter policy simulate --grant <grant-id> --method GET --endpoint /repos/octo/hello
# As a specific agent, from a specific IP, at a specific timealter policy simulate --grant <grant-id> --agent <agent-id> \ --client-ip 203.0.113.7 --at 2026-05-21T09:00:00Z
# As one app user, so that user's rules and their groups' rules are explainedalter policy simulate --grant <grant-id> --user <app-user-id>
# Match key-specific conditions with an ID from this app, never the secretalter policy simulate --grant <grant-id> --api-key-id <key-id> --method POST
# Content rules: name the operation explicitly and supply a simulated parameter bagalter policy simulate --grant <grant-id> --operation gmail.users.messages.send \ --params '{"recipients": ["bob@example.org"]}'
# Full composed verdict (effect + deny attribution + per-rule trace) as JSONalter policy simulate --grant <grant-id> --endpoint /repos/octo/hello --output json
# Test an app-wide rule before creating it (full create object, never stored)cat > candidate-rule.json <<'JSON'{"rule_type":"json_match","rule_body":{"when":{"method":"POST"},"effect":"deny"},"name":"Block POST"}JSONalter policy simulate --grant <grant-id> --method POST \ --candidate @candidate-rule.jsonDry-runs the app’s custom policy rules for one request shape. The headline distinguishes a block, human approval, redaction, recent sign-in, unevaluated runtime review, and incomplete evidence. “No policy block found” is not authorization: ownership, key/agent scopes, grant validity, and runtime conditions can still refuse the call. The per-rule trace retains each independent outcome; approval never overrides a block. The JSON effect remains the deterministic chain’s permit/deny, not a guarantee of execution. An AI rule skipped for a dry run has evidence_status: "runtime_review"; its canonical outcome: "pass" means the dry-run evaluator added no block, not that live AI review passed. No request is executed, quota consumed, approval created, or AI model contacted. It needs dashboard_app_policy:read (the same permission as the read commands).
Rules needing unsupplied API-key identity, client IP, or endpoint have evidence_status: "context_required" and context_gaps identifying the missing inputs. Supply --api-key-id, --client-ip, or --endpoint where prompted. A key ID must belong to the selected app; it supplies policy context only, without checking credential validity, scopes, or authentication. Missing input is not flagged when another known condition already proves the rule cannot apply, or a known method restriction already proves denial. The canonical JSON effect and six-value outcome contract are preserved for existing clients; evidence annotations are additive. Do not treat the supplied-context result as a prediction for missing context.
When run with a PAT (or otherwise outside an authenticated operator session), the simulation excludes organization-level policy definitions from the verdict — org rules are dashboard-only — and the command prints a warning to that effect on stderr. Use the operator dashboard’s Explain a request for a verdict that includes org rules.
It mirrors the dashboard’s policy explorer inputs: --grant (required) is the credential the simulated request uses; --method defaults to GET and accepts 1–16 letters (normalized to uppercase); --endpoint is a 1–512-character provider API path, required to evaluate method/endpoint restriction rules; --agent; --user is the app user the request runs as (the id field of alter end-users list --output json; it defaults to the grant’s own user), so that user’s rules and the rules of every group they are an active member of are evaluated; --client-ip is a bare IPv4/IPv6 address, not a CIDR, for ip_allowlist rules; --at is an ISO 8601 instant for time_window rules; --operation is a non-empty operation id of at most 255 characters for content_match rules (omit it to classify from --endpoint, exactly like the live gate); and --params is the simulated parameter bag for content_match rules, as a JSON object of up to 20 entries and 8,192 UTF-8 bytes when compact JSON-encoded. Arrays and scalars are rejected locally; values are used only for the dry-run and never stored. --candidate @file.json (or --candidate - for stdin) accepts one complete app-level create object — rule_type, rule_body, and optional name, description, and enabled — validates it locally, overlays it on the existing chain for this request only, and marks it candidate in the trace. Setting enabled to false is accepted but the rule is not overlaid and gets no trace row, because a disabled rule is never enforced once saved either; the command says so on stderr. It never calls the create route. The table form prints the verdict summary and per-rule trace; --output json emits the full composed object. The trace’s VIA column names the place each rule reached the request through ([home] marks the rule’s home); a rule an exclusion carved out of this request shows RESULT excluded and VIA names the exclusion (not agent:…, not user:…, or not group:…), and took no part in the verdict. In the JSON form these are each row’s matched_bindings and excluded_by. With --user, the table form prints an EFFECTIVE USER line on stderr naming the user the simulation ran for: the grant’s own user, else its consenting user, else --user, flagged when it is not the --user you passed; in the JSON form this is the verdict’s effective_app_user_id field. The table form also prints a GROUPS CONSIDERED line on stderr naming the effective user’s groups whose rules matched the request (none when no group rule matched); in the JSON form this is the verdict’s groups_considered field.
When a content_match classification ran, the verdict also names the operation the request classified to (or warns that the request is unclassified — content rules targeting the provider deny unclassified traffic), exactly as the dashboard’s explorer shows it; in the JSON form this is the classified_operation / classification_reason pair.
The dry-run covers the custom policy-rule chain, including rules that emit approval/redaction/sign-in obligations, and provider scope-narrowing for delegated or narrowed grants. It does not execute those obligations or apply the grant-validity floor. When the selected grant is terminal or a delegated chain is resolved structure-only, the command prints a “grant liveness not verified” caveat: the grant or an ancestor could deny the live request.
Policy rules
Section titled “Policy rules”A policy rule is evaluated on every request, on top of a default-deny base: a request already needs ownership of the grant, the right scope, and a valid grant before any rule runs. When a rule’s conditions match, it either denies the request or holds it for human approval (require_approval). Rules can only restrict that base — creating one never grants or widens access.
Targeting
Section titled “Targeting”Each rule is created at one home target, picked by a flag (the flags are mutually exclusive):
| Flags | Rule applies to |
|---|---|
| (none) | every request in the app |
--provider <provider> | every grant on one OAuth provider (e.g. --provider google) |
--secret <secret-id> | every request that uses one managed secret, directly or through a delegated grant |
--group <group-id> | every request an active member of one identity-provider group makes through the app, whichever grant or agent they use |
--user <app-user-id> | every request one app user makes through the app, including through a grant their group holds |
--agent <agent-id> | one agent only |
--grant <grant-id> | one grant (credential) only |
The target flag is part of the rule’s address: get, update, delete, bind, and unbind must repeat the flag for the rule’s home (or for any other place the rule applies) — a rule created with --agent is only found with the same --agent <agent-id>, and without it the command reports the rule as not found. Provider rules cover OAuth providers only; a managed-secret credential is covered by --secret (every grant of that secret) or --grant (one grant). Read a managed secret’s id from the id field of alter managed-secrets list --output json, an app user’s from the id field of alter end-users list --output json, and a group’s from the id field of alter managed-secrets groups list --output json.
One rule can also apply at more places in the same app (its bindings), and can exclude agents, users, and groups that would otherwise fall under it: pass --also and --exclude to create, or use bind and unbind on an existing rule. See Where a rule applies.
Each side edits only its own rules. The CLI, the dashboard, and policy files create and change developer rules; the limits an end user sets on their own account from the Wallet stay theirs. list --user <app-user-id> shows both (the table’s OWNER column reads end user (wallet) for the user’s own; in JSON, created_by_kind is app_user), but update, delete, bind, and unbind refuse the user’s own limits before any change (exit 4, and the server answers 404 as for a rule it does not show). The user sees the application’s rules in the Wallet as Set by the application, and cannot change them.
A group rule follows the membership the identity provider last reported. Memberships are refreshed at each sign-in, by the provider’s group webhooks where it sends them, and by directory import; a member removed from the group at the provider who never signs in again keeps the restriction until one of those runs. That over-restricts, which is the safe direction.
Permissions
Section titled “Permissions”Token permissions are split by what the operation can do:
| Command | Needs | Notes |
|---|---|---|
rules list / rules get | dashboard_app_policy:read | |
rules create | dashboard_app_policy:rules_create | Creating a rule only ever restricts access — a routine automation permission |
rules update | dashboard_app_policy:read and dashboard_app_policy:rules_update | Read is needed for the before/after review. Disabling a rule loosens enforcement, so the update permission is never granted by a wildcard — select it explicitly when creating the token |
rules delete | dashboard_app_policy:read and dashboard_app_policy:rules_delete | Read is needed for the before/after review. Removing a deny rule loosens enforcement — delete is also never granted by a wildcard |
rules bind | dashboard_app_policy:read and dashboard_app_policy:rules_create | Applying a rule at one more place only narrows access |
rules bind --exclude | dashboard_app_policy:read and dashboard_app_policy:rules_delete | Excluding an agent, user, or group loosens the rule |
rules unbind | dashboard_app_policy:read and dashboard_app_policy:rules_delete | Removing a place loosens the rule |
rules unbind --exclude | dashboard_app_policy:read and dashboard_app_policy:rules_create | Removing an exclusion narrows the rule |
rules create needs only dashboard_app_policy:rules_create, even with --exclude: a new rule cannot loosen any access that exists today, so its exclusions only limit how far the new restriction reaches. Adding an exclusion to a rule that already exists does loosen it, which is why rules bind --exclude needs rules_delete.
Create
Section titled “Create”Every change prints a review to stderr; JSON results stay on stdout. It starts with What this change does: one line per change and, where the rule’s meaning settles it, a rating (More restrictive, Less restrictive, Mixed, or Impact uncertain), plus a warning for any less-restrictive change. The complete Before / After definitions follow. Creating a rule only narrows access, so it proceeds without a prompt; updates and deletes ask for confirmation (see below). Creating a disabled rule does not enforce it.
# App-wide, body from stdin (json_match is the default --type)echo '{"when": {"method": ["POST", "DELETE"]}, "effect": "deny"}' | \ alter policy rules create --body - --name "Read-only app"
# Agent-scoped, body from a file; create disabled and enable lateralter policy rules create --agent <agent-id> --body @rule.json --disabled
# Provider-scopedecho '{"when": {"method": "DELETE"}, "effect": "deny"}' | \ alter policy rules create --provider google --body -
# Managed-secret-scoped: every request that uses this secretecho '{"allowed_methods": ["GET"]}' | \ alter policy rules create --secret <secret-id> --type restriction --body - --name "Read-only secret"
# One rule for two agents and a managed secret, homed at the first agentecho '{"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"
# A Google provider rule that also covers one managed secretecho '{"when": {"method": ["DELETE"]}, "effect": "deny"}' | \ alter policy rules create --provider google --also secret:<secret-id> --body -
# App-wide, except for one maintenance agentecho '{"when": {"method": ["DELETE"]}, "effect": "deny"}' | \ alter policy rules create --exclude agent:<maintenance-agent-id> --body - --name "No deletes"
# One identity-provider group, except its leadecho '{"allowed_methods": ["GET"]}' | \ alter policy rules create --group <group-id> --exclude user:<lead-app-user-id> \ --type restriction --body - --name "Contractors read-only"
# One rule on three app users, homed at the firstecho '{"when": {"method": ["DELETE"]}, "effect": "deny"}' | \ alter policy rules create --user <app-user-a-id> --also user:<app-user-b-id> \ --also user:<app-user-c-id> --body - --name "No deletes"
# App-wide, except for everyone in one groupecho '{"when": {"method": ["DELETE"]}, "effect": "deny"}' | \ alter policy rules create --exclude group:<group-id> --body - --name "No deletes"
# IP allowlist: deny every request NOT from these addresses / rangesecho '{"allow": ["203.0.113.5", "10.0.0.0/8"]}' | \ alter policy rules create --type ip_allowlist --body - --name "Office egress only"
# Time window: deny requests outside business hours in the given timezoneecho '{"windows": [{"days": ["mon","tue","wed","thu","fri"], "start": "09:00", "end": "17:00"}], "timezone": "America/New_York"}' | \ alter policy rules create --type time_window --body - --name "Business hours"
# Human in the loop (HITL): hold matching requests for human approval (does not deny)echo '{"effect": "require_approval", "when": {"method": ["POST", "DELETE"]}, "approval": {"approvers": ["lead@example.com"], "channels": ["email"]}}' | \ alter policy rules create --type require_approval --body - --name "Approve writes"
# Parameter-conditional approval: 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"
# Restriction: method/endpoint allowlist — deny anything NOT on it (read-only repos)echo '{"allowed_methods": ["GET"], "allowed_endpoints": ["/repos/**"]}' | \ alter policy rules create --type restriction --body - --name "Read-only repos"
# Quota: at most N calls per fixed window (per agent) — deny with a retry-after over the limitecho '{"limit": 1000, "period": "hour"}' | \ alter policy rules create --type quota --body - --name "1000/hour per agent"
# Content match: deny (or redact / step-up) specific provider operations by their parametersecho '{"match": {"families": ["send"]}, "params": [{"name": "recipients", "op": "not_subset_of", "value": ["*@example.com"]}], "effect": "deny"}' | \ alter policy rules create --type content_match --body - --name "Internal recipients only"--also <ref> applies the rule at one more place and --exclude <ref> excludes one agent, user, or group from it; both are repeatable. A <ref> is app, provider:<provider>, secret:<secret-id>, group:<group-id>, user:<app-user-id>, agent:<agent-id>, or grant:<grant-id>. A provider ref takes the same provider id as --provider (provider:google) or the provider configuration’s UUID (the id field of alter providers list --output json). The CLI resolves a provider id to its configuration by reading the app’s providers, which needs dashboard_providers:read; an id not configured for the app is refused (exit 4) before any change. The UUID form needs no extra permission. The home and every --also and --exclude ref must be distinct, at most 100 in total. Only agents, users, and groups can be excluded, and an exclusion must fall under a broader place the rule applies to: an excluded group under an app, provider, or managed secret; an excluded user under one of those or a group; an excluded agent under any of those or a user. The CLI checks all of this before it creates anything; the only call it makes first is the read of the app’s providers that resolves a provider id. Excluding a group also needs the same identity provider a group-bound grant needs (membership changes reported in real time, with webhooks enabled); otherwise the server refuses the create with group_exclusion_unsupported and the reason, and nothing is created.
--body takes @<file> or - for stdin (the same intake convention as every other JSON-accepting alter flag). --type picks the rule’s evaluator and the body shape that goes with it (default json_match):
--type | Body shape (generated from the policy-language spec) | Effect on matching requests |
|---|---|---|
ai_review | {"effect": "require_approval", "review": {"threshold_level": <int 1-4>, "tail_mass"?: <value>, "confidence_floor"?: <value>, "families"?: ["write"/"send"/"delete"/"admin"/"payment", ...], "triggers"?: [{...}, ...]}, "approval": {...}} | Ask an AI model to rate how risky a provider call is and require human approval above a threshold. Never denies, never runs on reads. |
content_match | {"match": {"operations"?: ["<string>", ...], "families"?: ["admin"/"delete"/"payment"/"read"/"send"/"write", ...]} (at least one), "params"?: [{...}, ...], "effect": "deny"/"redact"/"step_up", "redact"?: {"fields": ["<string>", ...]}, "step_up"?: {"max_session_age_seconds": <int 1-86,400>}} | Bind attested catalog operations/families and (optionally) their projected parameters; on a match, deny the request, redact fields from it, or require a fresh (step-up) user session. |
ip_allowlist | {"allow": ["<string>", ...]} | Restrict the request’s source IP to an allowlist of addresses / CIDR ranges (narrowing; requests from any other IP are denied). |
json_match (default) | {"when": {...}, "effect": "deny"} | Deny the request when a flat set of request-metadata conditions all match (string equality or list membership, ANDed). |
quota | {"limit": <int 1-1,000,000>, "period": "minute"/"hour"/"day"/"month"} | Admit at most ‘limit’ requests per fixed UTC calendar window per caller principal; beyond the limit the request is denied with 429 + Retry-After. |
require_approval | {"effect": "require_approval", "when"?: {...}, "approval": {"approvers"?: ["<string>", ...], "expires_in_seconds"?: <int 60-86,400>, "channels"?: ["email", ...], "step_up_max_session_age_seconds"?: <value>, "max_pending"?: <value>, "result_retention_seconds"?: <value>}} | Require human approval before the request executes (a 202 + approval flow), always or only when an optional condition matches. |
restriction | {"allowed_methods"?: ["DELETE"/"GET"/"HEAD"/"OPTIONS"/"PATCH"/"POST"/…, ...], "allowed_endpoints"?: ["<string>", ...]} (at least one) | Allowlist the HTTP methods and/or provider endpoint paths the target’s traffic may use; everything outside the allowlist is denied. |
time_window | {"windows": [{...}, ...], "timezone": "<string>"} | Deny the request when it falls outside the UNION of the configured day/time windows, evaluated in the rule’s IANA timezone. |
The CLI validates ai_review bodies locally: the threshold is an integer from
1 to 4; probabilities are bounded; eligible families exclude reads; triggers
have unique lowercase IDs, bounded questions and labels, and valid probability
thresholds. The rule also requires a valid human-approval block. See the
AI review setup and example.
For json_match, when is a map of request attribute → expected value or list of values; ALL conditions must match for the rule to fire, and effect is always "deny". The full matchable-attribute list is in the generated Authoring limits block below. Beyond plain metadata, operation and family match the request’s classified catalog operation (by id, or by semantic family — send, read, write, delete, admin, payment) and also fire on requests that cannot be classified (fail closed); client_ip is exact-IP only (CIDR ranges belong in an ip_allowlist rule); a method condition also gates raw-token retrievals, which confer every method. Every body is validated locally before any network call. Optional: --name, --description, --disabled.
For require_approval, the matching request is held for a human to approve rather than denied. The when map is OPTIONAL (omit it to require approval on every request at the target); it accepts the same attribute matcher as json_match plus the content_match vocabulary — a match object (operations and/or families) and a params condition list — so an approval can be operation- and parameter-conditional: {"effect": "require_approval", "when": {"match": {"families": ["payment"]}, "params": [{"name": "amount", "op": "gt", "value": 1000}]}, "approval": {...}} requires sign-off only for payments over 1,000. An operation-scoped condition on a request that cannot be classified is denied (never approved around). The approval object accepts: approvers (up to 10 approver emails — empty/omitted means the connection’s own owner approves: the end user who connected the account, including when an agent calls through a grant that user delegated to it. A connection no end user connected has no owner, so a gated call on it is refused with approval_config_error until the rule names an approver; a rule targeting a managed-secret grant must name at least one explicit approver, because a managed secret has no owner to default to — an approver-less save on one is rejected with a structured 400 naming the fix, rather than denying every gated call at runtime), expires_in_seconds (60–86400, default 600), channels (["email"], or [] for a single-gate approval that sends no email and surfaces only the approval link), and the advanced overrides step_up_max_session_age_seconds (-1 disables step-up, 0 forces a fresh sign-in for every decision, up to 3600), max_pending (1–100), and result_retention_seconds (3600–31,536,000 — one hour to one year). Multi-gate approvals require email delivery for every gate so each required approver receives their own link. Authorable at the app / provider / managed secret / group / user / agent / grant level here; an end user’s own approval rules on their account are authored by that user in the Wallet.
For restriction, a request is denied unless its HTTP 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. Each endpoint is either an exact path (/user) or a trailing /** prefix wildcard that matches that prefix and anything below it (/repos/** matches /repos, /repos/, and /repos/octo/hello). Exact entries are byte-exact including the trailing slash — /user does not admit /user/ (a distinct request at the provider); list both forms, or use /user/**, when both should pass. Requests spelled as non-canonical aliases — duplicate slashes (/user//), . segments, backslashes, or percent-encoded separators (%2F) — are denied outright under an endpoint allowlist, and patterns spelled that way are rejected at authoring: their provider-side resolution is ambiguous, so no exact rule could be held to what it names. A trailing /** is the ONLY wildcard form — any other * (mid-path like /repos/*/issues, or a single-segment /repos/*) is rejected (a 400 invalid_rule), not treated as a literal or a wildcard. Methods must be one of GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. This is the same method/endpoint allowlist a grant can carry; authoring it as a rule lets you apply it at the app / provider / managed secret / group / user / agent / grant level (e.g. an app-wide “GET only”). Methods are upper-cased; the backend rejects a path containing a .. traversal segment.
For time_window, the request is denied unless its local time (in the required timezone) falls inside one of the windows. Each window is a non-empty set of days (mon…sun, unique) plus a start/end time ("HH:MM", 24-hour). The window is [start, end) — start inclusive, end exclusive; end may be "24:00" for end-of-day. A start later than end is a valid overnight window anchored to the named day (e.g. fri 22:00 → sat 06:00). Up to 20 windows; timezone is required (no implicit default).
For quota, a request is admitted only while fewer than limit requests have been made in the current fixed window (top of the minute/hour, UTC midnight, or the first of the month), counted per caller principal (the agent when the request runs as one, otherwise the connection). Over the limit, the call is denied with a retry-after until the window resets — this is the only rule type that denies with a rate-limit response rather than a plain policy denial. Not usable as a per-request rule.
For content_match, the rule binds specific provider operations rather than raw paths: match names catalog operation ids ("operations", e.g. gmail.users.messages.send) and/or operation families ("families" — one of send, read, write, delete, admin, payment), and at least one of the two must be present. The optional params list adds parameter conditions that must ALL hold for the rule to fire; each condition is exactly {"name", "op", "value"}, where op is one of equals (scalar equality, case-insensitive for strings), any_in (fires when any projected value matches one of the listed patterns), not_subset_of (fires when the projected values are NOT fully covered by the listed patterns — the recipient-allowlist shape), the numeric comparisons gt/gte/lt/lte, or outside_windows (fires when a time-range parameter such as a calendar event’s event_time is not wholly inside the windows, back-to-back occurrences counting as one stretch; its value is a time-window body, {"windows": [{"days": [...], "start": "HH:MM", "end": "HH:MM"}], "timezone": "<IANA zone>"}, validated locally like --type time_window). A condition’s name references a parameter from the operation’s catalog entry or any query-string parameter the live request carries (evaluated identically whether the caller embeds it in the URL or passes it through the SDK’s separate query-parameters argument); the numeric operators require a cataloged numeric parameter — an ad-hoc query value projects as a string and a numeric condition on it denies. List values for any_in/not_subset_of support per-entry glob patterns (*@example.com); numeric ops take a number, equals a string or number. 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. 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) — deliberate anti-bypass semantics, so a condition cannot be dodged by a case or padding variant; 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. effect picks what happens on a match: "deny" blocks the request; "redact" lets it through but removes the named redactable fields from the outbound request body before forwarding — nothing is written in their place, so a redacted email subject is sent as no subject at all (requires the redact object); "step_up" requires the end user to have re-authenticated within max_session_age_seconds (1–86400, requires the step_up object). The redact/step_up objects are only valid with their matching effect. Content rules fail closed: a request that cannot be classified as a known operation (or whose parameters cannot be read) is denied while a content rule targets it — it is never let through, and never converted into a redaction or step-up for a request that can’t be named. Ambiguous query spellings fail closed: an uncataloged query key repeated with distinct values, or a query key shadowing a differently-sourced cataloged parameter, denies any condition naming that parameter rather than guessing which value the provider would honor — conditions on other parameters are unaffected. A cataloged scalar query parameter repeated with distinct values cannot be projected at all, so every parameter condition on that operation denies.
For ip_allowlist, each entry is a bare IPv4/IPv6 address or a CIDR range, and a request is denied unless its source IP matches one of them. A /0 catch-all entry — any base with a /0 prefix, including zero-padded forms like /00 or /000 — is rejected locally (exit code 2, before any network call): a rule that matches every source IP is an allow-all, not a restriction, so use a concrete address or a narrower prefix. A split-range set whose entries together cover the whole address space (for example 0.0.0.0/1 + 128.0.0.0/1, or ::/1 + 8000::/1) is rejected the same way, even though no single entry is a /0 — as is an entry covering the entire IPv4-mapped-IPv6 block (::ffff:0.0.0.0/96), which would admit every IPv4 source in mapped form on a dual-stack listener. Each entry’s IP/CIDR shape is validated locally too, so a malformed entry fails fast rather than at the backend.
Authoring limits (generated from the policy-language spec — the same source the policy engine enforces):
| Rule type | Limits |
|---|---|
ai_review | max trigger label chars: 60 · max trigger question chars: 500 · max triggers: 8 · risk levels: 5 |
content_match | max match entries: 100 · max operation id len: 255 · max param conditions: 20 · max param name len: 120 · max redact field len: 120 · max redact fields: 50 · max value entries: 200 · max value len: 512 · step up max seconds: 86,400 · step up min seconds: 1 |
ip_allowlist | max allow entries: 100 · max entry len: 49 |
json_match | max when list values: 100 · max when value len: 512 |
quota | max limit: 1,000,000 |
require_approval | default expiry seconds: 600 · max approvers: 10 · max expiry seconds: 86,400 · max pending max: 100 · max pending min: 1 · min expiry seconds: 60 · result retention max seconds: 31,536,000 · result retention min seconds: 3,600 · step up max seconds: 3,600 · step up min seconds: -1 |
restriction | max endpoint pattern len: 512 · max endpoints: 50 · max methods: 7 |
time_window | max windows: 20 |
Every serialized rule body is at most 16,384 bytes; --name is at most 120 characters and --description 2,000. Each target holds at most 100 rules authored through this command; a target may additionally carry the rules DERIVED from its settings block (an agent’s approval configuration, a managed-secret grant’s restrictions and approval configuration), which are managed from that surface and do not count against the 100.
json_match matchable attributes: agent_id, api_key_id, app_id, client_ip, environment, family, method, operation, provider_id, resource_kind.
json_match semantics (from the spec’s notes):
- method matches case-insensitively; a request with NO method (a raw-token retrieval) SATISFIES a method condition — the raw token confers every method
- an operation/family condition on an UNCLASSIFIED request is SATISFIED (fail closed — an unnameable request is a capability superset of anything the rule could deny)
- client_ip is an EXACT-IP match (IPv6-canonicalized); CIDR ranges belong to the ip_allowlist rule type and are rejected here
- an attribute absent on the request (e.g. agent_id for a non-agent caller) never matches — quiet no-fire, except method/operation/family above
List / get
Section titled “List / get”alter policy rules listalter policy rules list --agent <agent-id> --output jsonalter policy rules list --limit 103 --offset 0alter policy rules get --rule <rule-id> --grant <grant-id>list returns every rule that applies at the target, including rules homed elsewhere and attached to it. The table’s APPLIES TO column lists where each rule applies, home first, then the agents, users, and groups it excludes (for example group:1234abcd, secret:5678ef01; not user:9abc0123, with ids shortened). With --output json, each rule’s bindings field lists every place with its id, mode (include or exclude), level, target_id, target_label, and home (true for the home), and the rule’s home field names its home level and target_id.
list accepts --limit (1–103 — the 100 authored rules plus the derived rules: the settings layers and a delegation’s method limit, so one page always holds a complete target) and --offset, and renders a table by default (--output json|jsonl|table). Every format emits the rule rows themselves — json is the array, matching every other list command (the one exception is managed-secrets access, whose JSON keeps its summary envelope with the per-via counts). When more rules remain, the command prints the next --offset to run on stderr, so a | jq pipeline keeps clean data on stdout.
get preserves JSON as its backward-compatible default. Pass --output table for the complete human-readable explanation: type, effect, status, target/source context, operations, every parameter condition, rule-specific configuration, and a Bindings list naming each place the rule applies or excludes with its full target id and binding id. --output jsonl remains available for streaming pipelines.
Update
Section titled “Update”alter policy rules update --rule <rule-id> --disablealter policy rules update --rule <rule-id> --enablealter policy rules update --agent <agent-id> --rule <rule-id> --name "New name" --description "Why"alter policy rules update --rule <rule-id> --body @new-rule.jsonPartial update — only the flags you pass change (--enable and --disable are mutually exclusive; pass at least one change flag). A rule’s evaluator type is fixed at creation; everything else — body, name, description, enabled — is editable in place, and a replacement --body must keep the shape of the rule’s type (an ip_allowlist rule takes a new {"allow": [...]} body, and so on). Remember to repeat the rule’s target flag (--agent / --grant / --provider / --secret / --user / --group) for non-app-level rules.
Editing a disabled rule preserves its disabled state. Both enable and disable
require review. In automation, --yes also requires --expected-version from the
rule you inspected; the CLI never silently substitutes a newer version. For example,
after reviewing version 7:
alter policy rules update --rule <rule-id> --disable --yes \ --expected-version 7 --idempotency-key <unique-change-key>Capture the exact version field from alter policy rules get --rule <rule-id> --output json (for example, jq -r '.version') along with the definition being
reviewed. Do not substitute a fresh version immediately before applying an old review.
A rule owned by a policy file (code_owner on rules list / rules get; the table’s OWNER column and the text view’s “Managed by” line) cannot be changed here: rules update and rules delete stop before the review prompt with exit 5, and the server answers 409 policy_rule_managed to any other client. Change it through alter policy plan / apply, or release it from its manifest first (see the policy-as-code guide).
One exception: a rule whose origin is grant_settings or agent_settings (visible on rules list / rules get) is owned by its settings surface — the managed-secret grant editor’s approval/restrictions block or the agent’s approval settings — which replaces it wholesale on every settings save. Every rules update on such a rule (body, name, description, --enable / --disable) is rejected with a 400 pointing at the owning surface: an edit would be silently overwritten by the next settings save, and a toggle would desynchronize the two views. rules delete works on a grant_settings rule (it is exactly “turn the setting off”, and the grant’s settings read-back reflects it immediately); an agent_settings rule rejects delete too — manage it entirely through the agent’s approval settings.
Delete
Section titled “Delete”alter policy rules delete --rule <rule-id>alter policy rules delete --provider google --rule <rule-id> --yes --expected-version 7Deleting or disabling an active rule removes that restriction; it does not grant
access. All other policy layers and access checks still apply. The CLI asks for
confirmation on a terminal; scripts require --yes --expected-version <reviewed-version>.
As with update, repeat the target flag for non-app-level rules.
Apply a rule in several places
Section titled “Apply a rule in several places”# Also apply an existing rule to another agent (narrows access: no prompt)alter policy rules bind --rule <rule-id> --target agent:<agent-c-id>
# ...or to every grant on a provider, by provider id or configuration UUIDalter policy rules bind --rule <rule-id> --target provider:google
# Exclude an agent from an app-wide rule (loosens it: confirm with --yes in scripts)alter policy rules bind --rule <rule-id> --target agent:<agent-d-id> --exclude --yes
# Apply a group rule to one more group, and exclude one of its membersalter policy rules bind --group <group-id> --rule <rule-id> --target group:<other-group-id>alter policy rules bind --group <group-id> --rule <rule-id> --target user:<app-user-id> --exclude --yes
# Stop applying the rule to that agent (loosens it)alter policy rules unbind --rule <rule-id> --target agent:<agent-c-id> --yes
# Remove the exclusion again (narrows access: no prompt)alter policy rules unbind --rule <rule-id> --target agent:<agent-d-id> --exclude
# Or name the binding by the id in the rule's bindings fieldalter policy rules unbind --agent <agent-a-id> --rule <rule-id> --binding <binding-id> --yesAddress the rule with its home target flag, as for get (none for an app-level rule). bind --target <ref> takes the same refs as create --also; with --exclude it takes agent:<agent-id>, user:<app-user-id>, or group:<group-id>. unbind names the binding by --target <ref> (add --exclude to remove an exclusion rather than a place) or by --binding <binding-id>, the id of an entry in the rule’s bindings field. The home cannot be removed: delete the rule instead.
An exclusion follows delegation exactly as an attachment does. An excluded user or group is carved out of every request that user (or a member of that group) makes, whichever grant or agent carries it. An excluded agent is carved out whenever it is anywhere in the request’s delegation chain, which includes the agents that agent delegates to, and it is carved out even when a more specific attachment of the same rule also matched. A delegate never has more access than the agent it delegates from, so exempting an agent cannot give its delegates anything that agent lacks.
Attachments are removed automatically when the agent is revoked, the provider configuration is deleted, the managed secret is deleted, or the rule itself is deleted; a rule homed at such a target is re-homed to the most specific place it is still attached to (deleted only when none remains). Deprovisioning an app user removes nothing: the rules homed at that user and the user’s attachments and exclusions on other rules stay (inert while the user has no active connection, and in force again if the user is re-provisioned), and create, bind and --also/--exclude naming a deprovisioned user fail as not found (exit 4). When the identity provider deletes a group, by its webhook or when the identity provider itself is deleted, the group’s attachments and exclusions on other rules are removed and a rule homed at the group is re-homed to one of its remaining attachments (deleted only when none remains); each of these changes is recorded in the audit log, one entry per rule. Deleting the app deletes all of its rules, recorded as one count on the app-deletion audit entry. Revoking a grant does not remove an attachment to it: a revoked grant can never be used again, so the attachment has no effect, and it stays listed in the rule’s bindings until you unbind it.
Applying a rule at another place, or removing an exclusion, narrows access and proceeds after the printed review. Removing a place, or excluding an agent, user, or group, loosens the rule: the command asks for confirmation on a terminal, and scripts pass --yes. Before any call the CLI refuses a place the rule already names, an exclusion nothing covers, an unbind that would leave an exclusion covering nothing, the home binding, a file-managed rule, and an end user’s own limit.
Each change reads the rule, sends its current version as the reviewed version, and prints the new version and the updated applies to list on stderr; stdout carries the updated rule with its bindings and home. A refusal the server returns names its code on stderr, and nothing changes (a refusal the CLI detects first exits 2, 4, or 5 with its own message):
| Code | Exit | Meaning |
|---|---|---|
binding_exists | 5 | The rule already names that target. |
home_binding | 5 | That binding is the rule’s home. Delete the rule instead. |
binding_not_allowed | 5 | The rule applies only at its home: a settings-owned, organization, or end-user rule (a limit a user set on their own account). |
binding_limit | 5 | The rule already has 100 bindings. |
rule_limit | 5 | The target already has 100 rules applied to it. On a user, only the application’s rules count: the user’s own limits have a separate budget. On a group, the app may also have reached its ceiling of 1,000 distinct rules applied to its identity-provider groups together; the message names which limit was hit. |
binding_not_found | 4 | The rule has no such binding. |
target_gone | 4 | The target is not in the rule’s app, or was deleted while the change was in flight. |
exclude_without_include | 1 | An excluded agent, user, or group must fall under a broader place the rule applies to. |
group_exclusion_unsupported | 1 | A group can be excluded only when its identity provider reports membership changes in real time with webhooks enabled (Clerk, Okta, or WorkOS), the same requirement as a group-bound grant; the response’s reason names the precondition that failed (idp_not_group_capable, webhooks_not_enabled, or idp_inactive). |
stale_policy_rule | 5 | The rule changed after it was read. Run the command again. |
Stale changes and retries
Section titled “Stale changes and retries”Every create, update, delete, bind, and unbind carries a retry key (--idempotency-key,
generated when omitted); updates, deletes, binds, and unbinds also carry the reviewed version
(--expected-version; bind and unbind default it to the version they just read). A retry of
an unbind that named its binding by --target names it by --binding <binding-id>
instead, as the printed retry command says, because the binding is gone once the first
attempt committed. A stale version fails with HTTP 412 without changing
the rule. Both a locally detected stale revision and an
HTTP 412 response exit with code 5 (conflict). Read and review the current rule before making
a new change. Missing preconditions fail with 428; reusing a key for different
work fails with 422.
If a response is lost, retry the same command, payload, target, reviewed version,
and --idempotency-key printed on stderr, including --yes to confirm that exact
previously reviewed change even if the first attempt already advanced its version
or deleted the rule. Within 24 hours the same currently
authorized principal receives the original committed response (a rotated or
re-issued token in between does not create a second rule), without
another mutation or success audit. This also works after a successful delete has
removed the row. Do not reuse the key for a different change. After 24 hours, inspect
current state before acting: an expired key no longer guarantees deduplication.
Authorization is checked on every retry; the receipt cannot restore revoked access.
Recipes
Section titled “Recipes”Snapshot the app-level rules for review or diffing in CI
alter policy show-app --output json > app-policy-rules.jsonReview and apply a managed ruleset
alter policy test --manifest @policy.json --bindings @bindings.json --tests @tests.jsonalter policy plan --manifest @policy.json --bindings @bindings.json --tests @tests.json --save reviewed-plan.jsonalter policy apply --plan @reviewed-plan.jsonUse the complete three-file example. A loop of creates does not reconcile ownership, removals or reviewed drift.
Audit which rules are currently disabled
alter policy rules list --output json | jq '.[] | select(.enabled == false)'This lists the rules that apply at the app level only — repeat it with --agent / --grant / --provider / --secret / --user / --group (or loop over the targets) to cover each rule family.
Related
Section titled “Related”- Policies — the policy model
alter audit— see policy decisions on real calls (alter audit list --policy-decision DENY)