dart_io_sandbox 1.2.2
dart_io_sandbox: ^1.2.2 copied to clipboard
A Zone-based filesystem and process sandbox for Dart, built on IOOverrides. Confines all dart:io filesystem access to a configured root directory, blocks path-traversal and symlink escapes, enforces r [...]
dart_io_sandbox #
A Zone-based filesystem and process sandbox for Dart, written in pure
Dart on top of dart:io's IOOverrides. Run code with Sandbox.run and all
standard dart:io filesystem access is transparently confined to a root
directory: path-traversal and symlink escapes are blocked, a declarative
policy governs reads / writes / deletes, process execution is gated behind an
allowlist, network access is blocked by default, and every access is
observable through a hook.
It plugs into ordinary code — no special filesystem API to adopt. The same
File('data.txt') your code already writes becomes sandboxed inside the zone.
Optional package:file integration is included.
It also ships a dart_io_sandbox command-line tool whose test command
runs your suite like dart test, but with every test isolate confined to a
Sandbox.run jail — so a test can't read, write, spawn, or connect outside the
capabilities you grant. See Running tests under the sandbox.
⚠️ This is NOT an OS-level sandbox #
dart_io_sandboxis in-process, cooperative confinement. It works by overriding thedart:ioentity constructors inside a DartZone. It is a strong guardrail for semi-trusted code that uses the normaldart:ioAPIs — not a security boundary against hostile code. It does not stop native code,dart:ffi, direct syscalls,Process.runissued directly viadart:io(useSandbox.process), raw sockets / UDP (RawSocket,RawServerSocket,RawDatagramSocket—IOOverrideshas no hook for them), or isolates that install their own overrides. For untrusted/adversarial code, layer it on top of a real OS sandbox (containers, seccomp, jails, VMs).
API Documentation #
See the API Documentation for a full list of classes and members.
Features #
- Filesystem jail. Every
File,Directory, andLinkis confined to the configured root. Relative paths resolve against the root; any escape throws aSandboxViolationError. Full sync and async surface, so synchronous calls cannot be used to bypass the jail. - Traversal & symlink protection.
../traversal and absolute-path escapes are rejected lexically; symlinks are re-canonicalized and re-checked at access time, so a link whose target points outside the root cannot be read, and escaping links cannot be created. - Deterministic policy layer.
SandboxPolicysupports read-only mode, allow lists and deny lists (deny always wins). Evaluation is a pure, side-effect-free function — trivially unit-testable. - Process allowlist. Opt-in process execution restricted to named executables, never run through a shell, with shell-metacharacter rejection.
- Optional command analysis. Attach a
CommandGuard(backed bypackage:command_shield) to add semantic, execution-free analysis on top of the allowlist — an allowlistedbash/git/rminvoked destructively can still be denied. Off by default; fail-closed onreview. Pluggablefilter(override the verdict for every command) andconfirm(approve a would-be denial) hooks, sync or async. - Network gate.
Socket/ServerSocketcreation is blocked unlessallowNetwork: true, andHttpClient(bothhttp://andhttps://) is gated by aSandboxHttpOverridesinstalled alongside theIOOverrides. Raw sockets and UDP are not interceptable (see Limitations). - Observability. An
onAccesshook receives aSandboxAccessEventfor every allowed and denied operation — a complete audit trail. - Composable nesting. Sandboxes nest; a nested sandbox must live inside its parent and can never be more permissive than it (policies are intersected).
package:fileintegration. Expose a sandbox as apackage:fileFileSystemviaSandboxFileSystem.- Tested. Unit + integration tests cover path handling, policy, file operations, the process layer, the command guard, symlink escapes, nesting and the adapter.
Architecture #
Your code (plain dart:io)
│
File / Directory / Link / Socket(...)
│
▼
IOOverrides (Dart Zone) ◄── Sandbox.run installs SandboxIOOverrides
│
┌────────────────┼─────────────────┐
▼ ▼ ▼
Path resolver Policy Network gate
(traversal + (read-only, (allowNetwork)
symlink jail) allow/deny)
│ │
└────────┬───────┘
▼
Sandboxed File / Directory / Link ──► native dart:io (confined real path)
│
▼
onAccess hook (audit: allow + deny events)
Sandbox.process ──► allowlist + no-shell guard ──► Process.run / start
IOOverrides only intercepts the construction of File/Directory/Link,
so each one is returned as a policy-enforcing wrapper that delegates to a native
entity bound to an already-resolved, contained real path.
lib/
├── dart_io_sandbox.dart # public library (exports)
└── src/
├── sandbox.dart # Sandbox.run + the IOOverrides interception layer
├── context.dart # per-sandbox, zone-scoped state
├── config.dart # SandboxConfig value object
├── policy.dart # SandboxPolicy + deterministic evaluation
├── events.dart # SandboxAccessEvent + onAccess hook
├── errors.dart # SandboxError hierarchy
├── file_adapter.dart # package:file integration (SandboxFileSystem)
├── path/ # resolver.dart (resolution) + validator.dart (containment)
├── fs/ # sandboxed File / Directory / Link + shared mixin
└── process/ # allowlisted, shell-free process execution
Getting started #
dependencies:
dart_io_sandbox: ^1.0.1
The optional package:command_shield dependency is pulled in
automatically — you only import it when you build a CommandGuard (see
Add command analysis below).
Usage #
Run code in a sandbox #
import 'dart:io';
import 'package:dart_io_sandbox/dart_io_sandbox.dart';
await Sandbox.run(
root: '/tmp/sandbox',
policy: SandboxPolicy(
readOnly: false,
allowProcess: true,
allowedPaths: ['/tmp/sandbox/data'],
deniedPaths: ['/tmp/sandbox/data/secret'],
allowedExecutables: ['echo'],
),
onAccess: (event) => print(event), // [ALLOW] write /tmp/sandbox/data/x ...
action: () async {
// Plain dart:io — automatically sandboxed.
final file = File('data/notes.txt');
await Directory('data').create(recursive: true);
await file.writeAsString('hello');
print(await file.readAsString());
// Allowlisted process execution (never via a shell).
final result = await Sandbox.process.run('echo', ['sandboxed']);
print(result.stdout);
// The following all throw:
// File('../../etc/passwd'); // SandboxViolationError (traversal)
// File('/etc/passwd'); // SandboxViolationError (absolute)
// File('data/secret/k').writeAsString; // SandboxPolicyError (deny list)
// Sandbox.process.run('rm', ['-rf']); // SandboxProcessDeniedError
// Socket.connect('host', 80); // SandboxViolationError (network)
},
);
A complete runnable example lives at example/main.dart.
Enforce a policy #
const policy = SandboxPolicy(
readOnly: true, // blocks all writes/deletes/renames
allowedPaths: ['/srv/data'], // empty => everything within the root
deniedPaths: ['/srv/data/secrets'], // deny always overrides allow
allowProcess: false, // no process execution
allowNetwork: false, // no sockets (default)
allowedExecutables: ['git', 'echo'], // allowlist (fail-closed when empty)
);
Policy evaluation is a pure function, so it is testable without any sandbox:
policy.denyReason(AccessMode.write, '/srv/data/x'); // 'policy is read-only; ...'
policy.denyReason(AccessMode.read, '/srv/data/secrets/k'); // 'covered by deny list ...'
policy.denyReason(AccessMode.read, '/srv/data/x'); // null => allowed
Use the package:file adapter #
import 'package:file/file.dart';
import 'package:dart_io_sandbox/dart_io_sandbox.dart';
// Self-contained: confined wherever it is used.
final FileSystem fs = SandboxFileSystem.bound(root: '/tmp/sandbox');
await fs.file('a.txt').writeAsString('hi'); // confined under the root
fs.file('../../etc/passwd'); // throws SandboxViolationError
Inside a Sandbox.run body the default SandboxFileSystem() (and even a plain
LocalFileSystem) is automatically sandboxed, because package:file builds its
dart:io delegate at entity-construction time within the zone.
Add command analysis (CommandGuard) #
The executable allowlist answers "is this executable allowed?" but not "is
this specific command dangerous?". A CommandGuard — backed by
package:command_shield — adds semantic, execution-free
analysis on top of the allowlist, so an allowlisted bash/git/rm invoked
destructively can still be denied before anything runs. It is off by
default: attach one via Sandbox.run(commandGuard: ...) (or
SandboxConfig.commandGuard) and existing behaviour is otherwise unchanged.
import 'package:command_shield/command_shield.dart' show CommandSyntax;
import 'package:dart_io_sandbox/dart_io_sandbox.dart';
await Sandbox.run(
root: '/tmp/sandbox',
policy: const SandboxPolicy(
allowProcess: true,
allowedExecutables: ['bash', 'echo', 'rm'], // allowlist gates the executable
),
// The guard analyses each invocation's arguments and flags/denies dangerous ones.
commandGuard: CommandGuard.forSyntax(CommandSyntax.bash),
onAccess: (event) => print(event), // denied commands are audited with the reason
action: () async {
await Sandbox.process.run('echo', ['hi']); // allowed
await Sandbox.process.run('rm', ['-rf', '/']); // SandboxProcessDeniedError
await Sandbox.process.run('bash', ['-c', 'echo hi']); // denied: `review` is fail-closed
},
);
The guard runs after the sandbox's shell-metacharacter check, so it sees a
metacharacter-free, unambiguously reconstructed command. A command_shield
review verdict is treated as a denial by default (denyOnReview: true,
fail-closed); pass denyOnReview: false to permit (but still audit) reviewed
commands. For full control over the policy, build the guard from a
CommandShield directly: CommandGuard(CommandShield(...)).
Two optional callbacks — both receiving a CommandReview (the executable,
arguments, reconstructed command, and the full analysis result), each
sync or async (FutureOr) — plug into the guard:
final guard = CommandGuard.forSyntax(
CommandSyntax.bash,
// `filter` runs for EVERY command and can override the verdict: return a
// CommandDecision (force allow / review / deny) or null to keep the analysis.
filter: (review) =>
review.command.contains('/etc') ? CommandDecision.deny : null,
// `confirm` runs when a command WOULD be denied — return true to run it anyway
// (e.g. an interactive prompt). The override is flagged in the audit trail.
confirm: (review) async => await promptYesNo('Run "${review.command}"?'),
);
By default (neverConfirmCritical: true) the most dangerous commands — those
command_shield classifies as critical-severity denials (e.g. rm -rf /) —
can never be confirmed; confirm is not even consulted for them. Pass
neverConfirmCritical: false to let confirm override even those.
runSynccaveat:Sandbox.process.runSynccannot await. Iffilterorconfirmreturns aFuture, it throws anUnsupportedError— use the asyncrun/startinstead.
A complete runnable demo lives at
example/command_shield_example.dart. The mechanics are
detailed in Optional command analysis
under How it works.
How it works #
Path resolution (a jail, not a chroot) #
The current working directory inside the sandbox starts at the canonical root.
A path is resolved by joining relative inputs onto the cwd, normalizing ./..
lexically, and then asserting the result is contained within the root.
Anything that escapes — ../../etc/passwd, /etc/passwd — throws rather than
being silently clamped.
Symlink containment #
Lexical checks alone are not enough: a symlink whose name is inside the root can point outside it. Before each access, the longest existing prefix of the target path is canonicalized (symlinks resolved) and re-checked for containment, so links created after the sandbox starts are still caught. Creating a link whose target would escape is rejected outright.
Policy & nesting #
deniedPaths always override allowedPaths; read-only mode blocks every write,
delete and rename. When a sandbox is created inside another, its root must live
within the parent's root and its policy is intersected with the parent's —
read-only is OR-ed, process/network are AND-ed, deny lists are unioned and the
executable allowlist is intersected — so a nested sandbox can never widen the
permissions it was granted.
Process execution #
There is no IOOverrides hook for processes, so Sandbox.process is a separate,
explicit API. It requires allowProcess, an executable on the allowlist, runs
without a shell, and rejects arguments containing shell metacharacters.
Optional command analysis (CommandGuard) #
The executable allowlist answers "is this executable allowed?" but not "is
this specific command dangerous?". Attach a CommandGuard — backed by
package:command_shield — to analyse each invocation and
deny dangerous ones (e.g. an allowlisted bash/rm used destructively):
import 'package:command_shield/command_shield.dart' show CommandSyntax;
import 'package:dart_io_sandbox/dart_io_sandbox.dart';
await Sandbox.run(
root: root,
policy: const SandboxPolicy(
allowProcess: true,
allowedExecutables: ['bash', 'echo'],
),
commandGuard: CommandGuard.forSyntax(CommandSyntax.bash),
action: () async {
await Sandbox.process.run('echo', ['hi']); // allowed
await Sandbox.process.run('bash', ['-c', 'rm -rf /']); // SandboxProcessDeniedError
},
);
The feature is off unless a guard is attached, so existing behaviour is
unchanged. The guard runs after the shell-metacharacter check, so it sees a
metacharacter-free, unambiguously reconstructed command string. A
command_shield review verdict is treated as a denial by default
(denyOnReview: true, fail-closed); pass denyOnReview: false to permit (but
still audit) reviewed commands. For full control build the guard from a
CommandShield directly: CommandGuard(CommandShield(policy: ...)).
Custom hooks: filter and confirm
Two optional callbacks plug into the guard, both receiving a CommandReview
(the executable, arguments, reconstructed command, and the full
command_shield analysis result). They may be synchronous or asynchronous
(FutureOr):
filterruns for every command and can override the verdict — return aCommandDecision(forceallow/review/deny) ornullto keepcommand_shield's verdict. Use it for project-specific policy on top of the analysis.confirmruns whenever a command would be denied and decides whether to override the denial (returntrueto run it anyway) — e.g. an interactive "run anyway?" prompt. A confirmed override is still recorded in the audit trail.
By default (neverConfirmCritical: true) the most dangerous commands — those
command_shield classifies as critical-severity denials (e.g. rm -rf /) —
can never be confirmed: confirm is not even consulted for them. Pass
neverConfirmCritical: false to let confirm override even those.
final guard = CommandGuard.forSyntax(
CommandSyntax.bash,
filter: (review) =>
review.command.contains('/etc') ? CommandDecision.deny : null,
confirm: (review) async => await promptYesNo('Run "${review.command}"?'),
);
runSynccaveat:Sandbox.process.runSynccannot await. Iffilterorconfirmreturns aFuture,runSyncthrows anUnsupportedError— use the asyncrun/startinstead.
Command analysis is not composed across nested sandboxes — the innermost non-null guard wins (the same inheritance model used for
onAccess).
Network gate (what it can and cannot intercept) #
IOOverrides exposes hooks for Socket.connect, Socket.startConnect and
ServerSocket.bind, so those are gated by allowNetwork. HttpClient is gated
separately by SandboxHttpOverrides (installed by Sandbox.run alongside
the IOOverrides): it wraps every client and checks each request, so both
http:// and https:// are blocked when allowNetwork is false. This matters
because https:// uses SecureSocket (→ RawSocket) rather than
Socket.connect, so IOOverrides alone would miss it.
There is no override hook for RawSocket, RawServerSocket or
RawDatagramSocket (UDP), so raw sockets used directly (not via
HttpClient) cannot be intercepted in-process and are not blocked. If you
must deny UDP/raw sockets, do it at the OS layer (see the warning above).
Running tests under the sandbox (dart_io_sandbox CLI) #
This package ships a dart_io_sandbox command-line tool. Its test command
runs your suite like dart test, except every test isolate executes inside
a Sandbox.run jail. It reuses the standard test runner — globbing, -n/-t
filtering, -j concurrency, all reporters and exit codes are unchanged — and
simply overrides the VM platform so each suite's isolate installs the sandbox
before loading the tests. Because the sandbox is installed inside each spawned
isolate, you keep parallel execution and real per-isolate confinement.
Install it once with dart pub global activate so the dart_io_sandbox command
is available everywhere on your PATH:
dart pub global activate dart_io_sandbox
dart_io_sandbox # list commands
dart_io_sandbox help test # usage for a command
# Run the whole suite under the default "safe" preset:
dart_io_sandbox test test/
# Same test-runner arguments you already use are forwarded as-is:
dart_io_sandbox test -j 4 -r expanded -n 'parser' test/
# Pick a preset / point at a YAML config / override individual capabilities:
dart_io_sandbox test --preset paranoid test/
dart_io_sandbox test --config example/sandbox.yaml test/
dart_io_sandbox test --no-allow-network --deny-path lib/secret.dart test/
dart_io_sandbox test --audit test/ # log every allow/deny access to stderr
Prefer not to install it globally? Every command also works through
dart run dart_io_sandbox <command>from within a package that depends ondart_io_sandbox— e.g.dart run dart_io_sandbox test test/.
Commands:
| Command | Description |
|---|---|
test |
Run a test suite with every test isolate sandboxed (like dart test). |
config |
Print the resolved sandbox configuration (preset < YAML < flags). |
presets |
List the built-in capability presets. |
help |
Show usage for the tool or a specific command. |
Presets (the base layer of configuration):
| Preset | Filesystem | Network | Process | Command guard |
|---|---|---|---|---|
safe (default) |
read-write in root | allowed | dart, flutter, pub |
bash, on |
paranoid |
read-only | denied | denied | off |
Configuration precedence (lowest to highest): preset → YAML file
(--config) → CLI flags. See example/sandbox.yaml for
the full schema. Sandbox flags (for test and config): --config,
--preset, --root, --read-only, --allow-network, --allow-process,
--allow-exe, --allow-path, --deny-path, --audit (each negatable, e.g.
--no-allow-network). Every other argument to test is forwarded verbatim to
the test runner. Run dart run dart_io_sandbox help test for usage.
Same cooperative model, same caveats (see Limitations). The jail applies to each test isolate's standard
dart:iouse. A test that spawns its own isolate without installing the sandbox, callsProcess.rundirectly (useSandbox.process), or reaches for FFI/raw sockets is not confined. This is a strong guardrail for semi-trusted suites, not a security boundary for hostile ones — for that, layer it on top of an OS sandbox.
Rewriting commands (and auto-rewiring dart test) #
A subprocess spawned with Sandbox.process escapes the in-process jail. To close
that gap for the common case, a sandbox can rewrite a command before it is
spawned. Sandbox.run accepts:
commandRewriters— a list of trustedCommandRewritertransforms applied (in order) to everySandbox.processcommand after it passes the allowlist andCommandGuard. Each returns aCommandRewrite(executable, arguments)ornullto leave the command unchanged. They are transparent substitutions and are not re-checked against the policy, so they are for the host embedding the sandbox, not the sandboxed code.rewriteDartTest(defaulttrue) — a built-in rewriter that turns an intercepteddart test ...into adart run dart_io_sandbox test <flags> ...invocation whose<flags>reproduce the current sandbox's policy (and the serialisable part of itsCommandGuard). The nested test process is therefore confined to the same root/policy instead of running unrestricted.
await Sandbox.run(
root: projectDir,
policy: const SandboxPolicy(
allowProcess: true,
allowedExecutables: ['dart'],
),
action: () => Sandbox.process.run('dart', ['test', '-j', '4']),
// → spawns: dart run dart_io_sandbox test --preset none --root <projectDir>
// --allow-process --allow-exe dart ... test -j 4
);
The config→flags conversion is also exposed directly:
sandboxCliArgs(root, policy, commandGuard: guard) returns the dart_io_sandbox
sandbox flags equivalent to a policy. Set dartTestRewritePrefix to change the
target (e.g. ['dart_io_sandbox'] for a globally-activated binary instead of the
default dart run dart_io_sandbox).
Caveats. Only commands run through
Sandbox.processare rewritten (rawdart:ioProcess.runstill escapes). The defaultdart run dart_io_sandboxrequires the sandbox root to be a package depending ondart_io_sandbox. TheonAccesshook and aCommandGuard's customfilter/confirmclosures cannot cross a process boundary and are not reproduced in the nested run.
Errors #
All errors extend SandboxError and carry the attempted path/action and a reason:
| Error | Raised when |
|---|---|
SandboxViolationError |
Escape attempt: traversal, absolute path, symlink, or blocked network. |
SandboxPathError |
Malformed or unresolvable path (empty, null byte, ...). |
SandboxPolicyError |
Denied by read-only / allow / deny rules. |
SandboxProcessDeniedError |
Process disabled, not allowlisted, or contains a blocked pattern. |
Limitations #
- Cooperative only — see the warning above. Not robust against hostile code.
- Direct
Process.runfromdart:iois not intercepted (no override hook exists); useSandbox.process. Subprocesses themselves escape the jail — but aSandbox.processcommand can be rewritten to re-confine it (e.g.dart testis auto-rewired todart_io_sandbox test). - The network gate covers
Socket/ServerSocketandHttpClient(http://andhttps://, viaSandboxHttpOverrides).RawSocket,RawServerSocketandRawDatagramSocket(UDP) used directly have no override hook and therefore bypassallowNetwork. getSystemTempDirectory()is redirected to a.tmpdirectory inside the root.- Absolute paths must be expressed against the canonical root (symlinks
resolved); e.g. on macOS
/tmp/...is canonicalized to/private/tmp/.... - Confinement is per-zone; code that escapes the zone (new isolates, FFI) is not covered.
Running the example and tests #
dart run example/main.dart # runnable demo of the full feature set
dart run example/command_shield_example.dart # optional CommandGuard demo
dart test # full unit + integration suite
dart run dart_io_sandbox test test/ # run the suite under the sandbox
Source #
The official source code is hosted @ GitHub:
Features and bugs #
Please file feature requests and bugs at the issue tracker.
Contribution #
Any help from the open-source community is always welcome and needed:
- Found an issue?
- Please fill a bug report with details.
- Wish a feature?
- Open a feature request with use cases.
- Are you using and liking the project?
- Promote the project: create an article, do a post or make a donation.
- Are you a developer?
- Fix a bug and send a pull request.
- Implement a new feature.
- Improve the Unit Tests.
- Have you already helped in any way?
- Many thanks from me, the contributors and everybody that uses this project!
If you donate 1 hour of your time, you can contribute a lot, because others will do the same, just be part and start with your 1 hour.
Author #
Graciliano M. Passos: gmpassos@GitHub.