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 8376aaca0c..0c10ed9952 100644 --- a/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts @@ -41,7 +41,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 082cd1f482..bf565f8ad9 100644 --- a/packages/paperclip-runner/src/providers/hermes/test_bridge.py +++ b/packages/paperclip-runner/src/providers/hermes/test_bridge.py @@ -620,6 +620,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: