{"slug": "giving-an-ai-agent-a-real-sandbox-filesystem-and-network-jail-in-java", "title": "Giving an AI Agent a Real Sandbox: Filesystem and Network Jail, in Java", "summary": "Solon AI has released solon-ai-sandbox, a Java module that wraps agent-issued shell commands in real filesystem and network isolation on macOS, Linux, and Windows. The module, a Java port of Claude Code's sandbox-runtime, uses native platform facilities — sandbox-exec with Seatbelt profiles on macOS, bubblewrap on Linux, and srt-win.exe with a WFP filter layer on Windows — to keep agents confined to a project directory without requiring a container runtime. It applies allow-only defaults for writes and deny-then-allow-back defaults for reads, so agents can build and edit code while being blocked from sensitive paths such as ~/.ssh and ~/.aws.", "body_md": "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.\n\nThat 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.\n\nAll code below was verified against the `solon-ai-sandbox` source in the Solon AI 4.1.x tree.\n\nContainers 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.\n\nBooting 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.\n\n`solon-ai-sandbox` does exactly that, using each platform's native facility:\n\n| Platform | Mechanism | \n|---|---|\n| macOS | `sandbox-exec` with a generated Seatbelt profile | \n| Linux | `bubblewrap` (`bwrap` ), plus`socat` for the network bridge | \n| Windows | `srt-win.exe` with a WFP filter layer | \n\nThe module depends on nothing but `solon-ai-core`, so pulling it in does not drag a container runtime along with it.\n\n`SandboxManager` is a final class with static methods — there is one sandbox per process, and there is one place to configure it.\n\n```\n<dependency>\n    <groupId>org.noear</groupId>\n    <artifactId>solon-ai-sandbox</artifactId>\n    <version>${solon-ai.version}</version>\n</dependency>\n```\n\nInitialization takes a runtime config and an optional interactive callback:\n\n```\nSandboxManager.initialize(config, askCallback);\n```\n\nAnd wrapping a command is a single call:\n\n```\nString wrapped = SandboxManager.wrapWithSandbox(\"git status\");\nProcess p = Runtime.getRuntime().exec(new String[]{\"/bin/bash\", \"-c\", wrapped});\n```\n\nOn 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.\n\nReads and writes use opposite defaults, and understanding why is the key to configuring this correctly.\n\n**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.\n\n**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.\n\n```\nFilesystemConfig fs = new FilesystemConfig(\n    Arrays.asList(\"~/.ssh\", \"~/.aws\"),   // denyRead\n    Collections.emptyList(),             // allowRead\n    Arrays.asList(\"/tmp\", \".\"),          // allowWrite\n    Arrays.asList(\".git\"),               // denyWrite\n    false                                // allowGitConfig\n);\n```\n\nRead 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.\n\nTwo details that matter in practice:\n\n`allowWrite` list is the strictest possible setting`.git`.` denyWrite` on `.git/config` gets its own `allowGitConfig` switch (default `false`).\nNetwork 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.\n\n```\nNetworkConfig network = new NetworkConfig(\n    Arrays.asList(\"api.openai.com\", \"*.github.com\"), // allowlist\n    Arrays.asList(\"telemetry.example.com\"),          // denylist\n    null, null, null, null, null, null, null, null, null, null\n);\n```\n\nDomain 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.\n\nThe 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:\n\n```\nSandboxManager.updateConfig(newConfig);\n```\n\nCompare 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.\n\nThere is also a callback for the case where the allowlist is not the final word:\n\n``` php\nSandboxAskCallback callback = (hostPattern) -> {\n    System.out.println(\"Allow \" + hostPattern.getHost() + \":\" + hostPattern.getPort() + \"? [y/N]\");\n    return new Scanner(System.in).nextLine().trim().equalsIgnoreCase(\"y\");\n};\n```\n\nIt fails closed: if the callback throws, or no configuration covers the request, the connection is denied.\n\nHere is a concrete trap. The module's README still shows a `SandboxRuntimeConfig` with **12** constructor arguments. The current source declares **13**:\n\n```\nSandboxRuntimeConfig config = new SandboxRuntimeConfig(\n    network,                       // NetworkConfig\n    fs,                            // FilesystemConfig\n    ignoredViolations,             // Map<String, List<String>>\n    null,                          // enableWeakerNestedSandbox\n    null,                          // enableWeakerNetworkIsolation\n    null,                          // allowAppleEvents\n    null,                          // RipgrepConfig\n    null,                          // mandatoryDenySearchDepth\n    null,                          // allowPty\n    null,                          // SeccompConfig\n    null,                          // bwrapPath\n    null,                          // socatPath\n    null                           // WindowsConfig  <-- the 13th\n);\n```\n\nThe 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.\n\n`wrapWithSandbox(String)` returns a shell string, and on Windows it throws instead:\n\n`wrapWithSandbox() returns a shell string and is not supported on Windows. Use SandboxManager.wrapWithSandboxArgv()...`\n\nThe 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 }`.\n\nA blocked operation is an event worth recording. `SandboxViolationStore` is a thread-safe, category-keyed store:\n\n```\nSandboxViolationStore store = new SandboxViolationStore(ignoreViolations);\nstore.record(\"network\", \"attempted connection to telemetry.example.com:443\");\n\nfor (String category : store.getCategories()) {\n    System.out.println(category + \": \" + store.getViolations(category));\n}\n```\n\nBecause 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.\n\n**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:\n\n```\nPlatform platform = PlatformDetector.detect();\nSandboxDependencyCheck deps = SandboxManager.checkDependencies();\nif (deps.hasErrors()) {\n    throw new IllegalStateException(\"Sandbox unavailable: \" + deps.getErrors());\n}\n```\n\nOn 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.\n\n**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.\n\nThree rules cover almost all of it:\n\nThe 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.\n\nFor a Java agent stack, that is the difference between a demo and something you let near a real repository.\n\n`solon-ai-sandbox`), Solon AI 4.1.x tree` sandbox-runtime` (the TypeScript original this module ports)", "url": "https://wpnews.pro/news/giving-an-ai-agent-a-real-sandbox-filesystem-and-network-jail-in-java", "canonical_source": "https://dev.to/solonjava/giving-an-ai-agent-a-real-sandbox-filesystem-and-network-jail-in-java-28bi", "published_at": "2026-09-14 00:53:49+00:00", "updated_at": "2026-09-14 01:25:41.066739+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-safety"], "entities": ["Solon AI", "solon-ai-sandbox", "Claude Code", "sandbox-runtime", "sandbox-exec", "bubblewrap", "srt-win.exe", "Java"], "alternates": {"html": "https://wpnews.pro/news/giving-an-ai-agent-a-real-sandbox-filesystem-and-network-jail-in-java", "markdown": "https://wpnews.pro/news/giving-an-ai-agent-a-real-sandbox-filesystem-and-network-jail-in-java.md", "text": "https://wpnews.pro/news/giving-an-ai-agent-a-real-sandbox-filesystem-and-network-jail-in-java.txt", "jsonld": "https://wpnews.pro/news/giving-an-ai-agent-a-real-sandbox-filesystem-and-network-jail-in-java.jsonld"}}