Skip to content

Commit 0dd2179

Browse files
jkyberneeesclaude
andcommitted
fix(subagent): make the sub-agent child non-dumpable on Linux
A background process left by a child could reopen the child's stdout (or frame/key descriptor) via /proc/<pid>/fd, steal the authenticated result frame and write a forged one with its nonce. The child now calls prctl(PR_SET_DUMPABLE, 0) before reading its descriptors, making them root-owned in /proc. Costs: no core dumps, no same-uid debugger attach. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3268044 commit 0dd2179

6 files changed

Lines changed: 74 additions & 1 deletion

File tree

‎cmd/odek/subagent.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,12 @@ func resolveProfileName(cliFlag, taskFile string) string {
778778
}
779779

780780
func subagentCmd(args []string) error {
781+
// Become non-dumpable before touching the inherited descriptors, so a
782+
// same-uid process cannot reopen them (or stdout) through /proc. A
783+
// failure is reported but not fatal: the frame nonce still applies.
784+
if err := hardenSubagentProcess(); err != nil {
785+
fmt.Fprintf(os.Stderr, "odek subagent: warning: %v\n", err)
786+
}
781787
// Read the result-frame nonce first: the inherited descriptor must be
782788
// closed before anything else (MCP servers, tools) can be spawned.
783789
frameNonce := readFrameNonceFromInheritedFD()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//go:build linux
2+
3+
package main
4+
5+
import (
6+
"fmt"
7+
8+
"golang.org/x/sys/unix"
9+
)
10+
11+
// hardenSubagentProcess marks the sub-agent child non-dumpable. On Linux a
12+
// process running as the same uid can otherwise open /proc/<child>/fd/N and
13+
// reopen the child's stdout pipe, its result-frame descriptor or its key
14+
// descriptor: a background command left behind by the child could read the
15+
// authenticated result frame before the parent does and write a forged one
16+
// in its place. A non-dumpable process's /proc/<pid>/fd, environ and mem are
17+
// owned by root, so same-uid processes can no longer open them. The flag is
18+
// reset on execve, so commands the child runs are unaffected.
19+
//
20+
// The trade-off: no core dumps of sub-agents, and same-uid debuggers and
21+
// tracers (gdb, strace -p) cannot attach to them.
22+
func hardenSubagentProcess() error {
23+
if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil {
24+
return fmt.Errorf("prctl(PR_SET_DUMPABLE, 0): %w", err)
25+
}
26+
return nil
27+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//go:build linux
2+
3+
package main
4+
5+
import (
6+
"testing"
7+
8+
"golang.org/x/sys/unix"
9+
)
10+
11+
// The sub-agent child makes itself non-dumpable so same-uid processes cannot
12+
// reopen its descriptors through /proc/<pid>/fd.
13+
func TestRED_SubagentProcessNonDumpable(t *testing.T) {
14+
t.Cleanup(func() { _ = unix.Prctl(unix.PR_SET_DUMPABLE, 1, 0, 0, 0) })
15+
if err := hardenSubagentProcess(); err != nil {
16+
t.Fatal(err)
17+
}
18+
got, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0)
19+
if err != nil {
20+
t.Fatal(err)
21+
}
22+
if got != 0 {
23+
t.Fatalf("PR_GET_DUMPABLE = %d after hardening, want 0", got)
24+
}
25+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//go:build !linux
2+
3+
package main
4+
5+
// hardenSubagentProcess is a no-op outside Linux: no other supported platform
6+
// lets a same-uid process reopen another process's descriptors through /proc.
7+
func hardenSubagentProcess() error { return nil }

‎docs/SECURITY.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ The sub-agent process reads both at startup. `applySubagentTrust` clamps its `Da
303303

304304
**API key and secret handoff.** The API key is **not** passed via process environment. It is written to a 0600 temp file that is `unlink()`ed immediately (the FD survives), and the FD is handed to the child via `cmd.ExtraFiles` with an `ODEK_API_KEY_FD=3` env signal. The child reads from FD 3 once and closes it. The key never appears in `/proc/<pid>/environ`, in crash logs, or to any tool the child invokes that prints its own environment (`env`, `printenv`, etc.). On Windows, where you cannot `unlink` an open file, a 0600 temp file is used and deleted by the parent after the child exits. Whenever a key is handed off, every provider key variable odek reads (`ODEK_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY`, `MOONSHOT_API_KEY`) is also stripped from the child environment, so a key that came from the real process environment is not inherited a second way. Beyond the primary key, sub-agent children are spawned with all `~/.odek/secrets.env` values stripped from their environment (`childEnvWithout`), so `TELEGRAM_BOT_TOKEN` and every other injected secret stay unreadable in the child. Sub-agents also inherit the operator's resolved execution budgets, so child spend is bounded. `delegate_tasks` stamps the parent's `provider`, `model`, and selected `base_url` into the task envelope so the handed-off key authenticates that provider, not a child's default.
305305

306-
**Child result frames are authenticated.** A command the child runs can write to the child's stdout and forge a result line (e.g. a `usage` block that under-charges the shared budget). The parent mints a random per-spawn nonce and hands it over a second private descriptor (`ODEK_SUBAGENT_FRAME_FD`, same unlinked-FD discipline as the key; the env var names the descriptor, never the value). The child reads and closes it before any tool runs and stamps it on its framed result; the parent accepts only the first frame carrying it and ignores later result lines. Only the authenticated frame may report usage — otherwise the grant is settled as unreported and never refunded, and the unauthenticated line is shown with status `unverified` (never its claimed `success`) while the parent announces the terminal state itself. Residual: on Linux a process the child left running can reopen the child's stdout pipe through `/proc/<pid>/fd/1`, read the real frame and replay its nonce; that needs a lingering background process of a trusted child and only affects that child's own accounting.
306+
**Child result frames are authenticated.** A command the child runs can write to the child's stdout and forge a result line (e.g. a `usage` block that under-charges the shared budget). The parent mints a random per-spawn nonce and hands it over a second private descriptor (`ODEK_SUBAGENT_FRAME_FD`, same unlinked-FD discipline as the key; the env var names the descriptor, never the value). The child reads and closes it before any tool runs and stamps it on its framed result; the parent accepts only the first frame carrying it and ignores later result lines. Only the authenticated frame may report usage — otherwise the grant is settled as unreported and never refunded, and the unauthenticated line is shown with status `unverified` (never its claimed `success`) while the parent announces the terminal state itself. On Linux a process the child left running could otherwise reopen the child's stdout pipe (or the frame and key descriptors) through `/proc/<pid>/fd/N`, read the real frame before the parent and write a forged one carrying the nonce. The child therefore calls `prctl(PR_SET_DUMPABLE, 0)` at startup, before it reads either descriptor: a non-dumpable process's `/proc/<pid>/fd`, `environ` and `mem` are owned by root, so same-uid processes cannot open them. The flag resets on `execve`, so commands the child runs are unaffected. Trade-off: sub-agents produce no core dumps and same-uid debuggers and tracers (`gdb`, `strace -p`) cannot attach to them. Residual: root, or a process holding `CAP_SYS_PTRACE`, can still open those descriptors; if `prctl` fails the child logs a warning and continues with the nonce alone; other platforms have no `/proc` descriptor reopening and need no equivalent.
307307

308308
**Main-process env clearing.** After `LoadConfig` resolves keys into memory (and registers them with `redact`), the parent **unsets** `ODEK_API_KEY`, `DEEPSEEK_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` / `GOOGLE_API_KEY`, `ZAI_API_KEY`, and `KIMI_API_KEY` / `MOONSHOT_API_KEY`. go-llm-sdk is constructed from the resolved struct, not `FromEnv()` after that unset. Tools in the parent (`shell`, `printenv`) therefore do not see provider keys in the process environment.
309309

‎docs/SUBAGENTS.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,14 @@ kept for display only: its status becomes `unverified` (the claimed status is
690690
kept as `claimed_status`), the completion event and registry report
691691
`unverified`, and the parent announces the terminal state itself.
692692

693+
On Linux the child also makes itself non-dumpable (`prctl(PR_SET_DUMPABLE,
694+
0)`) before reading its inherited descriptors, so a background command it
695+
left running cannot reopen its stdout, frame or key descriptor through
696+
`/proc/<pid>/fd` to steal the frame and replay the nonce. As a consequence
697+
sub-agent processes write no core dumps and cannot be attached to by same-uid
698+
debuggers or tracers (`gdb`, `strace -p`); root or `CAP_SYS_PTRACE` still can.
699+
Commands the child runs are not affected (the flag resets on `execve`).
700+
693701
**Cost semantics (authoritative — do not re-derive).** `cost_usd` values are
694702
computed server-side with the exact `/api/usage` math (per-million prices
695703
resolved for the child's model over provider-reported token totals). Zero or

0 commit comments

Comments
 (0)