You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
-**`~/.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`.
175
175
-**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.
176
176
-**`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.
177
178
-**`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.
178
179
-**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.
179
180
-**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.
0 commit comments