AI Agent Authorization: How to Control What Agents Can Actually Do
AI agent authorization decides whether a specific agent may take a specific action on a specific resource, in the current context, before the action runs. Credentials say what an agent can reach. Authorization says what it may do with that reach, one call at a time. Below is a worked policy and six real requests traced to their verdicts.
Credentials grant reach. Nobody is deciding use.
Most agents run with someone else's permissions and no per-call decision at all.
When you start a coding agent, it inherits your shell, your cloud profile, your SSH keys and your repository access. When you connect it to an MCP server, every tool on that server becomes available to every prompt the agent reads. The question "should this particular call happen?" is never asked. If the call is technically possible, it runs.
That is fine until the agent reads a poisoned pull request title, misunderstands a task, or chains two harmless steps into a harmful one. At that point the only thing standing between the model's choice and the side effect is whatever you configured weeks ago. AI agent authorization adds the missing step: a deterministic decision on each call, made outside the model, that the model cannot argue with.
| Mechanism | Granted to | Decides per call? | Sees resource and context? |
|---|---|---|---|
| API key / OAuth scope | A credential | No | No |
| RBAC role | An identity | No | Rarely |
| System-prompt rules | The model | The model decides | Only as text it can be talked out of |
| Agent authorization policy | Each agent, each call | Yes | Yes |
Identity still matters: a policy can only scope by agent if each agent has a distinct name rather than a shared key. Our page on agent IAM covers that half.
Four inputs, three verdicts.
Cirvix rules match on exactly the things an authorization decision needs.
Agent
Who is asking. Rules use the agents glob. One permit for release-bot says nothing about pr-triage.
Action
What kind of operation: fs.read, k8s.apply, db.migrate. Tool names are classified into actions heuristically; unknown tools keep a mcp.<server>.<tool> or tool.<name> identity, so you can always target one tool by name.
Resource
What the action touches, canonicalized before matching so traversal, relative paths and case differences resolve to one value.
Context
Conditions in when: environment, path.insideWorkspace, egress.external, egress.allowlisted, session.touchedSecret, mcp.server, mcp.tool. Conditions are data, never code.
Every request resolves to one verdict. Permit lets the call run. Hold refuses it until a named approver signs off. Deny refuses it, with a reason and a remediation. Precedence is fixed: forbid beats hold, hold beats permit, and no match is a deny. The full grammar is on the policy engine page.
Six requests, one policy, every verdict explained.
A release agent that may read the repo, deploy to staging on its own, and deploy to production only with approval.
{
"rules": [
{ "name": "deny-dotenv-read", "effect": "forbid",
"actions": ["fs.*"], "resources": ["**/.env", "**/.env.*"],
"reason": "Reading .env files is denied outside an approved secrets flow.",
"remediation": "Request the deploy token as a secret handle." },
{ "name": "deny-egress-after-secret", "effect": "forbid",
"when": [
{ "path": "session.touchedSecret", "op": "eq", "value": true },
{ "path": "egress.external", "op": "eq", "value": true },
{ "path": "egress.allowlisted", "op": "eq", "value": false }
],
"reason": "This session has read secret material; external egress is closed." },
{ "name": "hold-prod-deploys", "effect": "hold",
"agents": ["release-bot"], "actions": ["k8s.apply"],
"when": [{ "path": "environment", "op": "eq", "value": "production" }],
"approvers": ["release-managers"],
"reason": "Production deploy. A release manager must approve." },
{ "name": "allow-staging-deploys", "effect": "permit",
"agents": ["release-bot"], "actions": ["k8s.apply"],
"when": [{ "path": "environment", "op": "eq", "value": "staging" }] },
{ "name": "allow-workspace-read", "effect": "permit",
"actions": ["fs.read", "fs.list"],
"when": [{ "path": "path.insideWorkspace", "op": "eq", "value": true }] }
]
}| # | Request | Verdict | Deciding rule | Why |
|---|---|---|---|---|
| 1 | release-bot · fs.read · src/app.ts | PERMIT | allow-workspace-read | The canonical path resolves inside the workspace and no forbid or hold matches. |
| 2 | release-bot · fs.read · .env.production | DENY | deny-dotenv-read | The file is inside the workspace, so the permit also matches, but forbid always wins. The agent receives the remediation and can ask for a handle instead. |
| 3 | release-bot · k8s.apply · production/checkout · env production | HOLD | hold-prod-deploys | Consequential and environment-specific. The call is refused pending approval by release-managers. |
| 4 | release-bot · k8s.apply · staging/checkout · env staging | PERMIT | allow-staging-deploys | Same action, different context, different answer. This is the decision a static scope cannot make. |
| 5 | pr-triage · k8s.apply · staging/checkout · env staging | DENY | none (rule: null) | The permit names release-bot only. Nothing matches another agent, so default deny applies. |
| 6 | release-bot · outbound POST to https://paste.example.net, after reading deploy/token.txt | DENY | deny-egress-after-secret | Reading a file whose path looks like a token sets session.touchedSecret, which never resets for the session. Each call was fine alone; the sequence is not. |
Three lessons fall out of the table. First, the order you write rules in does not create holes: row 2 shows a broad permit losing to a narrow forbid. Second, context, not the tool, carries the risk: rows 3 and 4 are the same tool call with different answers. Third, default deny is what keeps a policy small: you never had to write "pr-triage may not deploy" for row 5 to be refused.
Reproduce the walkthrough yourself.
No account, no agent, nothing executed. check evaluates one hypothetical call.
# Reproduce rows 1-5 with the policy saved as cirvix.policy.json. Nothing executes. P="--policy cirvix.policy.json" npx @cirvix_ai/agent-control check $P --agent release-bot --action fs.read --resource src/app.ts npx @cirvix_ai/agent-control check $P --agent release-bot --action fs.read --resource .env.production npx @cirvix_ai/agent-control check $P --agent release-bot --action k8s.apply --resource production/checkout --env production npx @cirvix_ai/agent-control check $P --agent release-bot --action k8s.apply --resource staging/checkout --env staging npx @cirvix_ai/agent-control check $P --agent pr-triage --action k8s.apply --resource staging/checkout --env staging # Add --json to any line for the full decision record, including the "considered" trace.
check exits 1 on deny and 0 on permit or hold, so do not treat a zero exit as permission. It also builds a deliberately minimal context, with egress.* and session.touchedSecret set to false, which is why row 6 belongs in a test that passes context explicitly:
// policy.test.mjs — run with: node --test policy.test.mjs import test from "node:test"; import assert from "node:assert/strict"; import { evaluate } from "@cirvix_ai/agent-control/testing"; test("production deploys by release-bot are held", async () => { const d = await evaluate({ policyFile: "cirvix.policy.json", agent: "release-bot", action: "k8s.apply", resource: "production/checkout", context: { environment: "production" } }); assert.equal(d.verdict, "hold"); assert.deepEqual(d.approvers, ["release-managers"]); }); // Row 6 needs session context, which `check` always sets to false. // The rule matches on context, so the action name here is illustrative. test("no external egress after a secret was read", async () => { const d = await evaluate({ policyFile: "cirvix.policy.json", agent: "release-bot", action: "http.post", resource: "https://paste.example.net/upload", context: { session: { touchedSecret: true }, egress: { external: true, allowlisted: false } } }); assert.equal(d.verdict, "deny"); assert.equal(d.rule, "deny-egress-after-secret"); // not just default deny });
What the agent experiences.
Enforcement only counts if the agent's calls actually pass through it.
A deny is a structured error
Through guard.wrap, a denied call throws CirvixDenied carrying the rule name and a decision ID, and the tool function is never invoked. The remediation text is what lets the agent re-plan instead of retrying the same call.
A hold is not a failure
SDKs raise a distinct CirvixHeld. An approver reviews pending calls with cirvix approvals; approval records a single-use grant for a later retry. Nothing resumes automatically, and local approval records name a reviewer rather than carrying an authenticated signature.
Every decision can be recorded
With an audit sink configured, decisions land in a SHA-256 hash-chained log. cirvix why <decision-id> explains one decision, and cirvix audit verify checks the chain's internal consistency.
Routing is your job
Register the returned wrapped tools with your framework, and make the MCP gateway the only server your client launches. Every governed agent action routed through Cirvix is evaluated before execution; a tool the agent reaches directly is not.
Four ways agent authorization quietly fails.
Wrapping tools but registering the originals
The executor keeps calling unwrapped functions and nothing is enforced. Check by triggering a known deny.
Leaving a direct MCP entry next to the gateway
The agent has two routes and only one is governed. Remove upstream entries from the client config.
Writing rules against raw strings
Cirvix canonicalizes resources, but your globs still need to match the canonical form. Inspect check --json output to see the resource the engine compared.
Holds with nobody to approve them
validateRules flags a hold without approvers. A hold nobody watches becomes a deny that frustrates users into loosening policy.
AI agent authorization, asked directly.
Short answers.
What is AI agent authorization?
Deciding whether a specific AI agent may perform a specific action on a specific resource in the current context, and enforcing that decision before the action runs. It is narrower than authentication, which establishes who is calling, and finer-grained than the scopes on the agent's credentials.
Why aren't OAuth scopes enough to authorize an agent?
Scopes are granted to a credential once and apply to every call made with it. They cannot see the resource of a single call, the environment, or what the agent did earlier in the session. Agent authorization evaluates each call with that context.
What should happen when an agent is denied?
The agent should get a structured refusal it can act on: the rule that matched, a reason and a remediation that names the legitimate path. Cirvix SDKs raise CirvixDenied for a deny and a distinct CirvixHeld for a hold, so an agent does not abandon work a person is about to approve.
How do I test an authorization rule without running the agent?
Use npx @cirvix_ai/agent-control check --action <action> --resource <resource> for a single hypothetical call, and evaluate() from @cirvix_ai/agent-control/testing inside node --test for assertions in CI. Neither executes a tool.
Does Cirvix authorize every action an agent takes?
Every governed agent action routed through Cirvix is evaluated before execution. That means calls made through the returned SDK wrappers, the MCP gateway or the local control socket. Editor built-in tools, unwrapped functions and arbitrary subprocesses are outside the boundary.
Pick the call you are most nervous about. Test it.
Run npx @cirvix_ai/agent-control check against the action your agent should never take unattended, then put that agent's tools behind policy.