Skip to content

Commit 0d421b8

Browse files
committed
M6: default missing sub-agent trust_level to untrusted; classify delegate_tasks as system_write
1 parent 8062421 commit 0d421b8

6 files changed

Lines changed: 54 additions & 10 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
177177
- **odek self-invocation gate** (`internal/danger/classifier.go`) — any shell stage whose program basename is `odek` is classified as `system_write`. This prevents a prompt-injected agent from reaching the human-gated trust mutations (`odek memory promote`, `odek memory extended confirm`, `odek skill promote --force`) through the shell tool and flipping its own taint gates.
178178
- **MCP inputSchema hardening** (`cmd/odek/mcp_approval.go`) — every string in an MCP tool's `inputSchema` is recursively guard-scanned for injection patterns; schemas larger than 256 KiB are rejected; the interactive approval prompt shows a SHA-256 hash and byte size of the schema so operators can detect changes.
179179
- **MCP tool batch classification** (`internal/loop/loop.go`) — MCP tools (`<server>__<tool>`) are classified as `unknown` by `classifyToolCall`, so the batch approval gate shows them and untrusted sub-agents force them to `deny`.
180+
- **Sub-agent trust defaults + delegate_tasks gate** (`cmd/odek/subagent.go`, `internal/loop/loop.go`) — a missing `trust_level` in `delegate_tasks` defaults to `untrusted`; `delegate_tasks` itself is classified as `system_write` so it requires explicit approval before spawning child processes.
180181
- **Session ID entropy + session-scoped auth tokens** (`internal/session/session.go`, `cmd/odek/serve.go`) — session IDs now carry 128 bits of randomness (16 bytes / 32 hex chars); each session stores a 256-bit `AuthToken` required by `GET/DELETE/POST /api/sessions/<id>`, `POST /api/cancel`, and WebSocket session-resume messages via `X-Session-Token` header, `session_token` cookie, or `auth_token` WS field. Per-IP rate limiting (60/min) on session lookups adds a brute-force backstop.
181182
- **Skill/episode untrusted wrapper** (`internal/loop/loop.go` + `odek.go`) — skill context and retrieved session-episode context are passed through the caller-provided untrusted wrapper (the same nonce'd `<untrusted_content_*>` boundary used for tool output) before being injected into the model's system context. This prevents a compromised or tainted skill/episode from being treated as trusted system instructions.
182183
- **`env` / `printenv` environment-dump gate** (`internal/danger/classifier.go`) — bare `env` and `printenv` invocations are classified as `system_write` because they can leak process-environment secrets that the redaction scanner does not recognise. `env VAR=value <cmd>` still classifies `<cmd>` normally.

cmd/odek/subagent.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -572,14 +572,18 @@ func applySubagentTrust(dc *danger.DangerousConfig, trustLevel, maxRisk string)
572572
if dc == nil {
573573
return
574574
}
575-
if trustLevel == "" && maxRisk == "" {
576-
return
577-
}
578575

579576
if dc.Classes == nil {
580577
dc.Classes = make(map[danger.RiskClass]danger.Action)
581578
}
582579

580+
// Default to untrusted: if a parent agent omits trust_level, the sub-agent
581+
// must not inherit the parent's TTY/approval context. This closes the path
582+
// where a prompt-injected parent silently spawns a full-trust child.
583+
if trustLevel == "" {
584+
trustLevel = "untrusted"
585+
}
586+
583587
if trustLevel == "untrusted" {
584588
deny := "deny"
585589
dc.NonInteractive = &deny

cmd/odek/subagent_trust_test.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,27 @@ import (
66
"github.com/BackendStack21/odek/internal/danger"
77
)
88

9-
// TestApplySubagentTrust_Noop confirms that with neither trust_level nor
10-
// max_risk set, the DangerousConfig is unchanged.
11-
func TestApplySubagentTrust_Noop(t *testing.T) {
9+
// TestApplySubagentTrust_EmptyDefaultsToUntrusted confirms that a missing
10+
// trust_level is treated as untrusted, so a parent cannot spawn a full-trust
11+
// sub-agent simply by omitting the field.
12+
func TestApplySubagentTrust_EmptyDefaultsToUntrusted(t *testing.T) {
1213
dc := danger.DangerousConfig{}
1314
applySubagentTrust(&dc, "", "")
14-
if len(dc.Classes) != 0 {
15-
t.Errorf("Classes mutated for noop call: %+v", dc.Classes)
15+
if dc.NonInteractive == nil || *dc.NonInteractive != "deny" {
16+
t.Errorf("NonInteractive should default to 'deny' when trust_level is empty, got %v", dc.NonInteractive)
1617
}
17-
if dc.NonInteractive != nil {
18-
t.Errorf("NonInteractive mutated for noop call: %v", *dc.NonInteractive)
18+
for _, cls := range []danger.RiskClass{
19+
danger.Destructive,
20+
danger.CodeExecution,
21+
danger.Install,
22+
danger.SystemWrite,
23+
danger.NetworkEgress,
24+
danger.Unknown,
25+
danger.Blocked,
26+
} {
27+
if got := dc.Classes[cls]; got != danger.Deny {
28+
t.Errorf("Class %s = %q, want %q when trust_level is empty", cls, got, danger.Deny)
29+
}
1930
}
2031
}
2132

docs/SECURITY.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,18 @@ MCP tool names are registered as `<server>__<tool>`. They were not handled by `i
577577

578578
`classifyToolCall` now detects the `__` naming convention and returns `unknown` for any MCP tool. This makes the batch gate display the tool instead of hiding it, and `applySubagentTrust` forces `unknown` to `deny` for untrusted sub-agents.
579579

580+
### 39g. Sub-agent trust defaults + `delegate_tasks` gate
581+
582+
The `delegate_tasks` tool spawns child `odek subagent` processes. Previously:
583+
584+
- `trust_level` was optional and `applySubagentTrust` no-oped when it was empty. A prompt-injected parent could omit the field and the child inherited the parent's TTY, so its approval prompts appeared indistinguishable from the parent's and its dangerous classes were not clamped.
585+
- `delegate_tasks` itself had no classification, so the batch approval gate did not surface it.
586+
587+
Now:
588+
589+
- `cmd/odek/subagent.go::applySubagentTrust` treats a missing `trust_level` as `untrusted`, forcing `non_interactive: deny` and denying Destructive/CodeExecution/Install/SystemWrite/NetworkEgress/Unknown/Blocked.
590+
- `internal/loop/loop.go::classifyToolCall` classifies `delegate_tasks` as `system_write`, so spawning sub-agents requires explicit operator approval and cannot be used to escape the parent's approval gate.
591+
580592
### 40. `/api/resources` result limit cap
581593

582594
The `/api/resources?q=...&limit=N` autocomplete endpoint previously accepted any positive `limit` value. It is now capped to 100 results both in the HTTP handler and in `Registry.Search`. This prevents a prompt-injected or attacker-forged request from forcing an unbounded directory walk and returning a multi-megabyte JSON response.

internal/loop/loop.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1411,6 +1411,12 @@ func classifyToolCall(name, args string) (danger.RiskClass, string) {
14111411
}
14121412
case "http_batch":
14131413
return danger.NetworkEgress, args
1414+
case "delegate_tasks":
1415+
// Spawning sub-agents is a trust-mutating operation: a compromised or
1416+
// prompt-injected parent can use it to escape its own approval gate by
1417+
// running commands in a child that shares the parent's terminal. Treat
1418+
// the call itself as system_write so it requires explicit approval.
1419+
return danger.SystemWrite, args
14141420
default:
14151421
// MCP tools are registered with names of the form <server>__<tool>.
14161422
// They bypass the built-in danger classifier because the server, not

internal/loop/loop_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1861,6 +1861,16 @@ func TestClassifyToolCall_MCPTool(t *testing.T) {
18611861
}
18621862
}
18631863

1864+
func TestClassifyToolCall_DelegateTasks(t *testing.T) {
1865+
risk, resource := classifyToolCall("delegate_tasks", `{"tasks":[{"goal":"x"}]}`)
1866+
if risk != danger.SystemWrite {
1867+
t.Errorf("delegate_tasks risk = %q, want system_write", risk)
1868+
}
1869+
if resource == "" {
1870+
t.Error("delegate_tasks resource should not be empty")
1871+
}
1872+
}
1873+
18641874
// ── Skills + Episode dedup regression tests ─────────────────────────
18651875
//
18661876
// TestEngine_SkillsAndEpisodesBothLoad verifies that when both skillLoader

0 commit comments

Comments
 (0)