Skip to content
R.

An AI tool call is a proposal, not permission

Validate a model-proposed tool call against server-owned identity, allowed resources, and current ownership before reading a private note.

4 min readComments

An assistant can produce a perfectly shaped tool call and still ask for something the current user is not allowed to read. A valid note ID is not permission. Neither is a model-generated user ID, an explanation in natural language, or text found inside a retrieved document.

This article isolates that boundary with a read-only note tool. The test inputs are synthetic proposals, checked with Node.js 22.23.2 on September 9, 2026. No model or provider API was invoked, so these tests make no claim about model accuracy, prompt-injection resistance, or how frequently an agent proposes an unsafe action.

Keep identity and allowed scope outside the proposal

OWASP identifies unnecessary tool functionality and excessive downstream permissions as forms of excessive agency. Its mitigation guidance includes narrow capabilities and enforcing authorization in the context of the real user. That is a server responsibility, not something to delegate to a prompt.

In this example, session comes from already verified application authentication. The server supplies a bounded set of note IDs allowed for this task. The proposal accepts only name and noteId. If it includes userId, role, or another unexpected field, the tool rejects it instead of letting model output widen the scope.

SourcesOWASP: LLM06 Excessive Agency (opens a new tab)

Use one narrow operation, not a general database tool

readNoteTool cannot write, delete, run arbitrary queries, or choose a network destination. It validates the operation and identifier, checks task scope, then checks current ownership on the loaded record. The ownership check matters because an earlier allowed-ID list can become stale.

Return an explicit field allowlist. An internal audit field should not reach the model just because the database record contains it. The returned body remains untrusted content; it may contain instructions, but those instructions do not change the authority of subsequent tool calls.

JavaScript
export async function readNoteTool(proposal, session, store) {
  // session comes from verified server authentication, never model arguments.
  if (!session?.userId || !(session.allowedNoteIds instanceof Set)) {
    throw new Error('Tool call not allowed');
  }
  if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal) ||
      Object.keys(proposal).some(key => !['name', 'noteId'].includes(key)) ||
      proposal.name !== 'read_note' || typeof proposal.noteId !== 'string' ||
      !/^[a-zA-Z0-9_-]{1,80}$/.test(proposal.noteId) ||
      !session.allowedNoteIds.has(proposal.noteId)) {
    throw new Error('Tool call not allowed');
  }
  const note = await store.get(proposal.noteId);
  if (!note || note.ownerId !== session.userId) {
    throw new Error('Note not available');
  }
  return { id: proposal.noteId, title: note.title, body: note.body };
}

Test denied proposals before any storage call

The executable test rejects missing authentication, null and array inputs, a write operation, a traversal-shaped ID, an out-of-scope ID, and a forged userId. The storage read counter remains zero for those denied inputs. This verifies where the boundary is enforced, not just which error message appears.

A note that is in the allowed set but now belongs to a different owner is also denied. An authorized read returns only id, title, and body; the fixture includes an extra privateAudit field to verify that it is excluded. These are policy tests that should keep passing even when the model, prompt, or retrieval pipeline changes.

Connect it to real authentication before using it

The example does not implement session verification, tenancy, rate limiting, a production store, or audit retention. Do not construct session directly from the same request body as proposal. A signed-in account is also not enough by itself: the application must decide which task and resources that account may use now.

For a large collection, replace the small in-memory allowed set with a server-side policy check. Keep the ownership decision close to the read. A shared workspace needs a membership policy rather than the simple owner equality used here. Each tool execution must repeat the relevant checks; a previous successful call is not blanket permission.

Treat write tools as a separate release decision

If the product later needs sending, deleting, or changing permissions, add a separate operation with its own scope, confirmation requirements, and audit trail. Do not expand this reader into a generic execute tool simply to avoid another interface.

This boundary does not eliminate prompt injection. It reduces what a bad proposal can do after it reaches the server. Evaluate model behavior separately using representative and adversarial tasks, and document the model identifier and settings for that evaluation. Passing the policy suite is a prerequisite, not an agent safety benchmark.

Share LinkedIn Email

Discussion

Leave a comment

Comments appear after review. No email needed.