DEV Community

Cover image for Put AI Agent Permissions in Code, Not in the Prompt
Radhakishan Jangid
Radhakishan Jangid

Posted on

Put AI Agent Permissions in Code, Not in the Prompt

The policy

Create agent-action-policy.js:

export function authorizeAction({ tool, args = {} }) {
  const readOnlyTools = new Set([
    "read_file",
    "search_docs",
    "list_issues",
  ]);

  const approvalRequiredTools = new Set([
    "send_email",
    "publish_post",
    "delete_file",
    "run_command",
  ]);

  if (readOnlyTools.has(tool)) {
    return { allowed: true, reason: "read-only action" };
  }

  if (approvalRequiredTools.has(tool)) {
    return {
      allowed: args.userApproved === true,
      reason: args.userApproved === true
        ? "explicit approval supplied"
        : "explicit approval required",
    };
  }

  return { allowed: false, reason: "unknown tool denied by default" };
}
Enter fullscreen mode Exit fullscreen mode

There are three decisions here:

  1. Known read-only actions are allowed.
  2. Side effects require an explicit approval signal.
  3. Unknown tools are denied.

The third rule matters. An allow-by-default policy quietly becomes less safe every time a new tool is added.

Test the boundary

Create agent-action-policy.test.js:

import test from "node:test";
import assert from "node:assert/strict";
import { authorizeAction } from "./agent-action-policy.js";

test("allows known read-only tools", () => {
  assert.deepEqual(authorizeAction({ tool: "read_file" }), {
    allowed: true,
    reason: "read-only action",
  });
});

test("blocks side effects without explicit approval", () => {
  assert.deepEqual(authorizeAction({ tool: "publish_post" }), {
    allowed: false,
    reason: "explicit approval required",
  });
});

test("allows a side effect only with explicit approval", () => {
  assert.deepEqual(
    authorizeAction({
      tool: "publish_post",
      args: { userApproved: true },
    }),
    { allowed: true, reason: "explicit approval supplied" },
  );
});

test("denies unknown tools", () => {
  assert.deepEqual(authorizeAction({ tool: "mystery_tool" }), {
    allowed: false,
    reason: "unknown tool denied by default",
  });
});
Enter fullscreen mode Exit fullscreen mode

Run it:

node --test agent-action-policy.test.js
Enter fullscreen mode Exit fullscreen mode

All four tests should pass.

What this example intentionally leaves out

This is a teaching boundary, not a complete production authorization system.

A real implementation should also include:

  • identity: who approved the action;
  • scope: exactly which destination, command, or record was approved;
  • expiry: how long the approval remains valid;
  • immutability: prevent the agent from changing approved arguments later;
  • audit logging: record the request, decision, and result;
  • isolation: run dangerous tools with minimal operating-system and network permissions.

The approval flag in this example also comes from trusted application state. Never let the model set userApproved: true because a document told it to.

Bind approval to the exact action

A boolean is useful for demonstrating the boundary, but it is too broad for production.

Imagine that a user approves sending one draft email to team@example.com. If the approval is stored only as true, an agent could change the recipient, body, or attachment before execution while still passing the policy check.

A stronger design creates an approval record from canonical action arguments:

{
  "tool": "send_email",
  "recipient": "team@example.com",
  "subject": "Release notes",
  "bodyHash": "sha256:...",
  "expiresAt": "2026-07-21T12:30:00Z"
}
Enter fullscreen mode Exit fullscreen mode

The executor compares the proposed call with this record immediately before performing the side effect. A changed recipient or body is a different action and requires new approval.

This pattern also prevents approval from becoming a reusable permission token. Scope it to one action, give it a short lifetime, consume it once, and write the result to an audit log.

Keep policy outside the model loop

The authorization function should not be one more tool the model can rewrite or bypass.

Place it between the agent runtime and the real tool implementation:

model proposal
      ↓
validated tool schema
      ↓
authorization policy
      ↓
isolated executor
      ↓
audit result
Enter fullscreen mode Exit fullscreen mode

The model receives the decision, not control over the decision-maker.

This separation also improves debugging. When an action is blocked, the log can show whether validation failed, approval was missing, the permission expired, or the executor rejected the request. Without that separation, every failure looks like mysterious “agent behavior.”

Test negative cases first

Teams naturally test whether an agent can complete a task. For side-effect tools, begin with the opposite question: under which conditions must execution never happen?

Useful negative tests include unknown tools, missing approval, expired approval, modified arguments, destinations outside an allowlist, and repeated use of a single-use approval. These cases turn safety expectations into regression tests instead of leaving them inside a system prompt.

The useful mental model

Treat the model as a proposer.

It can suggest an action and provide arguments. A deterministic policy evaluates that proposal. The application either executes the approved action or returns a refusal the model can explain.

That separation makes the system easier to test and much harder for prompt injection to turn into an external side effect.

Prompts guide behavior. Code enforces authority.

Reference

The code in this article was executed with node --test on 2026-07-21; four tests passed.

Nothing has been published.

Top comments (1)

Collapse
 
jugeni profile image
Mike Czerwinski

Permissions in code not prompt is the right floor, and the parameter binding is the part most people skip, so it is worth being precise about what the binding buys and what it does not. Binding the approval to recipient, subject, and bodyHash with a single-use short-TTL token closes the gap between approval and execution. The agent cannot get one thing approved and do another, cannot mutate the body after the human said yes, cannot replay the token. That is integrity, and it is real.

What it does not close is the gap between intent and approval. The human approves based on what the action is, and bodyHash guarantees the exact body they saw is the body that sends, but it says nothing about whether that body should have been sent. An agent induced into writing a phishing payload gets the payload approved and delivered, perfectly bound, hash matching end to end. Parameter binding verifies that what executed matches what was approved. It cannot verify that what was approved was the right thing, because correctness is not a property of the parameters, it is a property of the reason behind them, and the reason is not in the hash. So the binding secures the channel from approval to execution, which is exactly where TOCTOU attacks live, and it is why the human step cannot degrade into rubber-stamping the hash. The hash guarantees the action is intact. It does not guarantee the action is right, and those are different gates.