Put an AI-written CLI on a least-privilege budget: grant one input and one output directory, then make forbidden capabilities fail under Node 24.
An AI assistant can produce a command-line tool that reads a brief, writes a report, and passes every happy-path test. That result answers whether the tool can finish one job. It does not answer what else the process can read, overwrite, or launch while it is doing that job.
Review the capability boundary as executable behavior. In this guide, a synthetic CLI needs exactly one input file and one output directory. It does not need a private sibling file, a write path outside that directory, a child process, or a worker thread. A two-file Node 24 fixture runs the candidate under three permission sets and makes those negative requirements fail loudly when a grant grows too broad.
The goal is not to prove that AI-written code is uniquely dangerous. Generated code and human code can both inherit ambient authority that nobody meant to give them. The useful distinction is between “the command worked” and “the command worked with only the declared capabilities.” The second claim needs a ledger, denied probes, a pinned runtime, and a release decision whose scope is small enough to defend.
Write the Capability Ledger Before the Flags
Start with the operation, not with a list of Node switches. The example CLI consumes input/brief.json and produces one result inside an already-created out directory. The review does not grant a whole project merely because resolving its path is convenient. It also records capabilities the candidate must not have, because absence is part of the interface.
| Capability | Needed? | Fixture evidence | Release requirement |
|---|---|---|---|
| Read the approved brief | Yes | Parse one absolute file path | Allowed only in the exact-contract matrix |
| Read the private sibling | No | Attempt private/secret.txt | ERR_ACCESS_DENIED |
Write inside out | Yes | Create one named result | Allowed only after the output grant |
| Write beside the private file | No | Attempt a forbidden output | ERR_ACCESS_DENIED |
| Spawn another process | No | Attempt node --version | ERR_ACCESS_DENIED |
| Create a worker thread | No | Attempt a minimal worker | ERR_ACCESS_DENIED |
| Use the network | No | Not controlled by this model | Keep out of scope or isolate separately |
The entry script is not listed as an application data capability. Node 24.18.0 automatically includes application entry points in the allowed read list when the Permission Model starts. That is why the probe itself can load in the zero-grant matrix while its attempt to read the approved brief is denied. If the candidate loads additional modules or configuration, those paths must be represented in the real ledger and fixture.
Keep the ledger under human ownership. An assistant can inspect imports and suggest likely requirements, but it cannot decide that a private directory is harmless, that subprocess access is normal, or that a broad wildcard is an acceptable convenience. Those are architecture and operations decisions with consequences beyond the generated function.
Read the Node 24 Model as a Seat Belt, Not a Sandbox
In Node.js 24.18.0, the Permission Model is stable and opt-in. Starting a process with --permission restricts covered capabilities unless a matching allow flag is present. The covered set includes file access through node:fs, child processes, worker threads, native addons, WASI, and the runtime inspector. This fixture focuses on the first three categories.
Node’s own documentation describes the model as a “seat belt” for trusted code and explicitly says it does not protect against malicious code. Do not run an untrusted package, prompt-produced program, or hostile plugin and advertise --permission as a sandbox. An attacker can use documented gaps and host capabilities that this model does not mediate. Strong isolation still belongs at the operating-system, container, virtual-machine, or separate-service boundary.
There is also no network permission in the Node 24.18.0 Permission Model. No --allow-network flag appears in the pinned capability list, and enabling --permission does not establish that outbound requests are blocked. If a CLI must not reach the network, prove that with a separate controlled test and an external enforcement layer. Do not infer a network denial from green file, child-process, and worker assertions.
Pin the runtime rather than saying “Node 24” loosely. Permission behavior has changed within major release lines, and examples written for a later point release may use semantics or APIs unavailable in 24.18.0. Record the exact executable, process.version, platform, flags, fixture hash, and date. Re-run the contract when any of those change.
Use Three Matrices So Green Means Something
A least-privilege test needs more than a successful restricted run. The zero-grant matrix proves the probes really encounter the restriction layer: all six operations are denied, including the two the application ultimately needs. That matrix is expected to pass its assertions while the candidate remains on HOLD because it cannot do its work.
The exact-contract matrix grants one input path and the existing output directory. Approved read and write operations succeed. The private read, forbidden write, child process, and worker stay denied. This is the only matrix that can support RELEASE, and even then only for the named operations under the recorded environment.
The overbroad matrix is an intentional counterexample. It grants both file wildcards plus child processes and workers. Every operation succeeds, including the ones the ledger forbids. The test itself is green because it accurately detects the broad reach, but the candidate decision is HOLD. This prevents the common mistake of equating a zero exit code with an acceptable authority boundary.
Build a Package-Free Two-File Fixture
The inner file is the restricted probe. It performs each requested operation, catches structured denials, and emits one JSON report. It does not decide RELEASE. The outer node:test harness is intentionally unrestricted because it must create synthetic files, start restricted child processes, and clean up the temporary tree. Keeping orchestration outside the restricted candidate also makes each permission invocation explicit.
Save this first block as cli-permission-probe.mjs. The data-fixture-file attribute on the published block identifies the exact filename for mechanical extraction.
import { spawnSync } from 'node:child_process';
import { once } from 'node:events';
import { readFileSync, writeFileSync } from 'node:fs';
import { Worker } from 'node:worker_threads';
const [approvedInput, privateInput, approvedOutput, forbiddenOutput] =
process.argv.slice(2);
function denied(error) {
return {
allowed: false,
code: error?.code ?? null,
permission: error?.permission ?? null,
resource: error?.resource ?? null,
};
}
function attempt(name, operation) {
try {
return { name, allowed: true, value: operation() };
} catch (error) {
return { name, ...denied(error) };
}
}
async function attemptAsync(name, operation) {
try {
return { name, allowed: true, value: await operation() };
} catch (error) {
return { name, ...denied(error) };
}
}
const results = [
attempt('approved-read', () =>
JSON.parse(readFileSync(approvedInput, 'utf8')).job),
attempt('private-read', () => readFileSync(privateInput, 'utf8').trim()),
attempt('approved-write', () => {
writeFileSync(approvedOutput, '{"status":"ready"}\n', 'utf8');
return 'written';
}),
attempt('forbidden-write', () => {
writeFileSync(forbiddenOutput, 'should-not-exist\n', 'utf8');
return 'written';
}),
attempt('child-process', () => {
const child = spawnSync(process.execPath, ['--version'], {
encoding: 'utf8',
windowsHide: true,
});
if (child.error) throw child.error;
return { status: child.status, stdout: child.stdout.trim() };
}),
];
results.push(await attemptAsync('worker-thread', async () => {
const worker = new Worker('process.exit(0)', { eval: true });
const [exitCode] = await once(worker, 'exit');
return { exitCode };
}));
console.log(JSON.stringify({
runtime: process.version,
platform: process.platform,
execArgv: process.execArgv,
declaredPermissions: {
approvedRead: process.permission.has('fs.read', approvedInput),
privateRead: process.permission.has('fs.read', privateInput),
approvedWrite: process.permission.has('fs.write', approvedOutput),
forbiddenWrite: process.permission.has('fs.write', forbiddenOutput),
childProcess: process.permission.has('child'),
workerThread: process.permission.has('worker'),
},
results,
}));
Save the second block as permission-contract.test.mjs in the same directory. It uses only built-in modules, passes arguments as an array instead of constructing a shell command, and derives the child executable from process.execPath. That last choice keeps the three matrices on the same pinned runtime as the outer harness.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { after, test } from 'node:test';
const fixtureDir = mkdtempSync(join(tmpdir(), 'node-permission-contract-'));
const approvedInput = join(fixtureDir, 'input', 'brief.json');
const privateInput = join(fixtureDir, 'private', 'secret.txt');
const outputDir = join(fixtureDir, 'out');
const forbiddenOutput = join(fixtureDir, 'private', 'forbidden-output.txt');
const probe = join(dirname(fileURLToPath(import.meta.url)),
'cli-permission-probe.mjs');
const evidence = [];
mkdirSync(dirname(approvedInput), { recursive: true });
mkdirSync(dirname(privateInput), { recursive: true });
mkdirSync(outputDir, { recursive: true });
writeFileSync(approvedInput, '{"job":"release-report"}\n', 'utf8');
writeFileSync(privateInput, 'operator-secret\n', 'utf8');
function runMatrix(label, flags, outputName) {
const approvedOutput = join(outputDir, outputName);
const child = spawnSync(process.execPath, [
'--permission',
...flags,
probe,
approvedInput,
privateInput,
approvedOutput,
forbiddenOutput,
], {
encoding: 'utf8',
windowsHide: true,
});
assert.equal(child.status, 0,
`${label} exited ${child.status}: ${child.stderr}`);
assert.equal(child.signal, null);
return {
label,
approvedOutput,
stderr: child.stderr.trim(),
report: JSON.parse(child.stdout),
};
}
function result(report, name) {
return report.results.find((item) => item.name === name);
}
test('zero grants deny every requested capability', { concurrency: false }, () => {
const observed = runMatrix('zero-grant', [], 'zero.json');
for (const name of [
'approved-read',
'private-read',
'approved-write',
'forbidden-write',
'child-process',
'worker-thread',
]) {
assert.equal(result(observed.report, name).allowed, false, name);
assert.equal(result(observed.report, name).code, 'ERR_ACCESS_DENIED', name);
}
assert.equal(existsSync(observed.approvedOutput), false);
evidence.push({ matrix: observed.label, decision: 'HOLD',
reason: 'required input and output are denied' });
});
test('exact path grants release only the declared file contract',
{ concurrency: false }, () => {
const observed = runMatrix('exact-contract', [
`--allow-fs-read=${approvedInput}`,
`--allow-fs-write=${outputDir}`,
], 'exact.json');
assert.deepEqual(observed.report.declaredPermissions, {
approvedRead: true,
privateRead: false,
approvedWrite: true,
forbiddenWrite: false,
childProcess: false,
workerThread: false,
});
assert.deepEqual(result(observed.report, 'approved-read'), {
name: 'approved-read', allowed: true, value: 'release-report',
});
assert.equal(result(observed.report, 'approved-write').allowed, true);
for (const name of [
'private-read',
'forbidden-write',
'child-process',
'worker-thread',
]) {
assert.equal(result(observed.report, name).allowed, false, name);
assert.equal(result(observed.report, name).code, 'ERR_ACCESS_DENIED', name);
}
assert.equal(existsSync(observed.approvedOutput), true);
evidence.push({ matrix: observed.label, decision: 'RELEASE',
reason: 'tested operations match the capability ledger' });
});
test('wildcards plus child and worker grants are intentionally overbroad',
{ concurrency: false }, () => {
const observed = runMatrix('overbroad', [
'--allow-fs-read=*',
'--allow-fs-write=*',
'--allow-child-process',
'--allow-worker',
], 'overbroad.json');
assert.equal(observed.report.results.every((item) => item.allowed), true);
assert.equal(result(observed.report, 'child-process').value.status, 0);
assert.equal(result(observed.report, 'worker-thread').value.exitCode, 0);
assert.equal(existsSync(forbiddenOutput), true);
evidence.push({ matrix: observed.label, decision: 'HOLD',
reason: 'unneeded files, child processes, and workers are reachable' });
});
after(() => {
console.log(JSON.stringify({
runtime: process.version,
platform: process.platform,
selectedProfile: 'exact-contract',
selectedProfileDecision: evidence.find(
(item) => item.matrix === 'exact-contract',
)?.decision ?? 'HOLD',
evidence,
}, null, 2));
rmSync(fixtureDir, { recursive: true, force: true });
});
Run the fixture from that directory with the pinned Node 24.18.0 executable:
node --test permission-contract.test.mjs
The harness creates a unique temporary root containing input/brief.json, private/secret.txt, and out/. Creating out before Node initializes its permissions is deliberate. In this release, an existing directory grant is treated as a directory wildcard; a nonexistent directory is treated as only that literal path unless the grant explicitly includes a wildcard. The fixture removes the entire synthetic tree in its final hook.
On the pinned Windows run, the evidence reported v24.18.0, win32, three tests, three passes, and zero failures, skips, cancellations, or todos. It named exact-contract as the selected profile and emitted selectedProfileDecision: "RELEASE". The zero-grant and overbroad control profiles remained HOLD. Durations are machine-specific and are not release criteria.
Assert the Error Shape, Not an English Message
A denied Node operation throws an error with stable machine-readable evidence such as code: "ERR_ACCESS_DENIED". Permission errors can also include a permission category and resource. The fixture asserts the code for each denied operation and keeps those extra fields in the inner report for diagnosis. It does not compare a full stack trace or prose message that may vary by platform or release.
The positive probes matter just as much. The exact matrix checks the approved input’s actual value, verifies that the approved output exists, and checks process.permission.has() for every declared path and process capability. A permission query is useful supporting evidence, but it is not a substitute for performing the operation. Paths can be misspelled, adapters can use a different mechanism, and an apparently correct flag can still fail at the real boundary.
The forbidden output is placed beside the private input rather than in a distant arbitrary directory. That design catches a practical overgrant: a reviewer may allow an entire temporary root when the command only needs one output subtree. In the broad matrix the forbidden file is genuinely created, proving that the negative probe is live. Because all data is synthetic and the harness cleans its unique root, the demonstration does not touch a user file.
Treat Paths and Wildcards as Security-Relevant Inputs
Use absolute paths in release fixtures and pass repeated allow flags as separate arguments when more than one path is needed. Node accepts relative paths resolved from the current working directory, but a service manager, test runner, package script, or deployment wrapper can change that directory. An absolute fixture eliminates one source of accidental drift.
Do not assume a wildcard behaves like a familiar shell glob. In the pinned documentation, characters after the first * are ignored, so a pattern such as /home/*.js behaves like /home/*. The broad matrix uses exactly * because its purpose is to expose total file reach, not to suggest a production pattern.
Directory existence also affects interpretation, as the harness demonstrates by creating out first. If production creates an output directory after startup, either pre-create and verify it or declare the intended wildcard explicitly and test the resulting boundary. A passing developer command with a pre-existing directory can describe a different grant from a clean deployment where that directory is absent.
Symbolic links are another hard stop. Node 24.18.0 warns that links are followed even when they lead outside the granted paths, and relative symbolic links can enable arbitrary file access. Inspect the granted tree, reject unexpected links, and use stronger filesystem or OS isolation when the path contents are not fully controlled. A textual allow list is not a filesystem jail.
Know the Gaps Your Fixture Does Not Close
File permissions cover access through node:fs; the Node documentation specifically notes that other mechanisms, including node:sqlite, do not inherit the same guarantee. Existing file descriptors can bypass the model. Files read during pre-initialization by options such as --env-file or --openssl-config are also outside the normal permission check. Audit the complete launch command and inherited descriptors, not only application imports.
Child-process and worker grants deserve special suspicion. The overbroad matrix proves only that a child can start and a worker can exit successfully. It does not certify what the child may do. The model also does not inherit to a worker thread, so --allow-worker is not a narrow substitute for granting a single named task. If the candidate does not need either feature, denial is the cleanest contract.
The example does not probe native addons, WASI, or inspector activation. Those capabilities remain denied by --permission unless separately allowed, but a release involving them needs dedicated positive and negative fixtures. Do not turn their absence from this article into a claim that a real application’s addon loading or diagnostic setup has been certified.
Finally, this is not a dependency review. A package can execute installation scripts before the tested CLI starts, and a wrapper such as npx may need broad cache or global-module reads to locate a package. Freeze dependencies, inspect lifecycle behavior, and test the deployed entry command. A clean two-file bench establishes Node runtime behavior; it does not absolve the surrounding supply chain.
Keep AI Changes Inside the Evidence Boundary
An assistant can help enumerate imports, generate additional negative probes, or explain a denial. It should not silently widen a grant to make a failing test pass. Treat edits to the flags, synthetic tree, expected denied operations, and decision logic as security-sensitive review changes. A patch that changes --allow-fs-read=<file> to --allow-fs-read=* is not a test repair; it is a capability expansion.
Watch for subtler evidence laundering. The assistant may catch every error and return success, replace exact error assertions with snapshots, remove the forbidden probes, or mark every green test as RELEASE. The outer harness keeps the decision independent from the inner process, and the matrix table makes a deliberately green HOLD understandable to reviewers.
Store the fixture beside the CLI or in its release evidence, not only in a chat transcript. Retain the raw test output, exact Node binary version, target platform, launch arguments, capability ledger, and code review. When the input location, output path, deployment user, service wrapper, module graph, or runtime changes, reopen the decision and run the fixture in the real target environment.
Turn the Matrix Into an Explicit HOLD or RELEASE
| Observed matrix | Expected evidence | Decision | Meaning |
|---|---|---|---|
| Zero grants | All six probes denied | HOLD | The restriction is active, but required work cannot finish |
| Exact input plus existing output directory | Approved read/write succeed; four negatives stay denied | RELEASE | Only the tested local file contract is accepted |
| File wildcards plus child and worker | All six probes succeed | HOLD | The command works with authority it does not need |
| Different Node point release or platform | No fresh run | HOLD | Re-run because permission and path behavior may differ |
| Granted tree contains unreviewed links | Path may escape its apparent boundary | HOLD | Inspect links or use stronger isolation |
| Network denial is required | No Node 24.18 permission controls it | HOLD | Add external enforcement and separate evidence |
RELEASE is intentionally narrow. It means this version of the package-free candidate, run with the exact file grants on the recorded runtime and platform, completed the approved read and write while the four named negative operations were denied. It does not mean the process is sandboxed, the network is blocked, every filesystem API is mediated, or future code changes remain least-privileged.
That narrow language is the point. Permission flags are useful when they convert an architectural assumption into a repeatable failure. The strongest review result is not a long allow list. It is a small capability ledger, a fixture that proves both success and refusal, and a HOLD decision whenever the observed reach exceeds the job.
Primary Sources and Scope
- Node.js 24.18.0 Permissions documentation defines the stable opt-in model, its seat-belt threat model, runtime query, file rules, constraints, and known limitations.
- Node.js 24.18.0
--permissionCLI documentation lists the capabilities restricted when the model is enabled. - Node.js 24.18.0
--allow-fs-readand--allow-fs-writedocument path grants, repetition, directory behavior, and wildcards. - Node.js 24.18.0
--allow-child-processand--allow-workerdefine the explicit process and worker grants. - Node.js 24.18.0 test runner, strict assertions, child processes, and worker threads define the built-in APIs used by the fixture.
Sources and the runnable fixture were checked on August 29, 2026. The recorded run used Node.js v24.18.0 on Windows. Scope is limited to synthetic file, child-process, and worker probes in the two files shown. It excludes malicious-code isolation, outbound network control, native addons, WASI, inspector behavior, SQLite access, install scripts, inherited descriptors, database effects, and certification on another operating system or Node release.
Review Your Draft in One Workspace
Check AI-likelihood signals, revise structure and tone, and review the result before you publish.
Open AI Humanizer