Skip to content

Commit 2df95aa

Browse files
committed
fix: validate non_interactive and reject invalid values as deny (L-1)
1 parent 6bd7c3e commit 2df95aa

6 files changed

Lines changed: 122 additions & 9 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
158158
- **Schedule atomic-write hardening** (`internal/schedule/store.go` + `internal/fsatomic`) — schedule file writes now use `fsatomic.WriteFile`, which creates a random temp file with `O_EXCL`, fsyncs data and directory, and renames over the target. A swapped-in symlink is replaced rather than followed, closing the symlink-override attack on `schedules.json` / `schedule-state.json`.
159159
- **Schedule cross-process lock hard error** (`internal/schedule/store.go`) — `fileLock` now returns an error instead of silently falling back to a no-op releaser when `~/.odek/schedules.lock` cannot be opened or locked. Mutating schedule operations abort rather than risk two concurrent processes loading the same baseline and overwriting each other.
160160
- **Schedule JSON file-size cap** (`internal/schedule/store.go`) — `schedules.json` and `schedule-state.json` are rejected if larger than 10 MiB before being read into memory, preventing a tampered multi-gigabyte file from OOMing the scheduler.
161-
- **Default non-interactive policy is deny** (`internal/danger/classifier.go`, `cmd/odek/main.go`) — headless/CI/piped runs no longer auto-approve dangerous operations. Operators must explicitly set `non_interactive: "allow"` for unattended execution.
161+
- **Default non-interactive policy is deny** (`internal/danger/classifier.go`, `cmd/odek/main.go`) — headless/CI/piped runs no longer auto-approve dangerous operations. Operators must explicitly set `non_interactive: "allow"` for unattended execution. Invalid values such as `"prompt"` are rejected at load time with a warning and floored to `"deny"`, because a non-interactive environment cannot prompt.
162162
- **`~/.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`, `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`.
163163
- **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.
164164
- **`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.

docs/SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,7 @@ Defaults: `FrictionThreshold=3`, `FrictionWindow=60s`. To opt out (TTYApprover o
721721

722722
When odek cannot open a TTY (headless/CI/piped input), prompted operations used to fall back to the `non_interactive` action. The built-in default was `"allow"`, so a prompt-injected task such as `echo "task" | odek run "download and run attacker.sh"` could silently execute `curl … | sh`.
723723

724-
The default is now `"deny"`. Unattended runs must explicitly opt in to auto-approval by setting `"non_interactive": "allow"` in `~/.odek/config.json`, `ODEK_DANGEROUS_NON_INTERACTIVE=allow`, or the CLI. This makes the safe behaviour the default and closes the headless prompt-injection auto-execution vector.
724+
The default is now `"deny"`. Unattended runs must explicitly opt in to auto-approval by setting `"non_interactive": "allow"` in `~/.odek/config.json` or the CLI. Any other value — including the previously accepted `"prompt"` — is rejected at load time with a warning and treated as `"deny"`, because a non-interactive environment cannot prompt. This makes the safe behaviour the default and closes the headless prompt-injection auto-execution vector.
725725

726726
### 26. Generic file tools cannot write `~/.odek` trust anchors
727727

internal/config/loader.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1427,7 +1427,7 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
14271427
SandboxEnv: cfg.SandboxEnv,
14281428
SandboxVolumes: cfg.SandboxVolumes,
14291429
Skills: resolveSkills(cfg.Skills),
1430-
Dangerous: resolveDangerous(cfg.Dangerous),
1430+
Dangerous: resolveDangerous(cfg.Dangerous, true),
14311431
Memory: resolveMemory(cfg.Memory),
14321432
Guard: resolveGuard(cfg.Guard),
14331433
Embedding: cfg.Embedding,
@@ -1632,11 +1632,22 @@ func resolveSkills(cfg *SkillsConfig) skills.SkillsConfig {
16321632

16331633
// resolveDangerous merges file-level and potential env-level dangerous config.
16341634
// If no config is provided, returns an empty DangerousConfig (safe defaults).
1635-
func resolveDangerous(cfg *danger.DangerousConfig) danger.DangerousConfig {
1636-
if cfg != nil {
1637-
return *cfg
1635+
// When validate is true, invalid non_interactive values are rejected with a
1636+
// warning and forced to "deny" so headless runs cannot accidentally
1637+
// auto-approve dangerous ops.
1638+
func resolveDangerous(cfg *danger.DangerousConfig, validate bool) danger.DangerousConfig {
1639+
if cfg == nil {
1640+
return danger.DangerousConfig{}
16381641
}
1639-
return danger.DangerousConfig{}
1642+
resolved := *cfg
1643+
if validate && resolved.NonInteractive != nil {
1644+
if _, ok := danger.ParseNonInteractiveAction(*resolved.NonInteractive); !ok {
1645+
fmt.Fprintf(os.Stderr, "odek: warning: invalid non_interactive value %q — must be 'allow' or 'deny'; using 'deny'\n", *resolved.NonInteractive)
1646+
deny := "deny"
1647+
resolved.NonInteractive = &deny
1648+
}
1649+
}
1650+
return resolved
16401651
}
16411652

16421653
// resolveMemory merges file-level memory config with defaults.
@@ -1955,7 +1966,7 @@ func resolveSchedules(cfg *SchedulesConfig) ScheduleConfig {
19551966
}
19561967
out.TelegramAdminChats = cfg.TelegramAdminChats
19571968
out.TelegramAdminUsers = cfg.TelegramAdminUsers
1958-
out.Dangerous = resolveDangerous(cfg.Dangerous)
1969+
out.Dangerous = resolveDangerous(cfg.Dangerous, false)
19591970
return out
19601971
}
19611972

internal/config/loader_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,48 @@ func TestLoadConfig_GlobalFile(t *testing.T) {
223223
}
224224
}
225225

226+
func TestLoadConfig_InvalidNonInteractiveFlooredToDeny(t *testing.T) {
227+
dir := t.TempDir()
228+
t.Setenv("HOME", dir)
229+
230+
cfgDir := filepath.Join(dir, ".odek")
231+
os.MkdirAll(cfgDir, 0755)
232+
cfgPath := filepath.Join(cfgDir, "config.json")
233+
if err := os.WriteFile(cfgPath, []byte(`{
234+
"dangerous": {
235+
"non_interactive": "prompt"
236+
}
237+
}`), 0644); err != nil {
238+
t.Fatal(err)
239+
}
240+
241+
cfg := LoadConfig(CLIFlags{})
242+
if cfg.Dangerous.NonInteractive == nil || *cfg.Dangerous.NonInteractive != "deny" {
243+
t.Errorf("invalid non_interactive should be floored to 'deny', got %v", cfg.Dangerous.NonInteractive)
244+
}
245+
}
246+
247+
func TestLoadConfig_ValidNonInteractivePreserved(t *testing.T) {
248+
dir := t.TempDir()
249+
t.Setenv("HOME", dir)
250+
251+
cfgDir := filepath.Join(dir, ".odek")
252+
os.MkdirAll(cfgDir, 0755)
253+
cfgPath := filepath.Join(cfgDir, "config.json")
254+
if err := os.WriteFile(cfgPath, []byte(`{
255+
"dangerous": {
256+
"non_interactive": "allow"
257+
}
258+
}`), 0644); err != nil {
259+
t.Fatal(err)
260+
}
261+
262+
cfg := LoadConfig(CLIFlags{})
263+
if cfg.Dangerous.NonInteractive == nil || *cfg.Dangerous.NonInteractive != "allow" {
264+
t.Errorf("valid non_interactive='allow' should be preserved, got %v", cfg.Dangerous.NonInteractive)
265+
}
266+
}
267+
226268
func TestLoadConfig_ProjectOverridesGlobal(t *testing.T) {
227269
dir := t.TempDir()
228270

internal/danger/classifier.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,13 +589,33 @@ func (c *DangerousConfig) ActionForCommand(cmd string) Action {
589589
// NonInteractiveAction returns the action to use when no TTY is available.
590590
// Defaults to Deny so unattended/headless runs fail closed rather than
591591
// auto-approving dangerous operations.
592+
//
593+
// Only "allow" and "deny" are accepted; any other value (including "prompt")
594+
// is treated as "deny" because a non-interactive environment cannot prompt.
592595
func (c *DangerousConfig) NonInteractiveAction() Action {
593596
if c.NonInteractive != nil {
594-
return parseAction(*c.NonInteractive)
597+
action, ok := ParseNonInteractiveAction(*c.NonInteractive)
598+
if ok {
599+
return action
600+
}
595601
}
596602
return Deny
597603
}
598604

605+
// ParseNonInteractiveAction parses the non_interactive config value. It accepts
606+
// only "allow" and "deny"; "prompt" and any other value are rejected because
607+
// prompting is impossible without a TTY.
608+
func ParseNonInteractiveAction(s string) (Action, bool) {
609+
switch strings.ToLower(strings.TrimSpace(s)) {
610+
case "allow":
611+
return Allow, true
612+
case "deny":
613+
return Deny, true
614+
default:
615+
return Deny, false
616+
}
617+
}
618+
599619
// CheckOperation checks whether a tool operation is allowed, denied,
600620
// or needs approval. Returns nil on allow, error on deny, and prompts
601621
// the user on prompt. Uses the configured Approver when set; falls back

internal/danger/classifier_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,46 @@ func TestNonInteractiveAction_Deny(t *testing.T) {
961961
}
962962
}
963963

964+
func TestNonInteractiveAction_InvalidFailsClosed(t *testing.T) {
965+
// Any value other than "allow" or "deny" (including the previously
966+
// accepted "prompt") must fail closed to Deny, because a non-interactive
967+
// environment cannot prompt.
968+
for _, s := range []string{"prompt", "maybe", "yes", "", " prompt "} {
969+
s := s
970+
t.Run(s, func(t *testing.T) {
971+
cfg := &DangerousConfig{NonInteractive: &s}
972+
if got := cfg.NonInteractiveAction(); got != Deny {
973+
t.Errorf("non-interactive %q = %s, want deny", s, got)
974+
}
975+
})
976+
}
977+
}
978+
979+
func TestParseNonInteractiveAction(t *testing.T) {
980+
cases := []struct {
981+
input string
982+
want Action
983+
wantOK bool
984+
}{
985+
{"allow", Allow, true},
986+
{"deny", Deny, true},
987+
{"ALLOW", Allow, true},
988+
{"Deny", Deny, true},
989+
{"prompt", Deny, false},
990+
{"", Deny, false},
991+
{"maybe", Deny, false},
992+
}
993+
for _, tc := range cases {
994+
got, ok := ParseNonInteractiveAction(tc.input)
995+
if ok != tc.wantOK {
996+
t.Errorf("ParseNonInteractiveAction(%q) ok=%v, want %v", tc.input, ok, tc.wantOK)
997+
}
998+
if got != tc.want {
999+
t.Errorf("ParseNonInteractiveAction(%q) = %s, want %s", tc.input, got, tc.want)
1000+
}
1001+
}
1002+
}
1003+
9641004
func TestActionFor_UnknownClass(t *testing.T) {
9651005
cfg := &DangerousConfig{}
9661006
action := cfg.ActionFor(RiskClass("nonexistent"))

0 commit comments

Comments
 (0)