Runtime security · Advanced
How to Restrict Dangerous AI-Agent Actions with Permission Gates

An agent can have technical access to a tool without being authorized to use it for the current role, task, resource, or time window. This guide shows how to turn that distinction into a small, auditable control: a runtime permission gate.
The most dangerous agent failure is often not a clever prompt attack. It is an ordinary tool call made in the wrong context: a support agent can reach a production deletion endpoint, a research agent can send external email, or a coding agent can change a deployment because the shared runtime exposed more capabilities than the task required.
The practical fix is to evaluate every sensitive action at the moment it is requested. A permission gate receives the agent identity, task context, requested tool, target resource, limits, and current time. It returns an explicit decision and records the evidence used to make that decision. The tool may still be technically present, but the action is refused unless the request satisfies the policy.
This article uses a deliberately small policy model that you can reproduce in any language. It does not assume a particular agent framework, identity provider, cloud, database, or authorization product. All identifiers and values in the examples are fictional. The commands below are examples to adapt and execute in your own isolated test environment; no commands or checks are claimed to have been run here.
1. Separate technical access from task authorization
A tool registry answers a capability question: “Can this process call the function?” It does not answer the authorization question: “Should this agent call the function now, against this target, with these parameters?”
That distinction matters because agents operate across multiple boundaries at once:
- Role: What kind of worker is this agent?
- Task: Which bounded objective is it currently pursuing?
- Tool: Which operation is being requested?
- Scope: Which records, folders, repositories, environments, or recipients may be touched?
- Limit: How much data, money, compute, or change may the operation consume?
- Time: When does the authorization begin and expire?
- Approval: Does a human or another trusted service need to confirm this action?
A useful design principle is least privilege at decision time, not only at account-provisioning time. Static credentials and coarse roles remain useful, but they should establish an upper boundary. A runtime gate narrows that boundary to the current operation.
2. A concrete case: a support agent with a production tool
Consider a support agent whose task is to investigate a disputed order. The agent needs to read the order, inspect a redacted event history, and draft a response. The same runtime also exposes a customer-account tool with operations for changing an address, issuing a refund, and deleting an account.
The agent has technical access to the tool because the application bundles a common tool set. That implementation choice does not mean the support task authorizes every operation. In particular:
orders.readmay be allowed for one order identifier.events.readmay be allowed for the same order, with sensitive fields redacted.responses.draftmay be allowed without sending.accounts.update_addressmay be refused because it changes durable customer data.billing.refundmay require a separate approval and a strict amount limit.accounts.deletemay be refused for this role and task under all circumstances.
The important property is observable refusal. The agent should not receive a vague “tool unavailable” message if the real reason is policy denial. The application should return a stable error code, avoid leaking sensitive policy details, and write an audit record that explains the decision to an authorized reviewer.
3. Model a permission as a bounded decision
Start with a data model that is explicit enough to audit. A permission should name the operation rather than merely naming a broad service. “Billing access” is too broad to review. “Create a refund for one order, up to 50 units of the configured currency, with an approval token, until a stated expiry” is reviewable.
The following YAML is an example policy-as-code document. It is illustrative configuration, not a claim about a particular product’s syntax.
policy_version: "1"
policy_id: "support-order-investigation"
subject:
agent_role: "support-investigator"
task_id: "task_example_8472"
window:
not_before: "2026-11-06T09:00:00Z"
expires_at: "2026-11-06T10:00:00Z"
permissions:
- effect: "allow"
tool: "orders.read"
resource:
type: "order"
ids: ["order_example_123"]
limits:
max_calls: 10
- effect: "allow"
tool: "events.read"
resource:
type: "order"
ids: ["order_example_123"]
constraints:
redact_fields: ["email", "phone", "payment_token"]
limits:
max_calls: 5
- effect: "allow"
tool: "responses.draft"
resource:
type: "case"
ids: ["case_example_456"]
constraints:
send: false
limits:
max_calls: 3
- effect: "require_approval"
tool: "billing.refund"
resource:
type: "order"
ids: ["order_example_123"]
limits:
max_amount: 50
max_calls: 1
- effect: "deny"
tool: "accounts.delete"
- effect: "deny"
tool: "accounts.update_address"
The example has six important properties:
- The policy is tied to a role and a task, not just to a process.
- The time window is finite.
- Allowed tools are named at operation level.
- Resources are constrained to specific identifiers.
- Quantitative limits are separate from the allow decision.
- Dangerous operations have an explicit deny or approval requirement.
Treat omission as denial. If a tool is not listed, the evaluator should return a default-deny decision. This makes new tools safer by default: adding a tool to the runtime does not silently grant every existing agent access to it.
4. Define the gate’s decision contract
A gate should return a machine-readable result. Keep the contract small enough that every tool adapter can use it consistently.
{
"decision": "deny",
"code": "TOOL_NOT_PERMITTED",
"message": "The requested operation is not permitted for this task.",
"policy_id": "support-order-investigation",
"policy_version": "1",
"request_id": "req_example_001",
"audit_id": "audit_example_001",
"retryable": false
}
Possible decisions are:
allow: the adapter may execute the operation after validating the parameters.deny: the adapter must not execute it.require_approval: execution is paused until the required approval is verified.throttle: the operation is temporarily blocked by a rate or quantity limit.
Do not let the language model interpret the decision as a suggestion. The enforcement point must sit in application code between the agent and the side-effecting tool. A prompt instruction such as “do not delete accounts” is useful context, but it is not a permission boundary.
5. Normalize every request before evaluation
Policy bugs often come from comparing different representations. Normalize the request before matching it against a rule. The normalized form should include only the fields needed for authorization and auditing. Avoid copying full customer records or secrets into the policy engine.
{
"request_id": "req_example_001",
"agent": {
"id": "agent_example_support_01",
"role": "support-investigator"
},
"task": {
"id": "task_example_8472",
"purpose": "investigate disputed order"
},
"action": {
"tool": "accounts.delete",
"operation": "delete",
"resource": {
"type": "account",
"id": "account_example_987"
},
"parameters": {}
},
"context": {
"environment": "production",
"requested_at": "2026-11-06T09:24:00Z"
}
}
Canonicalization should cover tool names, resource identifiers, environment names, timestamps, and numeric
quantities. For example, do not let prod, production, and an unvalidated
environment value accidentally map to different policy branches. Decide whether identifiers are
case-sensitive and enforce that decision consistently.
6. Use a conservative evaluation order
A practical evaluator can follow this order:
- Reject malformed or incomplete requests.
- Resolve the policy version for the task.
- Check that the agent identity and role match the task.
- Check the time window using a trusted clock.
- Match the exact tool operation.
- Match the resource and environment scope.
- Check parameter constraints and quantity limits.
- Check whether an approval is required and valid.
- Return the most restrictive applicable decision.
- Write an audit event before executing a side effect.
The “most restrictive” rule prevents a broad allow from overriding a narrow deny. A useful precedence is explicit deny, then approval requirement, then throttle, then allow, then default deny. Document the precedence in code and tests; do not rely on map iteration order or file order unless that behavior is formally guaranteed.
Here is compact pseudocode for the core decision:
function authorize(request, policy, clock):
validate_shape(request)
if request.agent.role != policy.subject.agent_role:
return deny("ROLE_MISMATCH")
if request.task.id != policy.subject.task_id:
return deny("TASK_MISMATCH")
if clock.now < policy.window.not_before:
return deny("NOT_YET_VALID")
if clock.now >= policy.window.expires_at:
return deny("PERMISSION_EXPIRED")
matches = find_matching_rules(policy.permissions, request)
if matches is empty:
return deny("TOOL_NOT_PERMITTED")
if any(matches, rule => rule.effect == "deny"):
return deny("EXPLICIT_DENY")
if any(matches, rule => rule.effect == "require_approval"):
if not valid_approval(request):
return require_approval("APPROVAL_REQUIRED")
if any_limit_exceeded(matches, request):
return throttle("LIMIT_EXCEEDED")
return allow()
This pseudocode intentionally leaves identity verification, approval verification, and resource matching abstract. Those components depend on your system. The invariant is that the side-effecting adapter receives the gate result and cannot bypass it through a second call path.
7. Put the enforcement point in the tool adapter
The safest architecture has one wrapper around every sensitive tool. The wrapper constructs the normalized request, calls the gate, emits the audit event, and only then invokes the underlying tool.
async function callProtectedTool(agentContext, toolName, args) {
const request = normalizeRequest({
agent: agentContext.agent,
task: agentContext.task,
tool: toolName,
args,
requestedAt: trustedClock.now()
});
const decision = await permissionGate.evaluate(request);
await auditLog.append({
requestId: request.requestId,
auditId: decision.auditId,
decision: decision.decision,
code: decision.code,
policyId: decision.policyId,
policyVersion: decision.policyVersion,
tool: request.action.tool,
resource: request.action.resource,
requestedAt: request.context.requestedAt
});
if (decision.decision !== "allow") {
throw new PermissionError(decision.code);
}
return toolRegistry.invoke(toolName, args);
}
The wrapper should be the only supported route to the protected tool. Review direct imports, background workers, administrative scripts, plugin callbacks, and retry handlers. A gate that protects the main agent loop but not a helper path is not a complete control.
8. Make decisions auditable without logging secrets
An audit log should answer who requested what, against which resource, under which policy version, when the decision was made, and whether execution occurred. It should not become a second database of credentials, tokens, prompts, or personal data.
A minimal event might look like this:
{
"audit_id": "audit_example_001",
"request_id": "req_example_001",
"agent_id": "agent_example_support_01",
"role": "support-investigator",
"task_id": "task_example_8472",
"tool": "accounts.delete",
"resource_type": "account",
"resource_id_hash": "hash_example",
"decision": "deny",
"code": "EXPLICIT_DENY",
"policy_id": "support-order-investigation",
"policy_version": "1",
"requested_at": "2026-11-06T09:24:00Z",
"executed": false
}
Hash or tokenize identifiers only if reviewers can still correlate events in an approved way. The exact privacy treatment depends on your data classification and retention requirements. Do not put raw access tokens, API keys, authentication headers, full prompts, or unredacted customer fields into a permission log.
Record policy changes separately from request decisions. A reviewer should be able to determine which policy version was active and who or what published it. If policy updates are mutable in place, preserve an immutable version identifier for every decision.
9. Treat approval as a separate capability
“Require approval” is not the same as “ask the model to confirm.” An approval should be issued by a separately authenticated principal or trusted workflow and bound to the exact operation.
{
"approval_id": "approval_example_007",
"approved_by": "reviewer_example_12",
"request_hash": "hash_of_normalized_request",
"allowed_tool": "billing.refund",
"allowed_resource": {
"type": "order",
"id": "order_example_123"
},
"maximum_amount": 50,
"expires_at": "2026-11-06T09:35:00Z"
}
Bind approval to a request hash, resource, amount, and expiry. Otherwise an agent may reuse approval for a different order or change the amount after approval. The gate should reject an approval if any material request field differs.
For destructive operations, consider requiring a human-visible summary that states the target, action, affected quantity, and rollback status. The summary is a review aid, not a replacement for enforcement.
10. Enforce limits as state, not as prose
Limits such as “no more than one refund” or “read at most ten records” require state. The gate needs a reliable counter or reservation mechanism, especially when several agent turns or workers can act concurrently.
A safe sequence is:
- Evaluate whether the request is structurally valid.
- Atomically reserve the relevant quantity.
- Execute the operation.
- Mark the reservation consumed or release it according to the failure policy.
Without atomic reservation, two concurrent requests can each observe remaining capacity and together exceed the limit. The correct transaction or distributed-lock design depends on your storage system. Document what happens when the tool times out after the reservation: retrying may duplicate a side effect unless the operation supports idempotency.
11. Make expiry unavoidable
Every temporary permission should have an explicit expiry. Short windows reduce the impact of leaked task context, stale approvals, and abandoned sessions. The gate should compare timestamps using a trusted clock, not a timestamp supplied by the agent.
Decide the boundary convention once. In the examples above, not_before is inclusive and
expires_at is exclusive. That means a request exactly at expires_at is refused.
Test the boundary because off-by-one errors in time comparisons are easy to miss.
Expiry should apply to cached decisions as well. If you cache authorization results, include policy version, subject, task, resource, parameters that affect limits, and expiry in the cache key. Never let a cached allow outlive the policy window.
12. Test the refusal path first
A permission system is not verified by demonstrating that a permitted read succeeds. The high-value tests show that dangerous actions are refused under the expected conditions and that no underlying tool call occurs.
Build a test matrix that includes at least these cases:
| Case | Expected decision | Expected side effect |
|---|---|---|
| Listed read, matching resource, valid window | allow |
Tool may execute |
| Unlisted tool | deny |
No tool call |
| Explicitly denied tool | deny |
No tool call |
| Allowed tool, wrong resource | deny |
No tool call |
| Request at expiry | deny |
No tool call |
| Refund without approval | require_approval |
No tool call |
| Refund above amount limit | throttle or deny |
No tool call |
| Malformed resource identifier | deny |
No tool call |
The following command illustrates a generic test invocation. Replace the path and test name with those used by your project.
TEST_ENV=isolated \
./your-test-runner permission-gate \
--case "explicitly denied destructive action"
A useful assertion is stronger than checking the returned error:
expect(result.decision).toEqual("deny");
expect(result.code).toEqual("EXPLICIT_DENY");
expect(protectedTool.invoke).not.toHaveBeenCalled();
expect(auditLog.last().executed).toEqual(false);
To test the full agent path, provide the agent with a task that makes the dangerous action tempting, then inspect the tool boundary. The test should verify that the model’s proposed call is intercepted before the underlying operation. Avoid using real customer records, production credentials, or irreversible tools in this test.
13. Failure cases that commonly invalidate the control
Prompt-only restrictions
A system prompt can describe allowed behavior, but the model can misunderstand, hallucinate, or be manipulated by untrusted content. Keep the instruction for usability, and enforce the decision in code.
Broad tools with hidden operations
A single database.execute or http.request tool makes policy matching difficult.
Prefer narrow operations with typed parameters. If a broad tool is unavoidable, authorize method, host,
path, query, body shape, and resource scope independently.
Unprotected alternate paths
A developer console, retry queue, plugin hook, or scheduled worker may call the same side effect without passing through the gate. Inventory all callers and enforce the same boundary at the tool service or API layer when possible.
Confused deputy behavior
A trusted service may perform an action on behalf of an agent while checking only the service identity. Forward the original actor, task, resource, and approval context. Do not replace the agent identity with the identity of the gateway.
Policy and implementation drift
A policy can say “draft only” while the tool silently sends the message. Keep policy constraints close to
the adapter’s actual parameters and add integration tests for high-risk flags such as
send, publish, delete, and capture.
Fail-open behavior during outages
If the gate is unavailable, decide explicitly whether each action fails closed. Read-only, low-risk operations may have a different availability policy from irreversible writes, but the exception should be documented and tested. Never let a generic timeout silently become an allow.
14. A small reproducible implementation exercise
You can validate the design without connecting to a real service. Create a fake tool whose invocation increments a counter and returns a harmless value. Create a fixed clock and an in-memory audit sink. Then implement the following sequence:
- Load the example policy with one allowed read and one explicit destructive deny.
- Construct a request for the allowed read within the time window.
- Assert that the decision is
allowand the fake tool is called once. - Construct a request for the destructive operation.
- Assert that the decision is
denyand the fake tool call count remains unchanged. - Advance the fixed clock to the expiry boundary.
- Repeat the allowed read and assert
PERMISSION_EXPIRED. - Inspect the audit events and confirm that each request has a policy version and execution status.
This exercise tests the control boundary, not the safety of a production deployment. Before production use, add tests for concurrency, policy publication, identity verification, approval replay, serialization, logging redaction, retries, and every alternate tool path.
15. Limitations and design boundaries
Permission gates reduce unauthorized tool use; they do not make an agent trustworthy by themselves. They cannot correct a compromised host, an incorrect identity assertion, a vulnerable tool implementation, a malicious operator, or a policy that grants too much authority.
They also do not decide whether an allowed action is substantively wise. A gate can confirm that a refund is within the approved amount and resource scope. It cannot determine whether the business decision is fair unless that judgment is represented in a separate, testable rule or review process.
Keep the authorization vocabulary small. Every new wildcard, implicit inheritance rule, or emergency bypass increases the review burden. If an exception is necessary, make it time-limited, narrowly scoped, and visible in the audit trail.
16. Implementation checklist
- Use default deny for unlisted operations.
- Authorize exact tool operations, not broad service names.
- Bind permissions to agent role, task, resource, and environment.
- Set explicit quantity limits for data, money, and side effects.
- Use trusted time and an exclusive expiry boundary.
- Make approval specific to the normalized request.
- Enforce the gate in the tool adapter or service boundary.
- Record policy ID and immutable policy version.
- Log decisions without secrets or unnecessary personal data.
- Test refusal and verify that the underlying tool is not called.
- Review alternate callers, retries, caches, and background workers.
- Define fail-closed behavior for gate outages.
Conclusion
The central security question is not whether an agent can technically reach a tool. It is whether the requested operation is authorized for this role, this task, this resource, this amount, and this moment. A small runtime permission gate turns that question into a repeatable decision with an audit trail.
Start with one dangerous action, one explicit policy, and one refusal test. Then expand the model only when a real requirement demands it. The result should be narrow permissions, visible expiry, controlled approvals, and evidence that a forbidden action stops before it reaches the underlying system.
Continue with the related guides or look up implementation terms in the glossary.