Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 3 min read

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 approvalRequir

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" };
}

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",
  });
});

Run it:

node --test agent-action-policy.test.js

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 [email protected]. 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": "[email protected]",
  "subject": "Release notes",
  "bodyHash": "sha256:...",
  "expiresAt": "2026-07-21T12:30:00Z"
}

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

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.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.