AI Agent External Tool-Broker Lab
Exercises action-bound approval, replay resistance, kill-switch behavior, and unknown-argument denial in a local tool broker.
Application Security 3 min read
Implementation: Tested
Implementation
labs/ai-agent-security/broker.js
1#!/usr/bin/env node
2"use strict";
3
4/**
5 * Tested teaching implementation for labs/ai-agent-security.
6 *
7 * This broker receives authenticated context separately from the model-proposed
8 * call. It demonstrates deterministic authorization; it does not validate
9 * identity tokens, make network requests, or issue real credentials.
10 *
11 * Approval consumption uses an in-memory atomic compare-and-set that models a
12 * durable conditional write. Production systems must use a durable store such
13 * as a PostgreSQL conditional update / unique insert, Redis SET NX, DynamoDB
14 * conditional write, or another transactionally enforced compare-and-set.
15 * A JavaScript Set is not equivalent to a distributed durable store.
16 */
17
18class BrokerDenied extends Error {
19 constructor(code, message) {
20 super(message);
21 this.name = "BrokerDenied";
22 this.code = code;
23 }
24}
25
26function deny(code, message) {
27 throw new BrokerDenied(code, message);
28}
29
30function isRecord(value) {
31 return value !== null && typeof value === "object" && !Array.isArray(value);
32}
33
34function requireRecord(value, code, label) {
35 if (!isRecord(value)) deny(code, `${label} must be an object`);
36 return value;
37}
38
39function requireIdentifier(value, code, label) {
40 if (typeof value !== "string" || value.length < 1 || value.length > 128) {
41 deny(code, `${label} must be a nonempty string of at most 128 characters`);
42 }
43 return value;
44}
45
46function requireSafePositiveInteger(value, code, label) {
47 if (!Number.isSafeInteger(value) || value <= 0) {
48 deny(code, `${label} must be a positive safe integer`);
49 }
50 return value;
51}
52
53function hasOwn(object, key) {
54 return Object.prototype.hasOwnProperty.call(object, key);
55}
56
57const {createHash} = require("node:crypto");
58
59/**
60 * Canonical action binding for approval consumption.
61 * Key order is fixed. The hash is SHA-256 of JSON.stringify of that object —
62 * not a delimiter-joined string (which would be ambiguous for attacker-controlled
63 * identifier characters).
64 */
65function canonicalActionBinding(binding) {
66 return {
67 principalId: binding.principalId,
68 tenantId: binding.tenantId,
69 tool: binding.tool,
70 amountCents: binding.amountCents,
71 destinationRef: binding.destinationRef
72 };
73}
74
75function hashActionBinding(binding) {
76 const canonical = JSON.stringify(canonicalActionBinding(binding));
77 return createHash("sha256").update(canonical, "utf8").digest("hex");
78}
79
80/** @deprecated Use hashActionBinding; retained name for ApprovalStore callers. */
81function digestAction(binding) {
82 return hashActionBinding(binding);
83}
84
85function safeAuditMetadata(authenticatedContext, proposedCall) {
86 const args = isRecord(proposedCall?.arguments) ? proposedCall.arguments : {};
87 return {
88 principalId:
89 typeof authenticatedContext?.principalId === "string"
90 ? authenticatedContext.principalId
91 : "<invalid>",
92 tenantId:
93 typeof proposedCall?.tenantId === "string"
94 ? proposedCall.tenantId
95 : "<invalid>",
96 tool:
97 typeof proposedCall?.tool === "string" ? proposedCall.tool : "<invalid>",
98 amountCents:
99 Number.isSafeInteger(args.amountCents) ? args.amountCents : null,
100 destinationRef:
101 typeof args.destinationRef === "string"
102 ? args.destinationRef
103 : "<invalid>",
104 };
105}
106
107/**
108 * In-memory approval store that models durable conditional consumption.
109 *
110 * getApprovedAction is read-only. consumeIfUnused serializes consumption so
111 * only one concurrent caller can observe an unused approval and mark it
112 * consumed. Consumed state is not reverted if a later executor fails;
113 * production systems should reconcile by idempotency key before retrying.
114 */
115class ApprovalStore {
116 #approvals = new Map();
117 #consumed = new Map();
118 #mutex = Promise.resolve();
119 #readDelayMs;
120
121 constructor(approvals = [], {readDelayMs = 0} = {}) {
122 this.#readDelayMs = readDelayMs;
123 for (const approval of approvals) {
124 if (!isRecord(approval) || typeof approval.id !== "string") {
125 throw new TypeError("each approval must be an object with an id");
126 }
127 this.#approvals.set(approval.id, Object.freeze({...approval}));
128 }
129 }
130
131 async getApprovedAction(approvalId) {
132 if (this.#readDelayMs > 0) {
133 await new Promise((resolve) => setTimeout(resolve, this.#readDelayMs));
134 }
135 return this.#approvals.get(approvalId) ?? null;
136 }
137
138 /**
139 * Atomically consume an approval if it is unused and still bound to the
140 * supplied action digest. Returns true only for the first valid consumption.
141 */
142 async consumeIfUnused({approvalId, actionDigest, nowMs}) {
143 const run = this.#mutex.then(() => {
144 if (typeof approvalId !== "string" || approvalId.length < 1) {
145 return false;
146 }
147 if (typeof actionDigest !== "string" || actionDigest.length < 1) {
148 return false;
149 }
150 if (!Number.isSafeInteger(nowMs)) {
151 return false;
152 }
153 if (this.#consumed.has(approvalId)) {
154 return false;
155 }
156
157 const approval = this.#approvals.get(approvalId);
158 if (!isRecord(approval) || approval.decision !== "approved") {
159 return false;
160 }
161 if (!Number.isSafeInteger(approval.expiresAtMs) || approval.expiresAtMs <= nowMs) {
162 return false;
163 }
164
165 const expectedDigest = digestAction({
166 principalId: approval.principalId,
167 tenantId: approval.tenantId,
168 tool: approval.tool,
169 amountCents: approval.amountCents,
170 destinationRef: approval.destinationRef,
171 });
172 if (expectedDigest !== actionDigest) {
173 return false;
174 }
175
176 // Durable CAS model: mark consumed before returning success. Executor
177 // failure must not free the approval for a second attempt.
178 this.#consumed.set(approvalId, {
179 actionDigest,
180 consumedAtMs: nowMs,
181 });
182 return true;
183 });
184
185 this.#mutex = run.then(
186 () => undefined,
187 () => undefined,
188 );
189 return run;
190 }
191}
192
193class ToolBroker {
194 #approvalStore;
195 #audit;
196 #clock;
197 #executor;
198 #isExecutionEnabled;
199 #policy;
200
201 constructor({
202 policy,
203 approvalStore,
204 executor,
205 isExecutionEnabled = () => true,
206 audit = () => {},
207 clock = () => Date.now(),
208 }) {
209 if (!isRecord(policy) || !isRecord(policy.principals)) {
210 throw new TypeError("policy.principals must be an object");
211 }
212 if (
213 approvalStore == null ||
214 typeof approvalStore.getApprovedAction !== "function" ||
215 typeof approvalStore.consumeIfUnused !== "function"
216 ) {
217 throw new TypeError(
218 "approvalStore must implement getApprovedAction and consumeIfUnused",
219 );
220 }
221 if (typeof executor !== "function") {
222 throw new TypeError("executor must be a function");
223 }
224 if (typeof isExecutionEnabled !== "function") {
225 throw new TypeError("isExecutionEnabled must be a function");
226 }
227 if (typeof audit !== "function") {
228 throw new TypeError("audit must be a function");
229 }
230 if (typeof clock !== "function") {
231 throw new TypeError("clock must be a function");
232 }
233
234 this.#policy = policy;
235 this.#approvalStore = approvalStore;
236 this.#executor = executor;
237 this.#isExecutionEnabled = isExecutionEnabled;
238 this.#audit = audit;
239 this.#clock = clock;
240 }
241
242 async execute(authenticatedContext, proposedCall, approvalEvidence = null) {
243 const auditMetadata = safeAuditMetadata(authenticatedContext, proposedCall);
244
245 try {
246 const authorizedCall = await this.#authorize(
247 authenticatedContext,
248 proposedCall,
249 approvalEvidence,
250 );
251
252 // A production high-impact path should fail closed if this durable audit
253 // decision cannot be recorded. This lab's injected audit sink is in-memory.
254 this.#audit({
255 event: "authorization_decision",
256 outcome: "allow",
257 reason: "POLICY_ALLOWED",
258 ...auditMetadata,
259 approvalId: authorizedCall.approvalId,
260 });
261
262 try {
263 const result = await this.#executor(authorizedCall);
264 this.#audit({
265 event: "execution_outcome",
266 outcome: "success",
267 ...auditMetadata,
268 approvalId: authorizedCall.approvalId,
269 });
270 return result;
271 } catch (error) {
272 this.#audit({
273 event: "execution_outcome",
274 outcome: "failure",
275 reason: "EXECUTOR_FAILED",
276 ...auditMetadata,
277 approvalId: authorizedCall.approvalId,
278 });
279 throw error;
280 }
281 } catch (error) {
282 if (error instanceof BrokerDenied) {
283 this.#audit({
284 event: "authorization_decision",
285 outcome: "deny",
286 reason: error.code,
287 ...auditMetadata,
288 approvalId:
289 isRecord(approvalEvidence) &&
290 typeof approvalEvidence.id === "string"
291 ? approvalEvidence.id
292 : null,
293 });
294 }
295 throw error;
296 }
297 }
298
299 async #authorize(authenticatedContext, proposedCall, approvalEvidence) {
300 const context = requireRecord(
301 authenticatedContext,
302 "INVALID_CONTEXT",
303 "authenticatedContext",
304 );
305 const principalId = requireIdentifier(
306 context.principalId,
307 "INVALID_CONTEXT",
308 "authenticatedContext.principalId",
309 );
310
311 const call = requireRecord(
312 proposedCall,
313 "INVALID_CALL",
314 "proposedCall",
315 );
316 const tenantId = requireIdentifier(
317 call.tenantId,
318 "INVALID_CALL",
319 "proposedCall.tenantId",
320 );
321 const tool = requireIdentifier(
322 call.tool,
323 "INVALID_CALL",
324 "proposedCall.tool",
325 );
326 const args = requireRecord(
327 call.arguments,
328 "INVALID_ARGUMENTS",
329 "proposedCall.arguments",
330 );
331
332 const allowedArgumentNames = new Set([
333 "amountCents",
334 "destinationRef",
335 ]);
336 for (const argumentName of Object.keys(args)) {
337 if (!allowedArgumentNames.has(argumentName)) {
338 deny(
339 "UNKNOWN_ARGUMENT",
340 `argument ${argumentName} is not in the closed schema`,
341 );
342 }
343 }
344
345 const amountCents = requireSafePositiveInteger(
346 args.amountCents,
347 "INVALID_AMOUNT",
348 "proposedCall.arguments.amountCents",
349 );
350 const destinationRef = requireIdentifier(
351 args.destinationRef,
352 "INVALID_DESTINATION",
353 "proposedCall.arguments.destinationRef",
354 );
355
356 const executionEnabled = await this.#isExecutionEnabled({
357 principalId,
358 tenantId,
359 tool,
360 });
361 if (executionEnabled !== true) {
362 deny("EXECUTION_DISABLED", "the execution kill switch is active");
363 }
364
365 if (!hasOwn(this.#policy.principals, principalId)) {
366 deny("UNAUTHORIZED_PRINCIPAL", "principal is not registered");
367 }
368 const principalPolicy = requireRecord(
369 this.#policy.principals[principalId],
370 "INVALID_POLICY",
371 "principal policy",
372 );
373
374 if (
375 !Array.isArray(principalPolicy.tenants) ||
376 !principalPolicy.tenants.includes(tenantId)
377 ) {
378 deny("UNAUTHORIZED_TENANT", "principal cannot act for this tenant");
379 }
380
381 if (!isRecord(principalPolicy.tools) || !hasOwn(principalPolicy.tools, tool)) {
382 deny("UNAUTHORIZED_TOOL", "tool is not allowed for this principal");
383 }
384 const toolPolicy = requireRecord(
385 principalPolicy.tools[tool],
386 "INVALID_POLICY",
387 "tool policy",
388 );
389
390 const maxAmountCents = requireSafePositiveInteger(
391 toolPolicy.maxAmountCents,
392 "INVALID_POLICY",
393 "tool policy maxAmountCents",
394 );
395 const approvalRequiredAtCents = requireSafePositiveInteger(
396 toolPolicy.approvalRequiredAtCents,
397 "INVALID_POLICY",
398 "tool policy approvalRequiredAtCents",
399 );
400 if (approvalRequiredAtCents > maxAmountCents) {
401 deny(
402 "INVALID_POLICY",
403 "approval threshold cannot exceed the maximum amount",
404 );
405 }
406
407 if (amountCents > maxAmountCents) {
408 deny("AMOUNT_EXCEEDS_POLICY", "amount exceeds the policy maximum");
409 }
410
411 if (
412 !Array.isArray(toolPolicy.allowedDestinationRefs) ||
413 !toolPolicy.allowedDestinationRefs.includes(destinationRef)
414 ) {
415 deny(
416 "UNAUTHORIZED_DESTINATION",
417 "destination is not allowed by policy",
418 );
419 }
420
421 let approvalId = null;
422 if (amountCents >= approvalRequiredAtCents) {
423 const evidence = requireRecord(
424 approvalEvidence,
425 "APPROVAL_REQUIRED",
426 "approvalEvidence",
427 );
428 approvalId = requireIdentifier(
429 evidence.id,
430 "INVALID_APPROVAL",
431 "approvalEvidence.id",
432 );
433
434 const actionDigest = digestAction({
435 principalId,
436 tenantId,
437 tool,
438 amountCents,
439 destinationRef,
440 });
441 const nowMs = this.#clock();
442
443 // Verification (read) and consumption (atomic write) are separated.
444 let approval;
445 try {
446 approval = await this.#approvalStore.getApprovedAction(approvalId);
447 } catch {
448 deny("APPROVAL_STORE_ERROR", "approval store failed closed");
449 }
450
451 if (!isRecord(approval) || approval.decision !== "approved") {
452 deny("INVALID_APPROVAL", "approval service did not approve the action");
453 }
454 if (approval.id !== approvalId) {
455 deny("INVALID_APPROVAL", "approval identifier does not match");
456 }
457 if (
458 !Number.isSafeInteger(approval.expiresAtMs) ||
459 approval.expiresAtMs <= nowMs
460 ) {
461 deny("APPROVAL_EXPIRED", "approval has expired");
462 }
463
464 const expectedBinding = {
465 principalId,
466 tenantId,
467 tool,
468 amountCents,
469 destinationRef,
470 };
471 for (const [field, expectedValue] of Object.entries(expectedBinding)) {
472 if (approval[field] !== expectedValue) {
473 deny(
474 "APPROVAL_SCOPE_MISMATCH",
475 `approval is not bound to the requested ${field}`,
476 );
477 }
478 }
479
480 // Consume before invoking the executor. Only one concurrent request may
481 // succeed; the loser receives APPROVAL_REPLAYED. Consumed state is not
482 // reverted if the executor later fails.
483 let consumed;
484 try {
485 consumed = await this.#approvalStore.consumeIfUnused({
486 approvalId,
487 actionDigest,
488 nowMs,
489 });
490 } catch {
491 deny("APPROVAL_STORE_ERROR", "approval store failed closed");
492 }
493 if (consumed !== true) {
494 deny("APPROVAL_REPLAYED", "approval has already been consumed");
495 }
496 }
497
498 return Object.freeze({
499 principalId,
500 tenantId,
501 tool,
502 arguments: Object.freeze({
503 amountCents,
504 destinationRef,
505 }),
506 approvalId,
507 });
508 }
509}
510
511module.exports = {
512 ApprovalStore,
513 BrokerDenied,
514 ToolBroker,
515 canonicalActionBinding,
516 digestAction,
517 hashActionBinding,
518};
1"use strict";
2
3const assert = require("node:assert/strict");
4const test = require("node:test");
5
6const {
7 ApprovalStore,
8 BrokerDenied,
9 ToolBroker,
10 hashActionBinding,
11} = require("../broker");
12
13const NOW_MS = Date.parse("2026-07-23T12:00:00Z");
14
15const POLICY = Object.freeze({
16 principals: Object.freeze({
17 "agent-workload-alpha": Object.freeze({
18 tenants: Object.freeze(["tenant-alpha"]),
19 tools: Object.freeze({
20 "payments.create": Object.freeze({
21 maxAmountCents: 100_000,
22 approvalRequiredAtCents: 25_000,
23 allowedDestinationRefs: Object.freeze([
24 "approved-destination",
25 ]),
26 }),
27 }),
28 }),
29 }),
30});
31
32function proposedCall(overrides = {}) {
33 return {
34 tenantId: "tenant-alpha",
35 tool: "payments.create",
36 arguments: {
37 amountCents: 10_000,
38 destinationRef: "approved-destination",
39 },
40 ...overrides,
41 };
42}
43
44function approvalRecord(overrides = {}) {
45 return {
46 id: "approval-example-001",
47 decision: "approved",
48 principalId: "agent-workload-alpha",
49 tenantId: "tenant-alpha",
50 tool: "payments.create",
51 amountCents: 30_000,
52 destinationRef: "approved-destination",
53 expiresAtMs: NOW_MS + 60_000,
54 ...overrides,
55 };
56}
57
58function makeHarness({
59 enabled = true,
60 approvals = [],
61 readDelayMs = 0,
62 approvalStore,
63 executor,
64} = {}) {
65 const invocations = [];
66 const auditEvents = [];
67 const store =
68 approvalStore ??
69 new ApprovalStore(approvals, {readDelayMs});
70
71 const broker = new ToolBroker({
72 policy: POLICY,
73 approvalStore: store,
74 executor:
75 executor ??
76 (async (authorizedCall) => {
77 invocations.push(authorizedCall);
78 return {
79 status: "simulated",
80 invocationNumber: invocations.length,
81 };
82 }),
83 isExecutionEnabled: async () => enabled,
84 audit: (event) => auditEvents.push(event),
85 clock: () => NOW_MS,
86 });
87
88 return {
89 auditEvents,
90 broker,
91 invocations,
92 store,
93 };
94}
95
96async function expectDenied(promiseFactory, expectedCode) {
97 await assert.rejects(
98 promiseFactory,
99 (error) => {
100 assert.ok(error instanceof BrokerDenied);
101 assert.equal(error.code, expectedCode);
102 return true;
103 },
104 );
105}
106
107test("allows an authorized low-impact call without approval", async () => {
108 const harness = makeHarness();
109
110 const result = await harness.broker.execute(
111 {principalId: "agent-workload-alpha"},
112 proposedCall(),
113 );
114
115 assert.deepEqual(result, {
116 status: "simulated",
117 invocationNumber: 1,
118 });
119 assert.equal(harness.invocations.length, 1);
120 assert.deepEqual(harness.invocations[0], {
121 principalId: "agent-workload-alpha",
122 tenantId: "tenant-alpha",
123 tool: "payments.create",
124 arguments: {
125 amountCents: 10_000,
126 destinationRef: "approved-destination",
127 },
128 approvalId: null,
129 });
130 assert.equal(harness.auditEvents[0].outcome, "allow");
131 assert.equal(harness.auditEvents[1].outcome, "success");
132});
133
134test("rejects a high-impact call with missing approval", async () => {
135 const harness = makeHarness();
136 const call = proposedCall({
137 arguments: {
138 amountCents: 30_000,
139 destinationRef: "approved-destination",
140 },
141 });
142
143 await expectDenied(
144 () =>
145 harness.broker.execute(
146 {principalId: "agent-workload-alpha"},
147 call,
148 ),
149 "APPROVAL_REQUIRED",
150 );
151 assert.equal(harness.invocations.length, 0);
152});
153
154test("rejects an expired approval", async () => {
155 const approval = approvalRecord({expiresAtMs: NOW_MS});
156 const harness = makeHarness({approvals: [approval]});
157 const call = proposedCall({
158 arguments: {
159 amountCents: 30_000,
160 destinationRef: "approved-destination",
161 },
162 });
163
164 await expectDenied(
165 () =>
166 harness.broker.execute(
167 {principalId: "agent-workload-alpha"},
168 call,
169 {id: approval.id},
170 ),
171 "APPROVAL_EXPIRED",
172 );
173 assert.equal(harness.invocations.length, 0);
174});
175
176test("rejects approval bound to a different amount", async () => {
177 const approval = approvalRecord({amountCents: 30_001});
178 const harness = makeHarness({approvals: [approval]});
179 const call = proposedCall({
180 arguments: {
181 amountCents: 30_000,
182 destinationRef: "approved-destination",
183 },
184 });
185
186 await expectDenied(
187 () =>
188 harness.broker.execute(
189 {principalId: "agent-workload-alpha"},
190 call,
191 {id: approval.id},
192 ),
193 "APPROVAL_SCOPE_MISMATCH",
194 );
195 assert.equal(harness.invocations.length, 0);
196});
197
198test("allows an exactly bound high-impact approval and rejects sequential replay", async () => {
199 const approval = approvalRecord();
200 const harness = makeHarness({approvals: [approval]});
201 const call = proposedCall({
202 arguments: {
203 amountCents: 30_000,
204 destinationRef: "approved-destination",
205 },
206 });
207
208 await harness.broker.execute(
209 {principalId: "agent-workload-alpha"},
210 call,
211 {id: approval.id},
212 );
213 assert.equal(harness.invocations.length, 1);
214 assert.equal(harness.invocations[0].approvalId, approval.id);
215
216 await expectDenied(
217 () =>
218 harness.broker.execute(
219 {principalId: "agent-workload-alpha"},
220 call,
221 {id: approval.id},
222 ),
223 "APPROVAL_REPLAYED",
224 );
225 assert.equal(harness.invocations.length, 1);
226});
227
228test("consumes approval only once under concurrent replay", async () => {
229 const approval = approvalRecord();
230 // Delay the read so both requests overlap before either consumption.
231 const harness = makeHarness({approvals: [approval], readDelayMs: 40});
232 const call = proposedCall({
233 arguments: {
234 amountCents: 30_000,
235 destinationRef: "approved-destination",
236 },
237 });
238 const context = {principalId: "agent-workload-alpha"};
239 const evidence = {id: approval.id};
240
241 const results = await Promise.allSettled([
242 harness.broker.execute(context, call, evidence),
243 harness.broker.execute(context, call, evidence),
244 ]);
245
246 assert.equal(harness.invocations.length, 1);
247 assert.equal(
248 results.filter((result) => result.status === "fulfilled").length,
249 1,
250 );
251 assert.equal(
252 results.filter((result) => result.status === "rejected").length,
253 1,
254 );
255 const rejected = results.find((result) => result.status === "rejected");
256 assert.ok(rejected.reason instanceof BrokerDenied);
257 assert.equal(rejected.reason.code, "APPROVAL_REPLAYED");
258});
259
260test("does not free a consumed approval when the executor fails", async () => {
261 const approval = approvalRecord();
262 const store = new ApprovalStore([approval]);
263 let attempts = 0;
264 const harness = makeHarness({
265 approvalStore: store,
266 executor: async () => {
267 attempts += 1;
268 throw new Error("simulated executor failure");
269 },
270 });
271 const call = proposedCall({
272 arguments: {
273 amountCents: 30_000,
274 destinationRef: "approved-destination",
275 },
276 });
277
278 await assert.rejects(
279 () =>
280 harness.broker.execute(
281 {principalId: "agent-workload-alpha"},
282 call,
283 {id: approval.id},
284 ),
285 /simulated executor failure/,
286 );
287 assert.equal(attempts, 1);
288
289 await expectDenied(
290 () =>
291 harness.broker.execute(
292 {principalId: "agent-workload-alpha"},
293 call,
294 {id: approval.id},
295 ),
296 "APPROVAL_REPLAYED",
297 );
298 assert.equal(attempts, 1);
299});
300
301test("fails closed when approval store read throws", async () => {
302 const store = {
303 async getApprovedAction() {
304 throw new Error("store unavailable");
305 },
306 async consumeIfUnused() {
307 return true;
308 },
309 };
310 const harness = makeHarness({approvalStore: store});
311 const call = proposedCall({
312 arguments: {
313 amountCents: 30_000,
314 destinationRef: "approved-destination",
315 },
316 });
317
318 await expectDenied(
319 () =>
320 harness.broker.execute(
321 {principalId: "agent-workload-alpha"},
322 call,
323 {id: "approval-example-001"},
324 ),
325 "APPROVAL_STORE_ERROR",
326 );
327 assert.equal(harness.invocations.length, 0);
328});
329
330test("fails closed when approval store consume throws", async () => {
331 const approval = approvalRecord();
332 const store = {
333 async getApprovedAction() {
334 return approval;
335 },
336 async consumeIfUnused() {
337 throw new Error("consume unavailable");
338 },
339 };
340 const harness = makeHarness({approvalStore: store});
341 const call = proposedCall({
342 arguments: {
343 amountCents: 30_000,
344 destinationRef: "approved-destination",
345 },
346 });
347
348 await expectDenied(
349 () =>
350 harness.broker.execute(
351 {principalId: "agent-workload-alpha"},
352 call,
353 {id: approval.id},
354 ),
355 "APPROVAL_STORE_ERROR",
356 );
357 assert.equal(harness.invocations.length, 0);
358});
359
360test("rejects all calls while the independently injected kill switch is active", async () => {
361 const harness = makeHarness({enabled: false});
362
363 await expectDenied(
364 () =>
365 harness.broker.execute(
366 {principalId: "agent-workload-alpha"},
367 proposedCall(),
368 ),
369 "EXECUTION_DISABLED",
370 );
371 assert.equal(harness.invocations.length, 0);
372});
373
374test("rejects unknown arguments instead of passing them to the executor", async () => {
375 const harness = makeHarness();
376 const call = proposedCall({
377 arguments: {
378 amountCents: 10_000,
379 destinationRef: "approved-destination",
380 modelSuppliedAuthority: "allow-everything",
381 },
382 });
383
384 await expectDenied(
385 () =>
386 harness.broker.execute(
387 {principalId: "agent-workload-alpha"},
388 call,
389 ),
390 "UNKNOWN_ARGUMENT",
391 );
392 assert.equal(harness.invocations.length, 0);
393});
394
395test("rejects a tenant that the authenticated principal does not own", async () => {
396 const harness = makeHarness();
397
398 await expectDenied(
399 () =>
400 harness.broker.execute(
401 {principalId: "agent-workload-alpha"},
402 proposedCall({tenantId: "tenant-beta"}),
403 ),
404 "UNAUTHORIZED_TENANT",
405 );
406 assert.equal(harness.invocations.length, 0);
407});
408
409test("hashActionBinding is SHA-256 hex and changes when any bound field changes", () => {
410 const base = {
411 principalId: "agent-workload-alpha",
412 tenantId: "tenant-alpha",
413 tool: "payments.create",
414 amountCents: 30_000,
415 destinationRef: "approved-destination",
416 };
417 const digest = hashActionBinding(base);
418 assert.match(digest, /^[a-f0-9]{64}$/);
419
420 for (const [key, value] of Object.entries({
421 principalId: "agent-workload-beta",
422 tenantId: "tenant-beta",
423 tool: "payments.refund",
424 amountCents: 30_001,
425 destinationRef: "other-destination",
426 })) {
427 const mutated = {...base, [key]: value};
428 assert.notEqual(
429 hashActionBinding(mutated),
430 digest,
431 `expected hash to change when ${key} changes`,
432 );
433 }
434});
435
436test("delimiter-containing identifiers do not collide under hashActionBinding", () => {
437 // Under a NUL-joined serialization, ("a\0b", "c") and ("a", "b\0c") collide.
438 // JSON canonicalization must keep them distinct for every bound string field.
439 const left = hashActionBinding({
440 principalId: "a\u0000b",
441 tenantId: "c",
442 tool: "payments.create",
443 amountCents: 1,
444 destinationRef: "dest",
445 });
446 const right = hashActionBinding({
447 principalId: "a",
448 tenantId: "b\u0000c",
449 tool: "payments.create",
450 amountCents: 1,
451 destinationRef: "dest",
452 });
453 assert.notEqual(left, right);
454
455 const joinLeft = ["a\u0000b", "c", "payments.create", "1", "dest"].join("\u0000");
456 const joinRight = ["a", "b\u0000c", "payments.create", "1", "dest"].join("\u0000");
457 assert.equal(joinLeft, joinRight);
458});
Run it
node --test labs/ai-agent-security/tests/broker.test.js
Status: tested teaching implementation. The surrounding production architecture in appsec/ai-agent-security.md is conceptual and partially tested.
This dependency-free Node.js lab demonstrates one narrow security boundary: a model may propose an action, but a broker outside the model decides whether a fake executor receives it. The executor stores an in-memory record and returns status: simulated; it performs no network, filesystem, financial, cloud, or other external action.
Security properties exercised
The broker receives authenticatedContext separately from proposedCall and then:
- resolves a static server-side policy for the authenticated principal;
- rejects an unauthorized principal, tenant, tool, destination, or amount;
- accepts only a closed argument schema;
- requires approval at or above the configured impact threshold;
- asks an injected approval verifier for the authoritative approval record;
- binds approval to principal, tenant, tool, amount, and destination;
- consumes approval before execution to reject replay;
- checks an independently injected execution kill switch;
- emits sanitized decision and outcome events to an in-memory audit sink;
- invokes a fake executor only after all controls allow the call.
The tests assert that the fake executor's invocation count stays zero for denied actions. This distinction matters: a model may still propose a forbidden action, but it must not acquire authority by doing so.
Run
From the repository root with Node.js 22 or newer:
node --test labs/ai-agent-security/tests/broker.test.js
No package installation, environment variable, credential, service, container, or network connection is required.
Files
broker.jscontains the broker and stable denial codes.tests/broker.test.jshas tests for both what should work and what should get denied, plus the fake policy, approval store, audit sink, kill switch, and executor.
Deliberate limitations
This is not a production authorization library. In particular:
authenticatedContextis assumed to come from a trusted gateway. The lab does not validate a session, workload certificate, issuer, signature, audience, freshness, revocation, or tenant membership source.- The policy is an in-process fixture. It has no administrative authorization, durable version, distribution, cache, or outage behavior.
- The approval verifier reads an in-memory map. It does not authenticate an approver, verify a signature, enforce separation of duties, or persist single-use state across processes.
- Replay state, audit events, and fake execution records are memory-only.
- The broker validates one teaching schema for a simulated payment-like action. A real broker needs a reviewed, versioned schema and constraints per tool.
- The lab does not implement MCP, OAuth, token exchange, downstream reauthorization, idempotency reconciliation, a sandbox, secret delivery, egress control, rate limiting, or durable incident evidence.
For production, derive identity and tenant outside the model, use a maintained authorization implementation, bind short-lived credentials to the selected resource, make the downstream service authorize again, and test fail-closed behavior at every boundary.