Designing CLI Tools with Deno's Permission Model: From Prototype to Production
Deno's default‑deny model forces you to declare exactly what a CLI may read, write, or connect to. This post shows minimal flags in deno.json, runtime permission requests, and turning denials into deterministic CI checks—while noting path‑based limits.
26 Oct 2025, 04:30 UTC

The problem: implicit trust in every dependency
Most JavaScript runtimes give a script full access to the filesystem, network, and environment once it starts. A single compromised package can read ~/.ssh/id_rsa, scrape .env files, or open outbound connections without any signal to the developer. Deno flips this default: a script runs in a V8 isolate with zero capabilities until you explicitly grant them via command‑line flags such as --allow-read, --allow-write, --allow-net, and --allow-env. This default‑deny model is enforced at the runtime level and cannot be bypassed from user code.
Minimal permission declaration in a CLI project
When building a command‑line tool, declare the smallest permission set in a deno.json task. The task becomes the single source of truth for both local development and CI.
{
"tasks": {
"build": "deno run --allow-read=./content,./site.config.json --allow-write=./dist build.ts"
}
}
Running deno task build executes build.ts with read access limited to the content directory and the single config file, and write access only to dist. No access to /etc, ~/.ssh, or any network endpoint is possible unless you add the corresponding flags.
Worked example: static‑site generator
Assume build.ts reads Markdown from ./content, loads ./site.config.json, and writes HTML to ./dist. The permission set above is exactly what the program needs. If a future dependency tries to read process.env.HOME or open a socket, the runtime throws a PermissionDenied error with a clear message such as Requires net access to api.example.com.
To verify the flag set works, run the task in a clean container:
# In a fresh Docker image with only Deno installed
deno task build
# Expected: exit code 0, HTML files appear under ./dist
# Omit a flag to see the denial:
deno run --allow-read=./content build.ts
# Expected: PermissionDenied for ./site.config.json
This deterministic failure turns security review into a test step rather than a manual audit.
Requesting additional permissions at runtime
Some tools need user‑specific paths (e.g., a global config file). Deno provides Deno.permissions.request() which prompts interactively in a TTY and throws in non‑TTY environments.
// main.ts
const status = await Deno.permissions.request({ name: "read", path: "/home/user/.mytoolrc" });
if (status.state === "granted") {
console.log(await Deno.readTextFile("/home/user/.mytoolrc"));
} else {
console.error("Read permission denied");
Deno.exit(1);
}
Run locally with deno run --prompt main.ts to see the console prompt. In CI (GitHub Actions, GitLab CI) the same call throws PermissionDenied unless the flag --allow-read=/home/user/.mytoolrc is pre‑granted. Always test the exact flag set in a headless container before merging.
Trade‑offs and limitations
- Path‑based, not capability‑based:
--allow-read=/home/usergrants recursive access to every subdirectory. There is no pattern like "read only *.json". If finer control is needed, copy required files into a sandbox directory via a wrapper script. - No wildcard subdomains:
--allow-net=*.example.comis invalid. You must list each host:port tuple (e.g.,--allow-net=api.github.com:443) or route traffic through a single allowed proxy. - Environment variables are all‑or‑nothing per name:
--allow-env=DATABASE_URLpermits only that variable. A script that iteratesDeno.env.toObject()fails unless every referenced key is listed. - Compiled binaries embed permissions:
deno compile --allow-read=./data --allow-write=./out -o mytool main.tsbakes the allow‑list into the binary. Changing permissions requires recompilation; they cannot be overridden at runtime. This is intentional for supply‑chain integrity but surprises teams used to runtime flags. - Native plugins and child processes bypass the sandbox: FFI calls or
Deno.Commandinherit the host OS privileges of the Deno process itself.
CI/CD integration pattern
Add a dedicated job that runs the exact deno task commands used in production. Because permission failures exit with non‑zero codes and descriptive messages, the pipeline fails fast and the log tells you precisely which capability is missing.
# .github/workflows/deno.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v1
with:
deno-version: v1.40
- run: deno task build # fails if any flag is missing
- run: deno test --allow-read=./tests --allow-net=api.example.com:443
For monorepos, share a common deno.json at the workspace root and extend it per package, or generate allow‑lists programmatically to avoid duplication.
Ecosystem aid: JSR package metadata
Deno's standard library (std@0.224.0) and JSR packages increasingly declare their permission expectations in jsr.json (e.g., an exports field with a permissions hint). Tooling can read this metadata and auto‑suggest the minimal flags, reducing guesswork compared to npm's post‑install scripts.
Actionable closing checklist
- Write a
deno.jsontask that lists the exact--allow-*flags your tool needs. - Run the task in a clean container (Docker or CI) to confirm it passes without
--allow-all. - If the tool requires user‑specific paths, implement
Deno.permissions.request()and test both interactive and headless runs. - For distribution, compile with
deno compileusing the same flag set; verify the binary cannot escape its sandbox (e.g., attempt to read./data/../etc/passwd). - Add a CI job that executes the exact task and test commands; treat any permission denial as a build failure.
Following this pattern gives you a supply‑chain‑resistant CLI that still moves fast during prototyping—start with --allow-all locally, then shrink the allow‑list before the pull request lands.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.