dart_io_sandbox 1.2.1 copy "dart_io_sandbox: ^1.2.1" to clipboard
dart_io_sandbox: ^1.2.1 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 #

pub package Null Safety Dart CI GitHub Tag New Commits Last Commits Pull Requests Code size License

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_sandbox is in-process, cooperative confinement. It works by overriding the dart:io entity constructors inside a Dart Zone. It is a strong guardrail for semi-trusted code that uses the normal dart:io APIs — not a security boundary against hostile code. It does not stop native code, dart:ffi, direct syscalls, Process.run issued directly via dart:io (use Sandbox.process), raw sockets / UDP (RawSocket, RawServerSocket, RawDatagramSocketIOOverrides has 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, and Link is confined to the configured root. Relative paths resolve against the root; any escape throws a SandboxViolationError. 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. SandboxPolicy supports 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 by package:command_shield) to add semantic, execution-free analysis on top of the allowlist — an allowlisted bash/git/rm invoked destructively can still be denied. Off by default; fail-closed on review. Pluggable filter (override the verdict for every command) and confirm (approve a would-be denial) hooks, sync or async.
  • Network gate. Socket / ServerSocket creation is blocked unless allowNetwork: true, and HttpClient (both http:// and https://) is gated by a SandboxHttpOverrides installed alongside the IOOverrides. Raw sockets and UDP are not interceptable (see Limitations).
  • Observability. An onAccess hook receives a SandboxAccessEvent for 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:file integration. Expose a sandbox as a package:file FileSystem via SandboxFileSystem.
  • 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.

runSync caveat: Sandbox.process.runSync cannot await. If filter or confirm returns a Future, it throws an UnsupportedError — use the async run/start instead.

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.

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):

  • filter runs for every command and can override the verdict — return a CommandDecision (force allow / review / deny) or null to keep command_shield's verdict. Use it for project-specific policy on top of the analysis.
  • confirm runs whenever a command would be denied and decides whether to override the denial (return true to 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}"?'),
);

runSync caveat: Sandbox.process.runSync cannot await. If filter or confirm returns a Future, runSync throws an UnsupportedError — use the async run/start instead.

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 on dart_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:io use. A test that spawns its own isolate without installing the sandbox, calls Process.run directly (use Sandbox.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 trusted CommandRewriter transforms applied (in order) to every Sandbox.process command after it passes the allowlist and CommandGuard. Each returns a CommandRewrite(executable, arguments) or null to 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 (default true) — a built-in rewriter that turns an intercepted dart test ... into a dart run dart_io_sandbox test <flags> ... invocation whose <flags> reproduce the current sandbox's policy (and the serialisable part of its CommandGuard). 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.process are rewritten (raw dart:io Process.run still escapes). The default dart run dart_io_sandbox requires the sandbox root to be a package depending on dart_io_sandbox. The onAccess hook and a CommandGuard's custom filter/confirm closures 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.run from dart:io is not intercepted (no override hook exists); use Sandbox.process. Subprocesses themselves escape the jail — but a Sandbox.process command can be rewritten to re-confine it (e.g. dart test is auto-rewired to dart_io_sandbox test).
  • The network gate covers Socket / ServerSocket and HttpClient (http:// and https://, via SandboxHttpOverrides). RawSocket, RawServerSocket and RawDatagramSocket (UDP) used directly have no override hook and therefore bypass allowNetwork.
  • getSystemTempDirectory() is redirected to a .tmp directory 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.

License #

Apache License - Version 2.0

1
likes
150
points
65
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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 read-only / allow / deny policies, and gates process execution behind an allowlist. Includes a dart_io_sandbox CLI to run dart tests in sandbox mode.

Repository (GitHub)
View/report issues

License

Apache-2.0 (license)

Dependencies

args, async, command_shield, file, path, stream_channel, test_api, test_core, yaml

More

Packages that depend on dart_io_sandbox