← Blog

What --permission was not checking

August 22, 2026

oam takes Node's --permission flag and its shape: the flag denies everything, and you grant capabilities back one at a time. Seven categories are gated — filesystem read, filesystem write, network, environment, native addons, child processes and workers — behind --allow-fs-read, --allow-fs-write, --allow-net, --allow-env, --allow-addons, --allow-child-process and --allow-worker. A denied call throws ERR_ACCESS_DENIED, carrying the permission and resource properties Node attaches, so code that branches on Node's error shape branches identically here.

That is the description. This post is about an audit that asked what the flag was actually enforcing, and the answer for the filesystem was nine calls out of forty-seven.

Nine of forty-seven

With --permission and no grants at all — the most restrictive thing you can ask for — all of this worked:

The ops that did check checked correctly, and that is what made the rest hard to see: the flag was visibly working. A permission model covering most of a surface is indistinguishable from one covering all of it, right up until the call you were counting on being denied turns out to be one that was never wired.

Every path-based op checks now, with read and write classified against Node's own model rather than by guess: copyFile reads the source and writes the destination, rename writes both names, link reads the existing name and writes the new one.

The exemption, stated out loud

Four ops are deliberately still unchecked: the fd-based read, write, close and fstat. A descriptor can only have come from open, and open checks — so the capability is already gated at the only door that issues one, and checking again at point of use would be re-gating a handle whose provenance is settled.

That is a defensible exemption, and from the outside it is shaped exactly like an oversight. Which is why it is written down, here and in the source, instead of waiting to be rediscovered as a gap.

Where oam is deliberately stricter than Node: realpath

Measured against Node v22.22.2: with --permission and no grants, Node allows fs.realpathSync() on any path, including absolute paths outside the working directory, while denying every other path-based fs op. oam requires a read grant for realpath like the rest of the read family.

The reasoning is that a sandbox willing to answer "does this path exist, and where does it resolve to" is handing over most of what a filesystem read is for. Existence and resolution are the interesting part.

This is the one place oam is tighter than Node's model rather than matching it, and the cost lands on you: code written against Node's carve-out needs --allow-fs-read for that path here. It is in docs/node-divergences.md with every other divergence.

The one-line bypass

child_process and worker_threads consulted the permission model not at all.

execSync ran whatever you gave it. A Worker — or an oam.fork() isolate — was constructed with every permission granted, regardless of what its parent was started with. A process run under --permission --allow-fs-read=./data was one new Worker() away from an unrestricted one. Every filesystem and network restriction on it was advisory: not weakened, bypassable in a line, by code that did not have to be trying.

Spawning now checks the child permission, including the extra-fd spawn path. Starting an isolate checks worker. And a child isolate inherits its parent's permission set instead of being built fresh with everything granted.

Why --allow-worker stopped implying --allow-child-process

--allow-worker used to hand you child-process spawning along with it, and at the time that was the honest thing to do. A worker ran all-granted, so it could spawn its way out whether or not you had asked for that. Withholding --allow-child-process from someone holding --allow-worker would have been a distinction the runtime could not enforce — a checkbox, not a boundary.

Inheritance removes the premise. A worker carries its parent's set now, so it cannot spawn unless the parent could, and the implication collapses into pure over-granting: everyone who wanted a worker was handed process spawning as well. Node keeps the two separate, and now so does oam.

An implication like that gets reinstated by someone reading the old rationale and finding it convincing — it was convincing, once. So it has a regression test named after the mistake:

$ oam --permission --allow-worker worker_implies_child.mjs
spawn=ERR_ACCESS_DENIED

The test asserts that exact string. If the implication comes back, the build goes red with the reason attached to it.

The gate that keeps it shut

Everything above is one audit, and audits rot. So the rule is a build gate rather than a habit: a source-level test fails the build if any op that touches the filesystem, spawns a process or starts an isolate ships without either a permission check or an explicit, reasoned exemption. The read/write/close/fstat carve-out above is written in that second form, which is what keeps it an exemption instead of a hole.

It caught a second spawn entry point while it was being written. The first one was the path everybody knew about.

What this is worth

None of this came from a report. It came out of reading the permission surface end to end, and every gap described above existed in shipped releases that somebody could have been running with --permission on. A permission flag that under-enforces quietly is worse than no flag, because the flag is what produces the confidence — nobody audits the sandbox they were told they already had.

All of it is in the current release. oam is beta: breaking changes before 1.0 are still possible, and there is no LTS, so this is not the runtime for a service you are on call for. And --permission is worth exactly what a process-level permission model is ever worth — a defence-in-depth layer, not the only thing standing between untrusted code and the machine. As that layer it now covers the whole fs surface, child_process and worker_threads.

oam --permission --allow-fs-read=./data script.ts

Grant it nothing and watch what breaks. It is the fastest way to find out what your dependency tree actually reaches for.