Dev.to Security 🔐 Cybersecurity 👁 0 📖 10 min read

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-read lets the process read files, but not write them.
  • Child process – a separate OS process launched from the main Node program, usually via child_process.spawn or exec. 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

  1. Import the Bedrock client (@aws-sdk/client-bedrock) – this talks to Claude.
  2. askClaude builds the request payload, sends it, and extracts the raw JavaScript Claude suggests.
  3. The Lambda handler receives a prompt, asks Claude, and decides whether there is any tool code to run.
  4. runInSandbox writes the code to a temporary file and spawns a child process with only --allow-fs-read and --allow-net.
  5. The child’s stdout is captured and returned; any permission violation appears in stderr and causes the promise to reject.

Helpful tip: The --experimental-permission flag 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 the esm shim.
  • 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 /tmp with 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 stderr and 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-flags setting in the Lambda console; otherwise the flags are dropped silently and your sandbox is ineffective.
  • Use child_process.spawn to 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 stderr and cause a non‑zero exit code that you can surface to the API caller.
  • Keep ancillary Lambda gotchas in mind: avoid outdated esm layers, understand SnapStart + VPC limits, set correct Content-Type for 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.

📰 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.