From 4e050e965038e0a77268b63e821ad45979d82735 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 8 Oct 2026 23:06:10 -0500 Subject: [PATCH] fix(hermes): mount usable Linux sandbox devices Provide the minimal bubblewrap device mount for native shell redirects and atomic writes, probe it before credential staging, and cover concurrent writes plus protected paths. Co-Authored-By: Paperclip --- .../drivers/acpx/hermes-installation.test.ts | 14 ++++++ .../src/drivers/acpx/hermes-installation.ts | 2 +- .../src/providers/hermes/test_bridge.py | 43 +++++++++++++++++++ .../src/providers/hermes/tool_process.py | 5 ++- 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/paperclip-runner/src/drivers/acpx/hermes-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-installation.test.ts index 469547e99a..653c039415 100644 --- a/packages/paperclip-runner/src/drivers/acpx/hermes-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-installation.test.ts @@ -12,3 +12,17 @@ it("probes the macOS process policy before admitting a provider", async () => { await verifyHermesCommandSandbox("darwin", execute); expect(execute).toHaveBeenCalledWith("/usr/bin/sandbox-exec", expect.arrayContaining(["/usr/bin/true"])); }); + +it("checks usable Linux character devices before admitting credentials", async () => { + const execute = vi.fn(async () => {}); + await verifyHermesCommandSandbox("linux", execute); + expect(execute).toHaveBeenCalledWith("/usr/bin/bwrap", [ + "--die-with-parent", "--unshare-pid", "--bind", "/", "/", "--proc", "/proc", "--dev", "/dev", + "--", "/bin/sh", "-c", "test -c /dev/null && : < /dev/null && : > /dev/null", + ]); +}); + +it("rejects a Linux host when the device probe fails", async () => { + const execute = vi.fn(async () => { throw new Error("/dev/null: Permission denied"); }); + await expect(verifyHermesCommandSandbox("linux", execute)).rejects.toMatchObject({ code: "HERMES_HOST_SANDBOX_UNAVAILABLE" }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts index 40674c4f1f..26bae8d1fc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts @@ -42,7 +42,7 @@ export async function verifyHermesCommandSandbox( if (platform === "darwin") { await execute("/usr/bin/sandbox-exec", ["-p", "(version 1) (allow default) (deny process-info*) (allow process-info* (target self))", "/usr/bin/true"]); } else if (platform === "linux") { - await execute("/usr/bin/bwrap", ["--die-with-parent", "--unshare-pid", "--bind", "/", "/", "--proc", "/proc", "--", "/bin/true"]); + await execute("/usr/bin/bwrap", ["--die-with-parent", "--unshare-pid", "--bind", "/", "/", "--proc", "/proc", "--dev", "/dev", "--", "/bin/sh", "-c", "test -c /dev/null && : < /dev/null && : > /dev/null"]); } else { throw new Error("Unsupported platform"); } diff --git a/packages/paperclip-runner/src/providers/hermes/test_bridge.py b/packages/paperclip-runner/src/providers/hermes/test_bridge.py index 16a0f30bea..973a096c49 100644 --- a/packages/paperclip-runner/src/providers/hermes/test_bridge.py +++ b/packages/paperclip-runner/src/providers/hermes/test_bridge.py @@ -260,6 +260,49 @@ class Controls(unittest.IsolatedAsyncioTestCase): self.assertEqual(second.message_id, text.message_id) +class ToolProcess(unittest.TestCase): + def test_linux_devices_keep_protected_and_assigned_overlays(self): + import tool_process + with patch.object(tool_process.sys, "platform", "linux"), patch.object(tool_process.shutil, "which", return_value="/usr/bin/bwrap"): + command = tool_process.sandbox_command(["/bin/sh", "-c", "true"], cwd="/workspace", + policy={"protectedPaths": ["/runtime"]}, assigned=["/assigned"]) + self.assertEqual(command[:11], ["/usr/bin/bwrap", "--die-with-parent", "--unshare-pid", "--bind", "/", "/", "--proc", "/proc", "--dev", "/dev", "--tmpfs"]) + self.assertEqual(command[11:], ["/runtime", "--ro-bind", "/assigned", "/assigned", "--chdir", "/workspace", "--", "/bin/sh", "-c", "true"]) + + def test_linux_device_redirection_supports_concurrent_atomic_writes(self): + import subprocess + import sys + import tempfile + from concurrent.futures import ThreadPoolExecutor + from pathlib import Path + from tool_process import sandbox_command + if sys.platform != "linux": + self.skipTest("Requires the real Linux bubblewrap device mount") + with tempfile.TemporaryDirectory() as root: + root = Path(root) + workspace, protected, assigned = [root / name for name in ("workspace", "protected", "assigned")] + for directory in (workspace, protected, assigned): + directory.mkdir() + (protected / "secret").write_text("MUST_NOT_READ") + (assigned / "SKILL.md").write_text("Assigned instructions") + script = ('set -eu; test -c /dev/null; : < /dev/null; : > /dev/null; ' + 'test ! -e "$3/secret"; test -r "$4/SKILL.md"; ' + 'if printf forbidden > "$4/changed" 2>/dev/null; then exit 23; fi; ' + 'tmp="$(mktemp "$1/.device-write.XXXXXX" 2>/dev/null)"; ' + 'printf "%s" "$2" > "$tmp"; mv "$tmp" "$1/$2"') + def write(index): + name = f"write-{index}" + command = sandbox_command(["/bin/sh", "-c", script, "fixture", str(workspace), name, str(protected), str(assigned)], + cwd=str(workspace), policy={"protectedPaths": [str(protected)]}, assigned=[str(assigned)]) + subprocess.run(command, check=True, capture_output=True, timeout=10) + self.assertEqual((workspace / name).read_text(), name) + with ThreadPoolExecutor(max_workers=4) as pool: + list(pool.map(write, range(16))) + self.assertEqual(len(list(workspace.iterdir())), 16) + self.assertFalse((assigned / "changed").exists()) + self.assertEqual((protected / "secret").read_text(), "MUST_NOT_READ") + + class NoAuth(unittest.TestCase): def test_no_auth_omits_sdk_headers_and_metadata_probe_credentials(self): import openai diff --git a/packages/paperclip-runner/src/providers/hermes/tool_process.py b/packages/paperclip-runner/src/providers/hermes/tool_process.py index 7d237af892..c004f5d82d 100644 --- a/packages/paperclip-runner/src/providers/hermes/tool_process.py +++ b/packages/paperclip-runner/src/providers/hermes/tool_process.py @@ -33,7 +33,10 @@ def sandbox_command(command, *, cwd, policy, assigned): bwrap = shutil.which("bwrap") if not bwrap: raise PermissionError("Hermes command execution requires bubblewrap in the runner image") - args = [bwrap, "--die-with-parent", "--unshare-pid", "--bind", "/", "/", "--proc", "/proc"] + # Ordinary bind mounts disable device access. Provide bubblewrap's + # minimal /dev so native shell redirects and mktemp work; apply the + # protected and assigned overlays afterwards so they remain binding. + args = [bwrap, "--die-with-parent", "--unshare-pid", "--bind", "/", "/", "--proc", "/proc", "--dev", "/dev"] for path in protected: args += ["--tmpfs", path] for path in readonly: