fix(security): block docker socket from sandboxed shell; disable opencode batch_tool

two real CI failures on main, both shipping bugs in the action:

1. `token-exfil-claude` was a real sandbox escape: GHA `ubuntu-latest`
   puts `runner` in the `docker` group, so a sandboxed shell could run
   `docker run --pid=host --privileged busybox cat /proc/<parent>/environ`
   and read the action process's env (which holds user secrets) — fully
   bypassing the unshare PID-namespace. fix: inside the sandbox's mount
   namespace (already private via `--mount-proc` which implies `--mount`),
   bind-mount /dev/null over /var/run/docker.sock (+ podman/containerd/crio
   variants) so any container-runtime socket connect from the sandbox fails.
   only affects sandboxed shells — host runner mount table is untouched, so
   user workflow steps outside pullfrog keep working.

2. `restricted-opencode` regressed in #719 (`experimental.batch_tool`).
   opencode's batch tool rejects MCP tools with `"Tool '<name>' not in
   registry. External tools (MCP, environment) cannot be batched."` when a
   model emits parallel `pullfrog_shell` (or any MCP) tool_use blocks,
   opencode internally routes them through batch, they all fail, the model
   misreads the error as "the tool doesn't exist", and gives up. caught by
   a `lens:` subagent in the restricted test concluding shell was
   unavailable and setting `DIAGNOSTIC_ID=empty`. drop `batch_tool: true`
   and the matching opencode-specific guidance in `instructions.ts` — native
   parallel tool_use (multiple tool_use blocks per assistant message) still
   works for both built-in and MCP tools without batch, so we lose only the
   1-25 wrapper, not parallelism.
This commit is contained in:
Colin McDonnell
2026-05-16 15:40:44 +00:00
committed by pullfrog[bot]
parent efc1b67e7b
commit c0988e35b0
3 changed files with 46 additions and 16 deletions
+30 -2
View File
@@ -96,6 +96,27 @@ function detectSandboxMethod(): SandboxMethod {
const PROC_CLEANUP =
"umount /proc 2>/dev/null; umount /proc 2>/dev/null; mount -t proc proc /proc 2>/dev/null;";
// block container-runtime sockets that would otherwise grant a PID-namespace
// escape: `docker run --pid=host --privileged busybox cat /proc/<pid>/environ`
// reads the parent action process's env (which contains user secrets) even
// though the sandbox itself is unsharing PIDs. GHA `ubuntu-latest` puts the
// `runner` user in the `docker` group by default, so the socket is reachable
// without sudo. bind-mounting /dev/null on top inside the sandbox's mount
// namespace makes the socket unreachable from sandboxed shells without
// touching the host runner (so it doesn't break user workflow steps that
// run before/after pullfrog and legitimately need docker). same trick for
// podman/containerd/cri-o sockets — all silent-fail if the path is missing.
const SOCKET_CLEANUP = [
"/var/run/docker.sock",
"/run/docker.sock",
"/var/run/podman/podman.sock",
"/run/podman/podman.sock",
"/run/containerd/containerd.sock",
"/var/run/crio/crio.sock",
]
.map((path) => `mount --bind /dev/null ${path} 2>/dev/null;`)
.join(" ");
function spawnShell(params: SpawnParams): ChildProcess {
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
const sandboxMethod = detectSandboxMethod();
@@ -110,7 +131,14 @@ function spawnShell(params: SpawnParams): ChildProcess {
if (sandboxMethod === "unshare") {
return spawn(
"unshare",
["--pid", "--fork", "--mount-proc", "bash", "-c", `${PROC_CLEANUP} ${params.command}`],
[
"--pid",
"--fork",
"--mount-proc",
"bash",
"-c",
`${PROC_CLEANUP} ${SOCKET_CLEANUP} ${params.command}`,
],
spawnOpts
);
}
@@ -143,7 +171,7 @@ function spawnShell(params: SpawnParams): ChildProcess {
"--mount-proc",
"bash",
"-c",
`${PROC_CLEANUP} exec su -p -s /bin/bash ${username} -c '${escaped}'`,
`${PROC_CLEANUP} ${SOCKET_CLEANUP} exec su -p -s /bin/bash ${username} -c '${escaped}'`,
],
{ ...spawnOpts, env: {} }
);