General
Giving an AI Agent a Real Sandbox: Filesystem and Network Jail, in Java
Solon Framework DEV Community 周榜
2 views
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'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.
Why not just run the agent in Docker?
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.
The entry point is one class
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.
Filesystem: two different policies, on purpose
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:
An empty allowWrite list is the strictest possible setting — it means no writes at all beyond the mandatory system paths.
Don't write into .git. A corrupted or maliciously rewritten Git directory is a nasty persistence vector, which is why denyWrite on .git shows up in the security defaults, and why .git/config gets its own allowGitConfig switch (default false).
Network: a proxy, not a firewall rule
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:
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.
The constructor signature changed — watch out for this
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.
Windows needs a different call
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 }.
Observability: violations are data, not just logs
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.
Before you ship this: two operational gotchas
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.
The mental model
Three rules cover almost all of it:
Writes are opt-in, reads are opt-out. Configure writes as an explicit allowlist; configure reads as a short list of things worth protecting.
Network is hot, filesystem is cold. Allowlist changes apply to running processes; filesystem changes need a reset.
Fail closed, and fail visibly. Missing dependencies and unmatched callbacks should surface as errors, not as an unsandboxed fallback.
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.
References
Solon AI documentation: https://solon.noear.org/article/learn-solon-ai
Module source (solon-ai-sandbox), Solon AI 4.1.x tree
Claude Code sandbox-runtime (the TypeScript original this module ports)
Read original: https://dev.to/solonjava/giving-an-ai-agent-a-real-sandbox-filesystem-and-network-jail-in-java-28bi
← Previous
Playwright email verification can pass for the wrong reason: six checks for a trustworthy test
Next →
Build in the VM, Think on the Mac GPU: Debian 13 on Apple container With a Local Gemma 4
Related
Your Cache Is Part of the Security Model
General
0
DEV Community 周榜
Why Most Medicine Reminders Fail (And How We Built One That Refuses to Be Ignored)
General
0
DEV Community 周榜
How to find LM Studio plugins and MCP servers
General
0
DEV Community 周榜
Stop Returning “Access Denied”
General
2
DEV Community 周榜
Comments0
No comments yet — be the first