An employee approves a draft. The agent then changes the message before sending it. If approval is just a boolean on the conversation, the application may send something the employee never reviewed.
This exercise binds approval to one specific action and tests the boundary with synthetic data. It uses Node.js built-in modules, makes no network requests, and writes nothing to disk when run. A local array stands in for delivery. Use it as a learning exercise in an environment you control.
1. Define the action and trust boundary
You need Node.js 22 or newer. Save the following code as approval-gate.mjs. The agent may propose a send_reply action with a recipient and body. The trusted application issues approval after its own review; approve is not a tool exposed to the agent.
The approval record stays in a private map and expires after one minute. Its digest covers the normalized action, so changing either the recipient or message invalidates the approval. The code uses exact synthetic addresses rather than attempting to implement email delivery or address validation.
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
const recipients = new Set(['customer@example.test', 'reviewer@example.test']);
const approvals = new Map();
const delivered = [];
function normalize(input) {
assert(input && typeof input === 'object' && !Array.isArray(input), 'invalid action');
assert.deepEqual(Object.keys(input).sort(), ['body', 'operation', 'recipient']);
assert.equal(input.operation, 'send_reply', 'operation not allowed');
assert(recipients.has(input.recipient), 'recipient not allowed');
assert(typeof input.body === 'string' && input.body.trim().length > 0, 'empty body');
assert(input.body.length <= 2000, 'body too long');
return { operation: input.operation, recipient: input.recipient, body: input.body };
}
const digest = (action) => createHash('sha256').update(JSON.stringify(action)).digest('hex');
function approve(input, now = Date.now()) {
const action = normalize(input);
const approvalId = randomUUID();
approvals.set(approvalId, { digest: digest(action), expiresAt: now + 60_000 });
return approvalId;
}
function send(input, approvalId, now = Date.now()) {
const action = normalize(input);
const approval = approvals.get(approvalId);
assert(approval && now < approval.expiresAt, 'missing or expired approval');
assert.equal(digest(action), approval.digest, 'action changed');
// ponytail: synchronous, single-process simulation; use durable atomic state before real delivery.
approvals.delete(approvalId);
delivered.push(action);
}
const now = Date.parse('2026-10-16T02:00:00Z');
const draft = {
operation: 'send_reply',
recipient: 'customer@example.test',
body: 'Your support request is ready for review.',
};
const approvalId = approve(draft, now);
assert.throws(() => send(draft, 'unknown', now), /missing or expired/);
assert.throws(() => send({ ...draft, body: 'A different message.' }, approvalId, now), /action changed/);
assert.throws(() => send({ ...draft, recipient: 'reviewer@example.test' }, approvalId, now), /action changed/);
assert.throws(() => send({ ...draft, recipient: 'other@example.test' }, approvalId, now), /recipient not allowed/);
assert.throws(() => send({ ...draft, operation: 'delete_account' }, approvalId, now), /operation not allowed/);
assert.throws(() => send({ ...draft, body: null }, approvalId, now), /empty body/);
assert.throws(() => send({ ...draft, attachment: 'private.txt' }, approvalId, now));
assert.equal(delivered.length, 0, 'denied actions must have no delivery effect');
const expiredId = approve(draft, now);
assert.throws(() => send(draft, expiredId, now + 60_000), /missing or expired/);
assert.equal(delivered.length, 0);
send(draft, approvalId, now + 1);
assert.deepEqual(delivered, [draft]);
assert.throws(() => send(draft, approvalId, now + 2), /missing or expired/);
assert.equal(delivered.length, 1, 'replay must not duplicate delivery');
console.log('PASS: approved action delivered once; invalid, changed, expired, and replayed actions blocked.');
2. Run the checks
Run the file locally:
node approval-gate.mjs
The expected output is:
PASS: approved action delivered once; invalid, changed, expired, and replayed actions blocked.
The assertions verify observable effects: rejected calls leave the delivery array empty, the approved call adds exactly the reviewed action, and a replay leaves the count at one. They do not rely on what a model says it did.
There is intentionally no model call here. This is a deterministic test of the application’s decision boundary. Testing how often a model proposes an unsafe action is a separate evaluation.
3. Prove that the checks detect a broken gate
In a temporary copy, remove the digest comparison inside send and run it again. The changed-message test must fail because it expected rejection. Restore the line. Then remove approvals.delete(approvalId) in the temporary copy: the replay test must fail.
Do not use the altered copies for actual workflows. These small mutations confirm that the assertions exercise content binding and one-use approval, rather than passing simply because every call is denied.
4. Understand what the example leaves out
The digest is a fingerprint, not a signature or a substitute for authentication. The security decision relies on the approval map being controlled by the trusted application. If an agent can call approve or edit the map, this boundary disappears.
The map also does not survive restarts and is not shared across workers. Before adapting the pattern for production, authenticate the approver, bind approval to a task and actor, and check revocation at execution. Use durable storage with an atomic state transition to prevent concurrent consumption.
Real delivery introduces another problem: a service can accept a request even if your client times out. Deleting approval before a network call could leave the action unresolved; deleting it afterward could allow duplicates. Use the provider’s supported idempotency key, a persisted operation state, and reconciliation for uncertain outcomes. The in-memory sink deliberately avoids claiming to solve that distributed-systems problem.
This exercise belongs alongside a broader agent security test plan and limited tool permissions. It proves a small property: in this synchronous local program, an approval authorizes exactly one unchanged action before expiry. Keep that claim attached to the test when you extend it.
