Skip to content

Commit 285719f

Browse files
authored
fix(sandbox): kill in-container process group on command timeout (#107)
When a sandboxed shell command timed out (or the turn was cancelled), only the host-side docker exec client was killed - Docker does not propagate the signal, so the in-container process kept running (CPU/memory, half-written files in /workspace) until the container was torn down at session end. Repeated timeouts accumulated runaway processes inside the container. shellTool and parallelShellTool now run sandboxed commands under a pid-marker wrapper (wrapSandboxCommand): the wrapper records the container-side pid of its group-leading shell in a per-invocation pidfile under /tmp, with the command passed as $1 (never interpolated, so quoting cannot break out). docker exec processes are their own process-group leaders (pgid == pid, verified empirically on alpine), so on timeout/cancel odek follows up with docker exec ... kill -KILL -<pgid>, tearing down the command and every child it forked. Children that call setsid/setpgid escape the group - the follow-up is best-effort, not a hard guarantee. The "container restart after N stale kills" escalation from the report is deliberately not implemented: the group-kill follow-up addresses the leak directly without mid-session container churn. E2E tests (gated on ODEK_E2E + docker) prove no in-container survivors after shell and parallel_shell timeouts while the container init stays healthy; both fail against the pre-fix code. Unit tests updated for the new buildCmd signature and argv shape.
1 parent b262a78 commit 285719f

5 files changed

Lines changed: 172 additions & 18 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
174174
- **`~/.odek` trust-anchor protection** (`internal/danger/classifier.go`, `cmd/odek/file_tool.go`) — generic file tools reject writes to `~/.odek/config.json`, `secrets.env`, `IDENTITY.md`, `skills/`, `sessions/`, `audit/`, `plans/`, `schedules.json`, `schedule-state.json`, `mcp_approvals.json`, `mcp_tool_approvals.json`, `project_sandbox_approvals.json`, `restart.json`, `telegram.lock`, and related state files. These paths classify as `system_write` and must be modified through their dedicated subsystems. Matching is case-insensitive so variants such as `CONFIG.JSON` or `SECRETS.ENV` are also blocked on case-insensitive filesystems (e.g. macOS APFS). Shell reads of these trust anchors are also escalated to `system_write`.
175175
- **Nonce'd tool-result delimiter** (`internal/loop/loop.go`) — the static `┌── TOOL RESULT: ... └── END TOOL RESULT: ...` delimiter is now unique per tool call via a random hex nonce embedded in both the opening and closing lines. A tool or MCP server can no longer forge the closing delimiter to break out of the data framing and inject instructions.
176176
- **`parallel_shell` context + process-group kill** (`cmd/odek/perf_tools.go`) — commands now run via `exec.CommandContext` bound to the agent context, in their own process group. Cancellation or timeout kills the whole group (negative PID), so `sh -c 'sleep 3600 &'` cannot leave orphaned children. Per-command timeouts are also capped at 30 minutes.
177+
- **In-container timeout kill** (`cmd/odek/shell.go::wrapSandboxCommand`) — killing the host-side `docker exec` client on timeout/cancel does not terminate the in-container process (Docker does not propagate the signal). Sandboxed `shell`/`parallel_shell` commands therefore run under a pid-marker wrapper that records the container-side group leader's pid in `/tmp/.odek-cmd-*.pid`; on cancellation odek follows up with `docker exec … kill -KILL -<pgid>`, so a timed-out command cannot keep burning CPU/memory or leave half-written files until the container is torn down. Children that call setsid/setpgid escape the group — best-effort, not a guarantee.
177178
- **`batch_patch` trusted-class propagation** (`cmd/odek/perf_tools.go`) — `batch_patch` now passes its cached `trustedClasses` to `CheckOperation`, matching `write_file` and `patch`. A trusted `local_write` class is honored across all patches in the batch instead of re-prompting per patch.
178179
- **Browser link URL wrapping** (`cmd/odek/browser_tool.go`) — interactive element text was already wrapped as untrusted, but link URLs in `clickableRef.URL` were returned raw. They are now wrapped too, while an unexported `rawURL` is kept for internal click resolution.
179180
- **Browser post-redirect URL attribution** (`cmd/odek/browser_tool.go`) — `browser_navigate` now uses `resp.Request.URL` (the final post-redirect URL) for the snapshot URL, the untrusted-content source, and relative-link click resolution, instead of attributing content to the original requested URL.

cmd/odek/perf_tools.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,8 +498,11 @@ func (t *parallelShellTool) runOne(cmd parallelShellCmd) parallelShellEntry {
498498
defer cancel()
499499

500500
var shCmd *exec.Cmd
501+
var killInContainer func()
501502
if t.containerName != "" {
502-
shCmd = exec.CommandContext(ctx, "docker", "exec", "-w", "/workspace", t.containerName, "sh", "-c", cmd.Command)
503+
argv, followUp := wrapSandboxCommand(t.containerName, cmd.Command)
504+
shCmd = exec.CommandContext(ctx, "docker", argv...)
505+
killInContainer = followUp
503506
} else {
504507
shCmd = exec.CommandContext(ctx, "sh", "-c", cmd.Command)
505508
}
@@ -521,6 +524,12 @@ func (t *parallelShellTool) runOne(cmd parallelShellCmd) parallelShellEntry {
521524
shCmd.WaitDelay = 3 * time.Second
522525

523526
err := shCmd.Run()
527+
// Killing the host-side `docker exec` client does not terminate the
528+
// in-container process (Docker does not propagate the signal); kill its
529+
// process group explicitly so a timed-out command cannot linger.
530+
if ctx.Err() != nil && killInContainer != nil {
531+
killInContainer()
532+
}
524533
entry.Stdout = strings.TrimSpace(stdout.String())
525534
entry.Stderr = strings.TrimSpace(stderr.String())
526535
entry.DurationMs = time.Since(start).Milliseconds()

cmd/odek/sandbox_e2e_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,80 @@ func TestE2E_SandboxFileInjection_MultipleFiles(t *testing.T) {
218218
}
219219
}
220220
}
221+
222+
// TestE2E_SandboxTimeoutKillsInContainerProcesses verifies finding 7's fix:
223+
// when a sandboxed shell command times out, the in-container process group
224+
// is killed — not just the host-side `docker exec` client. Before the fix,
225+
// the in-container processes kept running until the container was destroyed.
226+
func TestE2E_SandboxTimeoutKillsInContainerProcesses(t *testing.T) {
227+
skipIfNoE2E(t)
228+
229+
workDir := t.TempDir()
230+
containerName := fmt.Sprintf("odek-test-timeout-%d", time.Now().UnixNano())
231+
args := sandbox.BuildRunArgs(sandboxConfig{
232+
Image: "alpine:latest",
233+
Network: "none",
234+
}, containerName, workDir, "alpine:latest")
235+
createCmd := exec.Command("docker", args...)
236+
createCmd.Stderr = os.Stderr
237+
if err := createCmd.Run(); err != nil {
238+
t.Fatalf("create container: %v", err)
239+
}
240+
defer exec.Command("docker", "rm", "-f", containerName).Run()
241+
time.Sleep(500 * time.Millisecond)
242+
243+
st := &shellTool{containerName: containerName, timeout: 1 * time.Second}
244+
// Fork a background child AND block in the foreground: both must die.
245+
_, err := st.Call(`{"command": "sleep 300 & sleep 300"}`)
246+
if err == nil || !strings.Contains(err.Error(), "timed out") {
247+
t.Fatalf("expected timeout error, got: %v", err)
248+
}
249+
250+
// The follow-up runs synchronously before Call returns. PID 1 is
251+
// "sleep infinity" — assert no leftover "sleep 300" processes remain.
252+
out, err := exec.Command("docker", "exec", containerName, "ps").CombinedOutput()
253+
if err != nil {
254+
t.Fatalf("docker exec ps: %v\n%s", err, out)
255+
}
256+
if strings.Contains(string(out), "sleep 300") {
257+
t.Errorf("in-container processes survived the timeout:\n%s", out)
258+
}
259+
if !strings.Contains(string(out), "sleep infinity") {
260+
t.Errorf("container init process missing — unexpected container state:\n%s", out)
261+
}
262+
}
263+
264+
// TestE2E_SandboxParallelTimeoutKillsInContainerProcesses is the
265+
// parallel_shell analogue: a per-command timeout must kill the in-container
266+
// process group, not just the host-side client.
267+
func TestE2E_SandboxParallelTimeoutKillsInContainerProcesses(t *testing.T) {
268+
skipIfNoE2E(t)
269+
270+
workDir := t.TempDir()
271+
containerName := fmt.Sprintf("odek-test-ptimeout-%d", time.Now().UnixNano())
272+
args := sandbox.BuildRunArgs(sandboxConfig{
273+
Image: "alpine:latest",
274+
Network: "none",
275+
}, containerName, workDir, "alpine:latest")
276+
createCmd := exec.Command("docker", args...)
277+
createCmd.Stderr = os.Stderr
278+
if err := createCmd.Run(); err != nil {
279+
t.Fatalf("create container: %v", err)
280+
}
281+
defer exec.Command("docker", "rm", "-f", containerName).Run()
282+
time.Sleep(500 * time.Millisecond)
283+
284+
pt := &parallelShellTool{containerName: containerName}
285+
_, err := pt.Call(`{"commands": [{"command": "sleep 300 & sleep 300", "timeout": 1}]}`)
286+
if err != nil {
287+
t.Fatalf("parallel_shell Call: %v", err)
288+
}
289+
290+
out, err := exec.Command("docker", "exec", containerName, "ps").CombinedOutput()
291+
if err != nil {
292+
t.Fatalf("docker exec ps: %v\n%s", err, out)
293+
}
294+
if strings.Contains(string(out), "sleep 300") {
295+
t.Errorf("in-container processes survived the parallel_shell timeout:\n%s", out)
296+
}
297+
}

cmd/odek/shell.go

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import (
55
"context"
66
"encoding/json"
77
"fmt"
8+
"os"
89
"os/exec"
910
"strings"
1011
"sync"
12+
"sync/atomic"
1113
"syscall"
1214
"time"
1315

@@ -161,10 +163,10 @@ func (t *shellTool) Call(args string) (string, error) {
161163

162164
// Bound execution: cancel with the agent context (Ctrl-C / turn timeout)
163165
// and a generous backstop timeout so a stuck command can never wedge the
164-
// agent forever. Note: in sandbox mode this kills the host-side
165-
// `docker exec` client, which unblocks the agent, but Docker does not
166-
// propagate the signal to the in-container process — that lingers until the
167-
// container is torn down at session end.
166+
// agent forever. In sandbox mode this kills the host-side `docker exec`
167+
// client, which unblocks the agentbut Docker does not propagate the
168+
// signal to the in-container process, so buildCmd also returns a
169+
// follow-up that kills the in-container process group explicitly.
168170
base := t.toolCtx()
169171
timeout := t.timeout
170172
if timeout <= 0 {
@@ -173,7 +175,7 @@ func (t *shellTool) Call(args string) (string, error) {
173175
ctx, cancel := context.WithTimeout(base, timeout)
174176
defer cancel()
175177

176-
cmd := t.buildCmd(ctx, input.Command)
178+
cmd, killInContainer := t.buildCmd(ctx, input.Command)
177179
// Run the command in its own process group and, on cancel/timeout, kill the
178180
// WHOLE group — not just the `sh` leader. `sh -c "<cmd>"` may fork children
179181
// (e.g. `sleep`); killing only the leader leaves them alive holding the
@@ -198,6 +200,15 @@ func (t *shellTool) Call(args string) (string, error) {
198200

199201
err := cmd.Run()
200202

203+
// On timeout/cancel the host-side `docker exec` client was killed by the
204+
// group kill above, but the in-container process survives it (Docker does
205+
// not propagate the signal). Kill its process group explicitly so a
206+
// timed-out command cannot keep burning CPU/memory or leave half-written
207+
// files in /workspace until the container is torn down.
208+
if ctx.Err() != nil && killInContainer != nil {
209+
killInContainer()
210+
}
211+
201212
// Surface cancellation/timeout as a clear, actionable error rather than an
202213
// opaque "signal: killed".
203214
if ctxErr := ctx.Err(); ctxErr != nil {
@@ -283,15 +294,52 @@ func (t *shellTool) promptUser(cmd, description string) error {
283294
// buildCmd constructs the exec.Cmd for the given shell command.
284295
//
285296
// When sandbox mode is active (containerName is non-empty), the command
286-
// is wrapped in "docker exec -w /workspace <container> sh -c <cmd>".
287-
// The -w /workspace flag ensures the command runs in the working directory
288-
// that was mounted into the container during setupSandbox().
297+
// is wrapped in "docker exec -w /workspace <container>" with a pid-marker
298+
// wrapper (see wrapSandboxCommand), and the returned follow-up kills the
299+
// in-container process group — call it when the command's context is
300+
// cancelled or times out. The -w /workspace flag ensures the command runs
301+
// in the working directory that was mounted into the container during
302+
// setupSandbox(). The follow-up is nil in host mode.
289303
//
290304
// When running on the host (default), the command executes via "sh -c"
291305
// in odek's current working directory.
292-
func (t *shellTool) buildCmd(ctx context.Context, command string) *exec.Cmd {
306+
func (t *shellTool) buildCmd(ctx context.Context, command string) (*exec.Cmd, func()) {
293307
if t.containerName != "" {
294-
return exec.CommandContext(ctx, "docker", "exec", "-w", "/workspace", t.containerName, "sh", "-c", command)
308+
argv, followUp := wrapSandboxCommand(t.containerName, command)
309+
return exec.CommandContext(ctx, "docker", argv...), followUp
310+
}
311+
return exec.CommandContext(ctx, "sh", "-c", command), nil
312+
}
313+
314+
// sandboxCmdSeq numbers sandboxed command invocations so each gets a unique
315+
// pid-marker file inside the container.
316+
var sandboxCmdSeq atomic.Uint64
317+
318+
// wrapSandboxCommand builds the "docker exec" argv that runs command inside
319+
// the container with a pid-marker wrapper, plus a follow-up function that
320+
// kills the in-container process group and removes the marker.
321+
//
322+
// Killing the host-side `docker exec` client on timeout/cancel does NOT
323+
// terminate the in-container process — Docker does not propagate the signal
324+
// — so without a follow-up, timed-out commands keep running (CPU/memory,
325+
// half-written files in /workspace) until the container is destroyed.
326+
//
327+
// The wrapper records the container-side pid of the shell in a
328+
// per-invocation pidfile. docker exec processes are their own process-group
329+
// leaders (pgid == pid; verified on alpine and debian images), so
330+
// signalling the negative pid tears down the command and every child it
331+
// forked. Children that call setsid/setpgid themselves escape the group —
332+
// this is best-effort, not a hard guarantee. The command string travels as
333+
// a positional argument ($1), never interpolated into the wrapper, so
334+
// quoting cannot break out of it.
335+
func wrapSandboxCommand(containerName, command string) (argv []string, followUp func()) {
336+
pidFile := fmt.Sprintf("/tmp/.odek-cmd-%d-%d.pid", os.Getpid(), sandboxCmdSeq.Add(1))
337+
wrapper := "echo $$ > " + pidFile + "; sh -c \"$1\"; rc=$?; rm -f " + pidFile + "; exit $rc"
338+
argv = []string{"exec", "-w", "/workspace", containerName, "sh", "-c", wrapper, "odek-cmd", command}
339+
followUp = func() {
340+
// Best-effort: the container may already be gone (session cleanup).
341+
_ = exec.Command("docker", "exec", containerName, "sh", "-c",
342+
"kill -KILL -$(cat "+pidFile+") 2>/dev/null; rm -f "+pidFile).Run()
295343
}
296-
return exec.CommandContext(ctx, "sh", "-c", command)
344+
return argv, followUp
297345
}

cmd/odek/shell_test.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -189,20 +189,39 @@ func TestShellTool_Call_StdoutAndStderr(t *testing.T) {
189189

190190
func TestShellTool_BuildCmd_Local(t *testing.T) {
191191
st := &shellTool{}
192-
cmd := st.buildCmd(context.Background(), "echo test")
192+
cmd, followUp := st.buildCmd(context.Background(), "echo test")
193193
args := cmd.Args
194194
if args[0] != "sh" || args[1] != "-c" || args[2] != "echo test" {
195195
t.Errorf("local cmd args = %v, want [sh -c 'echo test']", args)
196196
}
197+
if followUp != nil {
198+
t.Error("host mode must not return an in-container follow-up")
199+
}
197200
}
198201

199202
func TestShellTool_BuildCmd_Docker(t *testing.T) {
200203
st := &shellTool{containerName: "odek-12345"}
201-
cmd := st.buildCmd(context.Background(), "echo test")
204+
cmd, followUp := st.buildCmd(context.Background(), "echo test")
202205
args := cmd.Args
203-
expected := []string{"docker", "exec", "-w", "/workspace", "odek-12345", "sh", "-c", "echo test"}
204-
if !stringSlicesEqual(args, expected) {
205-
t.Errorf("docker cmd args = %v, want %v", args, expected)
206+
// Shape: docker exec -w /workspace <container> sh -c <wrapper> odek-cmd <command>
207+
if len(args) != 10 {
208+
t.Fatalf("docker cmd args = %v, want 10 elements", args)
209+
}
210+
if args[0] != "docker" || args[1] != "exec" || args[2] != "-w" || args[3] != "/workspace" ||
211+
args[4] != "odek-12345" || args[5] != "sh" || args[6] != "-c" || args[8] != "odek-cmd" || args[9] != "echo test" {
212+
t.Errorf("docker cmd args = %v, unexpected shape", args)
213+
}
214+
// The wrapper records the group-leader pid in a marker file and the
215+
// command travels as $1, never interpolated.
216+
wrapper := args[7]
217+
if !strings.Contains(wrapper, "echo $$ > /tmp/.odek-cmd-") || !strings.Contains(wrapper, `sh -c "$1"`) {
218+
t.Errorf("wrapper = %q, want pid-marker + $1 dispatch", wrapper)
219+
}
220+
if strings.Contains(wrapper, "echo test") {
221+
t.Errorf("wrapper = %q, command must not be interpolated into it", wrapper)
222+
}
223+
if followUp == nil {
224+
t.Error("sandbox mode must return an in-container kill follow-up")
206225
}
207226
}
208227

@@ -362,7 +381,7 @@ func TestShellTool_CheckApproval(t *testing.T) {
362381

363382
func TestShellTool_BuildCmd_Default(t *testing.T) {
364383
st := &shellTool{}
365-
cmd := st.buildCmd(context.Background(), "echo hello")
384+
cmd, _ := st.buildCmd(context.Background(), "echo hello")
366385
if cmd.Args[0] != "sh" {
367386
t.Errorf("expected sh, got %s", cmd.Args[0])
368387
}

0 commit comments

Comments
 (0)