# Giving an AI Agent a Real Sandbox: Filesystem and Network Jail, in Java

> Source: <https://dev.to/solonjava/giving-an-ai-agent-a-real-sandbox-filesystem-and-network-jail-in-java-28bi>
> Published: 2026-09-14 00:53:49+00:00

Ask a coding agent to run a build, and you have just handed a language model the ability to `cat ~/.ssh/id_rsa`. Prompt-level instructions like "do not read sensitive files" are not a security boundary — they are a suggestion to a stochastic process. If the agent executes commands on your machine, the only control that actually holds is the one the operating system enforces.

That is the problem [Solon AI](https://solon.noear.org/article/learn-solon-ai)'s new `solon-ai-sandbox` module solves. It is a Java port of Claude Code's `sandbox-runtime`, and it wraps agent-issued commands in real filesystem and network isolation — on macOS, Linux, and Windows.

All code below was verified against the `solon-ai-sandbox` source in the Solon AI 4.1.x tree.

Containers are the usual answer, and for a server-side agent they are the right one. But the agents people actually run interactively — the ones editing their working copy of a repo — are not in a container. They are on a laptop, in a terminal, one `bash` call away from everything the user can touch.

Booting a VM or a container per command is too slow for that loop, and it breaks the agent's access to the working tree you wanted it to edit. What you want is a *narrow* boundary: keep the agent in the project directory, let it reach the registries and package mirrors the build needs, and make everything else fail closed — without a container runtime in the picture.

`solon-ai-sandbox` does exactly that, using each platform's native facility:

| Platform | Mechanism | 
|---|---|
| macOS | `sandbox-exec` with a generated Seatbelt profile | 
| Linux | `bubblewrap` (`bwrap` ), plus`socat` for the network bridge | 
| Windows | `srt-win.exe` with a WFP filter layer | 

The module depends on nothing but `solon-ai-core`, so pulling it in does not drag a container runtime along with it.

`SandboxManager` is a final class with static methods — there is one sandbox per process, and there is one place to configure it.

```
<dependency>
    <groupId>org.noear</groupId>
    <artifactId>solon-ai-sandbox</artifactId>
    <version>${solon-ai.version}</version>
</dependency>
```

Initialization takes a runtime config and an optional interactive callback:

```
SandboxManager.initialize(config, askCallback);
```

And wrapping a command is a single call:

```
String wrapped = SandboxManager.wrapWithSandbox("git status");
Process p = Runtime.getRuntime().exec(new String[]{"/bin/bash", "-c", wrapped});
```

On macOS the result is a `sandbox-exec -p '<seatbelt profile>'` invocation; on Linux it is a `bwrap` invocation with the appropriate bind mounts and namespaces. Your code never branches on the platform.

Reads and writes use opposite defaults, and understanding why is the key to configuring this correctly.

**Writes are `allow-only`.** The default is *deny everything*. You list the paths the agent may write, and `denyWrite` punches holes back out of that list. The manager always adds the paths a process genuinely cannot function without — `/dev/*`, temp directories, and so on — so you are not fighting the OS.

**Reads are `deny-then-allow-back`.** The default is *allow*, because breaking every read on the machine would break the compiler, the JVM, and half of userspace. Instead you name the regions to protect, and `allowRead` re-opens specific paths inside them.

```
FilesystemConfig fs = new FilesystemConfig(
    Arrays.asList("~/.ssh", "~/.aws"),   // denyRead
    Collections.emptyList(),             // allowRead
    Arrays.asList("/tmp", "."),          // allowWrite
    Arrays.asList(".git"),               // denyWrite
    false                                // allowGitConfig
);
```

Read that as: the agent may write only under the current working directory and `/tmp`, must never write into `.git`, and may not read your SSH or AWS credentials even though reads are otherwise open.

Two details that matter in practice:

`allowWrite` list is the strictest possible setting`.git`.` denyWrite` on `.git/config` gets its own `allowGitConfig` switch (default `false`).
Network isolation here is implemented with a local HTTP and SOCKS5 forward proxy. The sandboxed process is pointed at it via environment variables, and the module decides per request whether to let the connection through.

```
NetworkConfig network = new NetworkConfig(
    Arrays.asList("api.openai.com", "*.github.com"), // allowlist
    Arrays.asList("telemetry.example.com"),          // denylist
    null, null, null, null, null, null, null, null, null, null
);
```

Domain patterns support wildcards like `*.github.com`, and `HostUtils` normalizes IPv4, IPv6, and hostnames so that matching is not trivially bypassed by writing an address a different way.

The reason to use a proxy instead of a kernel firewall rule is **live updates**. The proxies read the configuration on every request, so this takes effect immediately, on already-running agent processes, with no rebind and no port change:

```
SandboxManager.updateConfig(newConfig);
```

Compare that with filesystem rules, which are *not* live: on macOS the rules are baked into the Seatbelt profile when the command is wrapped, and on Windows they have to be explicitly re-stamped. To change filesystem restrictions you must `reset()` and `initialize()` again. Know which knob is hot and which one requires a restart.

There is also a callback for the case where the allowlist is not the final word:

``` php
SandboxAskCallback callback = (hostPattern) -> {
    System.out.println("Allow " + hostPattern.getHost() + ":" + hostPattern.getPort() + "? [y/N]");
    return new Scanner(System.in).nextLine().trim().equalsIgnoreCase("y");
};
```

It fails closed: if the callback throws, or no configuration covers the request, the connection is denied.

Here is a concrete trap. The module's README still shows a `SandboxRuntimeConfig` with **12** constructor arguments. The current source declares **13**:

```
SandboxRuntimeConfig config = new SandboxRuntimeConfig(
    network,                       // NetworkConfig
    fs,                            // FilesystemConfig
    ignoredViolations,             // Map<String, List<String>>
    null,                          // enableWeakerNestedSandbox
    null,                          // enableWeakerNetworkIsolation
    null,                          // allowAppleEvents
    null,                          // RipgrepConfig
    null,                          // mandatoryDenySearchDepth
    null,                          // allowPty
    null,                          // SeccompConfig
    null,                          // bwrapPath
    null,                          // socatPath
    null                           // WindowsConfig  <-- the 13th
);
```

The trailing `WindowsConfig` parameter is the one the README example is missing, so code copied from it will not compile against 4.1.x. I verified this directly in `SandboxRuntimeConfig.java`; the code in this post is written against the source, not the README.

`wrapWithSandbox(String)` returns a shell string, and on Windows it throws instead:

`wrapWithSandbox() returns a shell string and is not supported on Windows. Use SandboxManager.wrapWithSandboxArgv()...`

The argv variant returns `{ argv, env }`, where `env` carries the full proxy environment the child needs to inherit. On macOS and Linux it still works — it just wraps the string form behind `<shell> -c` — so if you want one code path across all three platforms, use `wrapWithSandboxArgv` everywhere and spawn with `{ shell: false }`.

A blocked operation is an event worth recording. `SandboxViolationStore` is a thread-safe, category-keyed store:

```
SandboxViolationStore store = new SandboxViolationStore(ignoreViolations);
store.record("network", "attempted connection to telemetry.example.com:443");

for (String category : store.getCategories()) {
    System.out.println(category + ": " + store.getViolations(category));
}
```

Because violations are categorized — `file_read`, `file_write`, `network` — a burst of `network` denials from a normally well-behaved agent is a signal worth alerting on. `ignoreViolations` suppresses known-noisy entries by substring match, which keeps the signal readable.

**Check dependencies at startup, and fail loudly.** `initialize()` refuses to proceed if the platform's dependency is missing, and you should surface that rather than silently degrading to an unsandboxed run:

```
Platform platform = PlatformDetector.detect();
SandboxDependencyCheck deps = SandboxManager.checkDependencies();
if (deps.hasErrors()) {
    throw new IllegalStateException("Sandbox unavailable: " + deps.getErrors());
}
```

On Linux that means `bubblewrap` and `socat` must be installed (`apt install bubblewrap socat`); macOS needs nothing, since `sandbox-exec` ships with the OS; Windows needs `srt-win.exe` installed once with elevation to set up the WFP layer.

**Call `cleanupAfterCommand()` after each command on Linux.** `bwrap` creates empty placeholder files on the *host* filesystem when it protects paths that do not exist — `~/.bashrc` on a fresh container, for example. They linger after the process exits. The method is a no-op on macOS, and it is also invoked from `reset()` and a JVM shutdown hook, but calling it in your command loop avoids accumulating junk.

Three rules cover almost all of it:

The larger point is about where the boundary lives. Once an agent can execute code, "the model was asked nicely" is not a control. `solon-ai-sandbox` moves that control down into the mechanism the OS already enforces — Seatbelt, bubblewrap, or the Windows Filtering Platform — and hands you a small, uniform Java API for it.

For a Java agent stack, that is the difference between a demo and something you let near a real repository.

`solon-ai-sandbox`), Solon AI 4.1.x tree` sandbox-runtime` (the TypeScript original this module ports)
