AI编码
dev.to ·
@Solon Framework
为 AI Agent 提供真实沙箱:Java 实现文件系统与网络隔离
文章指出,让编码 Agent 执行构建命令等于把读取敏感文件的能力交给语言模型,提示词层面的“不要读取敏感文件”并非安全边界,只有操作系统层面的隔离才有效。作者介绍 Solon AI 的 solon-ai-sandbox 模块,称其为 Claude Code sandbox-runtime 的 Java 移植,可在 macOS、Linux 和 Windows 上对 Agent 发出的命令实施文件系统与网络隔离,并说明为何交互式本地 Agent 不适合直接用 Docker 隔离。
AI推荐理由 文章讨论编码 Agent 执行命令时的安全边界问题,指出提示词层面的限制不构成安全边界,并介绍 Solon AI 的 solon-ai-sandbox 模块——Claude Code sandbox-runtime 的 Java 移植,在 macOS/Linux/Windows 上对 Agent 命令做文件系统与网络隔离。对 Agent 安全落地有实际参考价值,但属项目方自述,效果未经独立验证。
74
正文
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.
org.noear
solon-ai-sandbox
${solon-ai.version}
Enter fullscreen mode
Exit fullscreen mode
Initialization takes a runtime config and an optional interactive callback:
SandboxManager.initialize(config, askCallback);
Enter fullscreen mode
Exit fullscreen mode
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});
Enter fullscreen mode
Exit fullscreen mode
On macOS the result is a sandbox-exec -p '' 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
);
Enter fullscreen mode
Exit fullscreen mode
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
);
Enter fullscreen mode
Exit fullscreen mode
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);
Enter fullscreen mode
Exit fullscreen mode
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");
};
Enter fullscreen mode
Exit fullscreen mode
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>
null, // enableWeakerNestedSandbox
null, // enableWeakerNetworkIsolation
null, // allowAppleEvents
null, // RipgrepConfig
null, // mandatoryDenySearchDepth
null, // allowPty
null, // SeccompConfig
null, // bwrapPath
null, // socatPath
null // WindowsConfig -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));
}
Enter fullscreen mode
Exit fullscreen mode
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());
}
Enter fullscreen mode
Exit fullscreen mode
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)