Running Scripts You Don't Fully Trust: Deno's Permission Flags as a Design Decision
Deno runs scripts with zero file, network, or env access by default. Here's how scoped permission flags turn running third-party scripts into a reviewable, least-privilege decision.
16 Mar 2026, 18:55 UTC

The problem: your script runner trusts everything
Someone on your team pastes a handy utility script into the repo — a Markdown linter, a CSV converter, a small fetch-and-transform job. Under Node.js, the moment you run it, that script can read your SSH keys, enumerate your environment variables, and open sockets to anywhere. Nothing stops it, because nothing was ever asked. The runtime assumes that if you started the process, you meant to grant it everything the OS allows your user to do.
Deno makes the opposite bet: a script starts with no access to the file system, network, environment variables, or subprocesses, and every capability must be granted explicitly on the command line. That is not just a security feature — it is an engineering decision about where trust should live, and it changes how you review and run third-party code.
Least privilege as a CLI flag
The permission model is flag-based. The common grants are --allow-read, --allow-write, --allow-net, --allow-env, and --allow-run. Crucially, each can be scoped: --allow-read=./data permits reading only that directory, and --allow-net=api.example.com permits network calls to only that host. A bare flag grants the whole capability; a scoped flag grants a slice of it.
There are also --deny-* counterparts, which subtract from a broader grant. For example, --allow-read --deny-read=./secrets allows general file reads except one directory — useful when a script legitimately needs broad access but you want a hard line around something sensitive.
A worked example: one config file, one API
Here is a small script that reads a local config file and calls one HTTPS endpoint:
// report.ts
const config = JSON.parse(await Deno.readTextFile("./config.json"));
const res = await fetch(`https://api.example.com/v1/status?project=${config.project}`);
console.log(await res.json());Run it with no flags, from a shell where the deno binary is on your PATH (no special permissions needed beyond your normal user):
deno run report.tsDepending on your Deno version and whether you are on an interactive terminal, you will either get a permission prompt asking to allow the read, or an immediate NotCapable error. Either way, the script cannot touch the file. That failure is the feature: you now know exactly what the script wants before it gets anything.
Now grant the minimum it actually needs:
deno run --allow-read=./config.json --allow-net=api.example.com report.tsExpected result: the script runs and prints the API response. Two useful checks: temporarily point the fetch at a different host and confirm the run is denied again (proving the host scoping works), and try reading a second file from the script to confirm the path scoping holds. If both attempts fail with permission errors, your grant set is genuinely minimal.
Make the contract reviewable with deno.json tasks
Typed-out flags are easy to drift. Deno's config file lets you pin the permission set into a named task, so the grant becomes version-controlled and shows up in code review like any other change:
{
"tasks": {
"report": "deno run --allow-read=./config.json --allow-net=api.example.com report.ts"
}
}Now deno task report runs with exactly those grants, and a pull request that widens them — say, swapping in --allow-net without a host — is a visible diff a reviewer can question. This is where the model pays off organizationally: the permission set becomes an artifact, not a habit.
The honest trade-offs
First, friction. For a throwaway script, answering prompts or constructing scoped flags is slower than just running the thing. That friction is the price of the guarantee, and it is why -A (--allow-all) exists — but -A silently erases the entire benefit. Treat any -A in a committed task file the way you would treat sudo in a build script: occasionally justified, always worth a comment.
Second, the sandbox has edges. Permissions are enforced at the runtime boundary for Deno's own APIs. Code that escapes through a child process you explicitly allowed via --allow-run, or through FFI/native addons, is outside that enforcement. And nothing in the flag model protects you from a malicious dependency in your import graph — supply-chain hygiene is a separate problem. The permission system narrows what a script can do; it does not make arbitrary third-party code safe.
Where to start
Pick one script your team runs regularly that came from outside — a codegen tool, a data fetcher — and run it with no flags. Note what it asks for. Then write a deno.json task granting exactly that, scoped as tightly as the syntax allows, and verify by deliberately breaking one assumption (wrong host, extra file) to watch it get denied. Flag names and scoping syntax have shifted across Deno releases, so confirm against deno --help on the version you actually have installed before committing the task. The whole exercise takes under an hour and leaves you with a permission contract you can point at in the next review.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.