Secure AI Tool Calls in Node.js 22: Using Permission Flags to Sandbox Claude Agents
AI agents can execute arbitrary code when they call tools – a hidden security nightmare. Node.js 22 finally gives you native permission flags to lock down filesystem and network access without containers. Learn how to ha
AI agents can execute arbitrary code when they call tools – a hidden security nightmare. Node.js 22 finally gives you native permission flags to lock down filesystem and network access without containers. Learn how to harness these flags in a Lambda function to keep Claude’s tool calls safe.
Why AI Tool Calls Need Sandboxing
When a language model (LLM) like Anthropic’s Claude is asked to “fetch the latest CSV from S3 and summarize it,” it often replies with a tool call – a piece of code the model thinks should run to satisfy the request. A tool call is simply a request for the surrounding application to execute some operation, such as reading a file, making an HTTP request, or running a shell command.
If you hand that code to Node.js unchecked, the model could ask the runtime to delete files, open a reverse shell, or exfiltrate data. Think of it like giving a guest the keys to your house without asking which rooms they’re allowed to enter.
In plain English: A sandbox is a fenced yard around the code, so the model can only play within the fence you draw.
Without a sandbox, a compromised or mischievous model can turn your server into an attack vector. The traditional answer is to spin up a Docker container or a separate VM for every request – heavy, expensive, and often overkill for a quick Lambda invocation.
Node.js 22 introduced a permission model that works like a built‑in fence: you tell the engine which resources (network, file system, environment variables, etc.) a particular piece of code may touch. The model is enforced at runtime, and if the code tries to go beyond the granted permissions, Node throws an error and aborts the operation.
Because the permission flags only affect child processes (processes spawned from the main Node process) and worker threads (lightweight parallel execution contexts), we can keep the Lambda handler itself lightweight and attach strict fences only to the part that runs the model‑generated code.
Node.js 22 Permission Model Overview
Core concepts
-
Permission flag – a command‑line option that tells Node what it may do. For example,
--allow-fs-readlets the process read files, but not write them. -
Child process – a separate OS process launched from the main Node program, usually via
child_process.spawnorexec. It inherits its own set of permissions. - Worker thread – a Node thread that runs JavaScript in parallel; it also receives its own permission set.
Node’s flag syntax looks like:
node --allow-fs-read --allow-net my-app.js
When you launch a child process with these flags, the child inherits only the permissions you specify. The parent (your Lambda handler) can stay unrestricted or have a different set.
Tip: Think of the parent process as the house manager and the child process as a hired contractor. The manager decides which tools the contractor can use.
How the flags are parsed
Node checks each flag at startup. If a flag is unknown, the process aborts with an error. In AWS Lambda, the runtime ignores unknown flags unless you explicitly enable the nodejs-runtime-flags setting in the function configuration. This is a common source of silent failures: the child process runs without the intended fence, and the model’s tool call either succeeds when it shouldn’t or fails without a clear message.
The minimal permission set for Claude tool calls
A typical Claude‑driven tool call needs:
- Read‑only file system – to load a CSV or JSON file that the model wants to inspect.
- Outbound network – to fetch a remote API or download a file.
That translates to the flags:
--allow-fs-read # can read files, cannot write or delete
--allow-net # can make outbound HTTP/HTTPS requests
No write access (--allow-fs-write) and no access to environment variables (--allow-env) keep the sandbox tight.
Setting Up a Secure Lambda Handler for Claude
Below is a minimal Lambda handler that receives a prompt, calls Claude via the Bedrock SDK, and then hands the model‑generated tool code to a child process that runs under the restricted flags.
Key takeaway: The Lambda runtime itself runs without the flags; only the child process that executes the tool code is fenced.
// handler.ts
import { BedrockClient, InvokeModelCommand } from "@aws-sdk/client-bedrock";
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
import { spawn } from "child_process";
import { Buffer } from "buffer";
// ---------- 1️⃣ Prepare the Bedrock client (no special flags needed) ----------
const bedrock = new BedrockClient({ region: "us-east-1" });
// ---------- 2️⃣ Helper to invoke Claude and extract the tool call ----------
async function askClaude(prompt: string): Promise<string> {
const command = new InvokeModelCommand({
modelId: "anthropic.claude-v2", // example model identifier
contentType: "application/json",
accept: "application/json",
body: JSON.stringify({
prompt,
max_tokens: 1024,
temperature: 0.2,
}),
});
const response = await bedrock.send(command);
// Bedrock returns a Uint8Array; turn it into a string
const payload = Buffer.from(response.body as Uint8Array).toString("utf-8");
const parsed = JSON.parse(payload);
// Claude returns tool calls inside `parsed.output.text`
// For this demo we assume the model returns raw JavaScript code
return parsed.output?.text?.trim() ?? "";
}
// ---------- 3️⃣ Lambda handler ----------
export const handler = async (event: any) => {
// The incoming event is expected to have a `prompt` field
const userPrompt = event.prompt ?? "Summarize the attached CSV";
// Ask Claude for a tool call
const toolCode = await askClaude(userPrompt);
// If Claude didn't suggest any code, just return a friendly message
if (!toolCode) {
return {
statusCode: 200,
body: JSON.stringify({ message: "Claude didn't need a tool call." }),
};
}
// ---------- 4️⃣ Run the tool code in a sandboxed child process ----------
const result = await runInSandbox(toolCode);
// Return the tool's output to the API caller
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result }),
};
};
// ---------- 5️⃣ Function that spawns a child with strict flags ----------
function runInSandbox(code: string): Promise<string> {
return new Promise((resolve, reject) => {
// Write the code to a temporary file (read‑only for the child)
// In a real app you would use a secure temp directory and clean up after.
const fs = require("fs");
const path = require("path");
const tmpFile = path.join("/tmp", `tool-${Date.now()}.js`);
fs.writeFileSync(tmpFile, code, { mode: 0o400 }); // read‑only permission
// Spawn a new Node process with ONLY read and network permissions
const child = spawn(
"node",
[
"--experimental-permission", // enables permission flags
"--allow-fs-read",
"--allow-net",
tmpFile, // script to execute
],
{
stdio: ["ignore", "pipe", "pipe"], // ignore stdin, capture stdout & stderr
}
);
// Collect stdout
let stdout = "";
child.stdout.on("data", (data) => (stdout += data));
// Collect stderr for debugging
let stderr = "";
child.stderr.on("data", (data) => (stderr += data));
child.on("close", (code) => {
// Clean up the temporary file
fs.unlinkSync(tmpFile);
if (code === 0) {
resolve(stdout.trim());
} else {
// Permission errors surface here; surface them to the caller for visibility
reject(
new Error(
`Sandboxed tool failed (exit ${code}). Stderr: ${stderr.trim()}`
)
);
}
});
});
}
What the code does, line by line
- Import the Bedrock client (
@aws-sdk/client-bedrock) – this talks to Claude. -
askClaudebuilds the request payload, sends it, and extracts the raw JavaScript Claude suggests. - The Lambda
handlerreceives a prompt, asks Claude, and decides whether there is any tool code to run. -
runInSandboxwrites the code to a temporary file and spawns a child process with only--allow-fs-readand--allow-net. - The child’s stdout is captured and returned; any permission violation appears in
stderrand causes the promise to reject.
Helpful tip: The
--experimental-permissionflag is required in Node 22 to enable the permission system. Without it, the other--allow‑*flags are ignored.
Lambda‑specific gotchas
-
Runtime flags must be enabled – In the Lambda console, set Node.js runtime flags to
--experimental-permission. Otherwise the flags are silently dropped, and the child process runs with full privileges. -
require(esm) breakage – If you have a layer that forces ECMAScript module loading (
require('esm')), Node 22 may throw an error that disappears in logs. Keep your Lambda layers up‑to‑date or avoid theesmshim. - SnapStart + VPC – Enabling SnapStart while the function is in a VPC does not speed up the VPC attachment phase; the cold start time remains unchanged. Plan capacity accordingly.
-
Response streaming – When you later stream large tool results, set
Content-Type: application/octet-stream(or the appropriate MIME type) or Lambda will buffer the entire payload before sending it.
Running Claude Tool Calls with Limited Permissions
The heart of the sandbox is the spawn call shown earlier. Let’s break down why each option matters.
const child = spawn(
"node",
[
"--experimental-permission", // turn on the permission system
"--allow-fs-read", // allow read‑only file access
"--allow-net", // allow outbound HTTP/S
tmpFile, // the script that contains Claude’s code
],
{
stdio: ["ignore", "pipe", "pipe"], // no stdin, capture output streams
}
);
-
--experimental-permission– tells Node to enforce any following--allow‑*flags. If you omit this, the subsequent flags are parsed but ignored, leaving the child with full access. -
--allow-fs-read– grants read access to any file on the filesystem. Because we wrote the script to/tmpwith read‑only mode, the child cannot modify it. -
--allow-net– lets the child make HTTP/HTTPS requests. If the tool tries to open a listening socket (e.g.,net.createServer), Node will throw a permission error because inbound connections are not covered by this flag.
In plain English: The child process is like a kid in a playground who can only pick up toys (read files) and shout to the outside (make network requests). They can’t break the swings (write files) or open the gate (listen for incoming connections).
Adding a write‑only exception (optional)
If you have a use‑case where the model must write a temporary file (e.g., a CSV transformation), you can tighten the scope by creating a dedicated directory with limited permissions and granting --allow-fs-write only to that path:
mkdir -p /tmp/sandbox-write
chmod 700 /tmp/sandbox-write
Then launch the child with:
spawn("node", [
"--experimental-permission",
"--allow-fs-read",
"--allow-fs-write=/tmp/sandbox-write", // write only inside this folder
"--allow-net",
tmpFile,
]);
Node will reject any attempt to write outside /tmp/sandbox-write, protecting the rest of the filesystem.
Testing and Observing Permission Errors
A sandbox is only as good as your tests. Below is a simple test harness you can run locally (Node 22 required) that simulates a malicious tool call.
// test-sandbox.ts
import { spawn } from "child_process";
import * as fs from "fs";
import * as path from "path";
// 1️⃣ Malicious script that tries to delete a file and open a server
const maliciousCode = `
const fs = require('fs');
const net = require('net');
// Attempt to delete the Lambda environment file (should fail)
try {
fs.unlinkSync('/etc/passwd');
console.log('Deleted /etc/passwd');
} catch (e) {
console.error('Delete failed:', e.message);
}
// Attempt to start a TCP server (should fail)
const server = net.createServer(() => {});
server.listen(9999, () => console.log('Listening on 9999'));
`;
// 2️⃣ Write the code to a temporary file
const scriptPath = path.join(__dirname, "malicious.js");
fs.writeFileSync(scriptPath, maliciousCode, { mode: 0o400 });
// 3️⃣ Spawn a sandboxed child
const child = spawn(
"node",
[
"--experimental-permission",
"--allow-fs-read", // note: no write permission
"--allow-net", // network allowed, but not listening
scriptPath,
],
{ stdio: ["ignore", "pipe", "pipe"] }
);
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
child.on("close", (code) => {
console.log("=== STDOUT ===");
console.log(out);
console.log("=== STDERR ===");
console.log(err);
console.log(`Child exited with code ${code}`);
// Clean up
fs.unlinkSync(scriptPath);
});
When you run node test-sandbox.ts, you should see errors like:
Delete failed: EPERM: operation not permitted, unlink '/etc/passwd'
Error: listen EACCES: permission denied 0.0.0.0:9999
at ...
The exit code will be non‑zero, indicating the sandbox blocked the dangerous actions.
Key takeaway: Permission errors surface on
stderrand cause the child to exit with a non‑zero status, which you can translate into an informative Lambda response.
Observing Lambda behavior
Deploy the handler to AWS Lambda with the following settings:
| Setting | Value |
|---|---|
| Runtime | Node.js 22.x |
| Handler | handler.handler |
Environment variable NODE_OPTIONS
|
--experimental-permission |
| Node.js runtime flags (in the console) | --experimental-permission |
| Memory | 256 MiB (enough for a small child process) |
| Timeout | 15 seconds (adjust as needed) |
If you forget to add the runtime flags field, the child will run with full access and you’ll see the tool code succeed even when it should be blocked. The Lambda logs (/aws/lambda/...) will show a message like Permission flags ignored – child process had unrestricted rights.
The Takeaway
In plain English: Node.js 22 gives you a light‑weight, built‑in fence that you can attach to any piece of code you run inside a Lambda. You don’t need a full container sandbox to keep AI‑generated tool calls safe.
- Permission flags (
--allow-fs-read,--allow-net, etc.) are enforced only in child processes or worker threads; the Lambda handler itself stays unrestricted unless you also launch it with flags. - Enable the
nodejs-runtime-flagssetting in the Lambda console; otherwise the flags are dropped silently and your sandbox is ineffective. - Use
child_process.spawnto execute the model‑generated script, passing the minimal set of flags that match the tool’s needs. - Test aggressively with intentionally malicious scripts; permission errors appear on
stderrand cause a non‑zero exit code that you can surface to the API caller. - Keep ancillary Lambda gotchas in mind: avoid outdated
esmlayers, understand SnapStart + VPC limits, set correctContent-Typefor streaming responses, and watch provisioned concurrency costs.
By applying these steps, you can let Claude—or any LLM—run helpful tool calls while keeping the rest of your AWS environment locked down. Happy sandboxing!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-10-27 · Primary focus: NodeJS22
All code blocks are intended to be correct and runnable, but please verify them
against the Node.js docs before using in production.Find an error? Drop a comment — corrections are always welcome.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.