Skip to content

Commit 784fc04

Browse files
authored
feat(schedule): schedule-specific dangerous policy override (#40)
* feat(schedule): add schedule-specific dangerous policy override Add schedules.dangerous config section so operators can allow network_egress, system_write, code_execution, install, or unknown for unattended scheduled runs without widening the global policy or docker/config.restricted.json. Safety floor remains non-overrideable: - non_interactive is forced to deny - destructive and blocked classes are always denied Project-level odek.json cannot set schedules.dangerous (same trust model as the top-level dangerous section). Configuration is accepted via ~/.odek/config.json or ODEK_SCHEDULES_DANGEROUS_* env vars. New env vars: - ODEK_SCHEDULES_DANGEROUS_CLASSES (JSON object) - ODEK_SCHEDULES_DANGEROUS_ALLOWLIST (comma-separated) - ODEK_SCHEDULES_DANGEROUS_DENYLIST (comma-separated) - ODEK_SCHEDULES_DANGEROUS_ACTION - ODEK_SCHEDULES_DANGEROUS_NON_INTERACTIVE Includes tests and docs/CONFIG.md update. * test(schedule): increase coverage for schedule-specific danger policy - Extract buildHeadlessDangerConfig so the policy assembly is unit-testable - Add tests for default safety floor, schedule overrides, and floor enforcement - Cover env parsing edge cases: invalid JSON, empty lists, action-only overrides - Cover merge helpers for nil base/schedule classes and scalar overrides Feature-related functions now at 100% statement coverage: - buildHeadlessDangerConfig - mergeScheduleDangerous - envScheduleDangerousConfig - mergeDangerousConfig * chore(docker): add schedules.dangerous to example configs Add schedule-specific dangerous policy examples to both Docker configs: - config.godmode.json: sets schedules.dangerous.action = "allow" so scheduled runs are permissive while the safety floor still denies destructive/blocked and forces non_interactive=deny. - config.restricted.json: allows network_egress for scheduled runs while keeping the interactive policy restricted (network_egress stays prompt for CLI/REPL/WebUI). This demonstrates how to run unattended online searches without widening the global restricted policy. * chore(docker): set skills.verbose to false in example configs The example docker configs had skills.verbose enabled; set it to false for quieter default output.
1 parent a7146c8 commit 784fc04

7 files changed

Lines changed: 545 additions & 42 deletions

File tree

cmd/odek/schedule.go

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -625,38 +625,50 @@ func startSchedulerForBot(ctx context.Context, bot *telegram.Bot, resolved confi
625625

626626
// ── headless agent execution ────────────────────────────────────────────
627627

628-
// runTaskHeadless builds a fresh agent with no terminal renderer and no
629-
// interactive approver and runs one task to completion, returning the final
630-
// text and the tokens it consumed. mcpTools are pre-connected MCP tools shared
631-
// across fires (the builtin tools are rebuilt per call — they're cheap and
632-
// must not be shared concurrently); pass nil for none.
628+
// buildHeadlessDangerConfig assembles the danger policy for an unattended
629+
// scheduled run. It starts from the global config, overlays any schedule-
630+
// specific policy, and then applies a non-overrideable safety floor.
633631
//
634-
// Safety: a scheduled task runs unattended, so there is no human to answer an
635-
// approval prompt. builtinTools is given a nil approver, which means a
636-
// Prompt-class op would fall back to DangerousConfig.NonInteractiveAction().
637-
// To prevent a compromised task (or a permissive "godmode" profile) from
638-
// executing destructive/network operations while no one is watching, we force
639-
// NonInteractive to "deny" and clamp the highest-risk classes to Deny
640-
// regardless of what the resolved config says. This mirrors the untrusted
641-
// sub-agent damage cap.
642-
func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system, task string, mcpTools []odek.Tool) (string, int64, error) {
632+
// Safety floor:
633+
// - non_interactive is forced to "deny" (no human present to approve)
634+
// - destructive and blocked classes are always denied
635+
//
636+
// Schedule-specific overrides can allow network_egress, system_write,
637+
// code_execution, install, or unknown for cron jobs.
638+
func buildHeadlessDangerConfig(resolved config.ResolvedConfig) danger.DangerousConfig {
643639
dangerCfg := resolved.Dangerous
640+
mergeScheduleDangerous(&dangerCfg, resolved.Schedules.Dangerous)
641+
644642
deny := "deny"
645643
dangerCfg.NonInteractive = &deny
646644
if dangerCfg.Classes == nil {
647645
dangerCfg.Classes = make(map[danger.RiskClass]danger.Action)
648646
}
647+
// Non-overrideable floor. Destructive and blocked are irreversible or
648+
// hard-coded malicious; scheduled runs must never execute them.
649649
for _, cls := range []danger.RiskClass{
650650
danger.Destructive,
651-
danger.CodeExecution,
652-
danger.Install,
653-
danger.SystemWrite,
654-
danger.NetworkEgress,
655-
danger.Unknown,
656651
danger.Blocked,
657652
} {
658653
dangerCfg.Classes[cls] = danger.Deny
659654
}
655+
return dangerCfg
656+
}
657+
658+
// runTaskHeadless builds a fresh agent with no terminal renderer and no
659+
// interactive approver and runs one task to completion, returning the final
660+
// text and the tokens it consumed. mcpTools are pre-connected MCP tools shared
661+
// across fires (the builtin tools are rebuilt per call — they're cheap and
662+
// must not be shared concurrently); pass nil for none.
663+
//
664+
// Safety: a scheduled task runs unattended, so there is no human to answer an
665+
// approval prompt. builtinTools is given a nil approver, which means a
666+
// Prompt-class op would fall back to DangerousConfig.NonInteractiveAction().
667+
// The danger policy used is built by buildHeadlessDangerConfig, which applies
668+
// a non-overrideable safety floor on top of the global + schedule-specific
669+
// policy. Project-level odek.json is not allowed to set schedules.dangerous.
670+
func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system, task string, mcpTools []odek.Tool) (string, int64, error) {
671+
dangerCfg := buildHeadlessDangerConfig(resolved)
660672

661673
tools := builtinTools(dangerCfg, nil, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{Transcription: resolved.Transcription, Vision: resolved.Vision, WebSearch: resolved.WebSearch}, nil)
662674
tools = append(tools, mcpTools...)
@@ -699,6 +711,30 @@ func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system
699711
return result, tokens, err
700712
}
701713

714+
// mergeScheduleDangerous overlays schedule-specific dangerous policy onto the
715+
// global policy. It mutates base in place. Lists are appended; scalar/map
716+
// fields in schedule override global. Schedule policy comes from operator-
717+
// controlled sources only (~/.odek/config.json and ODEK_SCHEDULES_DANGEROUS_*
718+
// env vars); project-level odek.json is rejected by the config loader.
719+
func mergeScheduleDangerous(base *danger.DangerousConfig, schedule danger.DangerousConfig) {
720+
if schedule.Classes != nil {
721+
if base.Classes == nil {
722+
base.Classes = make(map[danger.RiskClass]danger.Action)
723+
}
724+
for k, v := range schedule.Classes {
725+
base.Classes[k] = v
726+
}
727+
}
728+
base.Allowlist = append(base.Allowlist, schedule.Allowlist...)
729+
base.Denylist = append(base.Denylist, schedule.Denylist...)
730+
if schedule.DefaultAction != nil {
731+
base.DefaultAction = schedule.DefaultAction
732+
}
733+
if schedule.NonInteractive != nil {
734+
base.NonInteractive = schedule.NonInteractive
735+
}
736+
}
737+
702738
// buildScheduledMCPTools connects the configured MCP servers ONCE so the
703739
// connections can be reused across every scheduled fire (the MCP client
704740
// serialises calls with a mutex, so sharing across concurrent runs is safe),

cmd/odek/schedule_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"time"
1010

1111
"github.com/BackendStack21/odek/internal/config"
12+
"github.com/BackendStack21/odek/internal/danger"
1213
"github.com/BackendStack21/odek/internal/schedule"
1314
"github.com/BackendStack21/odek/internal/telegram"
1415
)
@@ -218,3 +219,132 @@ func TestAppendScheduleLog_RedactsSecrets(t *testing.T) {
218219
t.Errorf("log should contain [REDACTED] markers: %q", string(data))
219220
}
220221
}
222+
223+
func TestMergeScheduleDangerous(t *testing.T) {
224+
base := danger.DangerousConfig{
225+
Classes: map[danger.RiskClass]danger.Action{
226+
danger.SystemWrite: danger.Prompt,
227+
},
228+
Allowlist: []string{"base-allow"},
229+
Denylist: []string{"base-deny"},
230+
}
231+
schedule := danger.DangerousConfig{
232+
Classes: map[danger.RiskClass]danger.Action{
233+
danger.NetworkEgress: danger.Allow,
234+
danger.SystemWrite: danger.Allow, // overrides base
235+
},
236+
Allowlist: []string{"schedule-allow"},
237+
Denylist: []string{"schedule-deny"},
238+
}
239+
mergeScheduleDangerous(&base, schedule)
240+
241+
if base.Classes[danger.NetworkEgress] != danger.Allow {
242+
t.Errorf("network_egress not added from schedule: %s", base.Classes[danger.NetworkEgress])
243+
}
244+
if base.Classes[danger.SystemWrite] != danger.Allow {
245+
t.Errorf("system_write not overridden from schedule: %s", base.Classes[danger.SystemWrite])
246+
}
247+
if len(base.Allowlist) != 2 || base.Allowlist[0] != "base-allow" || base.Allowlist[1] != "schedule-allow" {
248+
t.Errorf("allowlist not merged: %v", base.Allowlist)
249+
}
250+
if len(base.Denylist) != 2 || base.Denylist[0] != "base-deny" || base.Denylist[1] != "schedule-deny" {
251+
t.Errorf("denylist not merged: %v", base.Denylist)
252+
}
253+
}
254+
255+
func TestMergeScheduleDangerous_NilBaseClasses(t *testing.T) {
256+
base := danger.DangerousConfig{}
257+
schedule := danger.DangerousConfig{
258+
Classes: map[danger.RiskClass]danger.Action{
259+
danger.NetworkEgress: danger.Allow,
260+
},
261+
}
262+
mergeScheduleDangerous(&base, schedule)
263+
if base.Classes[danger.NetworkEgress] != danger.Allow {
264+
t.Errorf("network_egress not added when base.Classes is nil: %s", base.Classes[danger.NetworkEgress])
265+
}
266+
}
267+
268+
func TestMergeScheduleDangerous_NilScheduleClasses(t *testing.T) {
269+
base := danger.DangerousConfig{
270+
Classes: map[danger.RiskClass]danger.Action{
271+
danger.SystemWrite: danger.Allow,
272+
},
273+
}
274+
schedule := danger.DangerousConfig{
275+
Allowlist: []string{"schedule-allow"},
276+
}
277+
mergeScheduleDangerous(&base, schedule)
278+
if base.Classes[danger.SystemWrite] != danger.Allow {
279+
t.Errorf("base classes mutated when schedule.Classes is nil")
280+
}
281+
if len(base.Allowlist) != 1 || base.Allowlist[0] != "schedule-allow" {
282+
t.Errorf("allowlist not merged when schedule.Classes is nil: %v", base.Allowlist)
283+
}
284+
}
285+
286+
func TestMergeScheduleDangerous_ActionAndNonInteractive(t *testing.T) {
287+
base := danger.DangerousConfig{}
288+
schedule := danger.DangerousConfig{
289+
DefaultAction: strPtr("allow"),
290+
NonInteractive: strPtr("prompt"),
291+
}
292+
mergeScheduleDangerous(&base, schedule)
293+
if base.DefaultAction == nil || *base.DefaultAction != "allow" {
294+
t.Errorf("DefaultAction not overridden")
295+
}
296+
if base.NonInteractive == nil || *base.NonInteractive != "prompt" {
297+
t.Errorf("NonInteractive not overridden")
298+
}
299+
}
300+
301+
func TestBuildHeadlessDangerConfig_Defaults(t *testing.T) {
302+
resolved := config.ResolvedConfig{}
303+
cfg := buildHeadlessDangerConfig(resolved)
304+
305+
if cfg.NonInteractive == nil || *cfg.NonInteractive != "deny" {
306+
t.Errorf("non_interactive should be forced to deny, got %v", cfg.NonInteractive)
307+
}
308+
if cfg.Classes[danger.Destructive] != danger.Deny {
309+
t.Errorf("destructive should be denied, got %s", cfg.Classes[danger.Destructive])
310+
}
311+
if cfg.Classes[danger.Blocked] != danger.Deny {
312+
t.Errorf("blocked should be denied, got %s", cfg.Classes[danger.Blocked])
313+
}
314+
}
315+
316+
func TestBuildHeadlessDangerConfig_ScheduleOverridesAllowed(t *testing.T) {
317+
resolved := config.ResolvedConfig{
318+
Dangerous: danger.DangerousConfig{
319+
Classes: map[danger.RiskClass]danger.Action{
320+
danger.SystemWrite: danger.Prompt,
321+
},
322+
},
323+
Schedules: config.ScheduleConfig{
324+
Dangerous: danger.DangerousConfig{
325+
Classes: map[danger.RiskClass]danger.Action{
326+
danger.NetworkEgress: danger.Allow,
327+
danger.SystemWrite: danger.Allow,
328+
danger.Destructive: danger.Allow, // should be floored back to deny
329+
},
330+
},
331+
},
332+
}
333+
cfg := buildHeadlessDangerConfig(resolved)
334+
335+
if cfg.Classes[danger.NetworkEgress] != danger.Allow {
336+
t.Errorf("network_egress should be allow via schedule override, got %s", cfg.Classes[danger.NetworkEgress])
337+
}
338+
if cfg.Classes[danger.SystemWrite] != danger.Allow {
339+
t.Errorf("system_write should be allow via schedule override, got %s", cfg.Classes[danger.SystemWrite])
340+
}
341+
if cfg.Classes[danger.Destructive] != danger.Deny {
342+
t.Errorf("destructive should remain denied by safety floor, got %s", cfg.Classes[danger.Destructive])
343+
}
344+
if cfg.Classes[danger.Blocked] != danger.Deny {
345+
t.Errorf("blocked should remain denied by safety floor, got %s", cfg.Classes[danger.Blocked])
346+
}
347+
if *cfg.NonInteractive != "deny" {
348+
t.Errorf("non_interactive should remain denied by safety floor, got %s", *cfg.NonInteractive)
349+
}
350+
}

docker/config.godmode.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"tool_progress_cleanup": false,
66
"no_color": false,
77
"skills": {
8-
"verbose": true
8+
"verbose": false
99
},
1010
"transcription": {
1111
"model": "tiny",
@@ -45,5 +45,12 @@
4545
"dangerous": {
4646
"action": "allow",
4747
"non_interactive": "allow"
48+
},
49+
"schedules": {
50+
"enabled": true,
51+
"max_concurrent": 2,
52+
"dangerous": {
53+
"action": "allow"
54+
}
4855
}
49-
}
56+
}

docker/config.restricted.json

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"tool_progress_cleanup": false,
66
"no_color": false,
77
"skills": {
8-
"verbose": true
8+
"verbose": false
99
},
1010
"transcription": {
1111
"model": "tiny",
@@ -48,14 +48,25 @@
4848
"safe": "allow",
4949
"local_write": "allow",
5050
"install": "prompt",
51-
"network_egress": "prompt",
51+
"network_egress": "allow",
5252
"code_execution": "prompt",
5353
"system_write": "prompt",
5454
"unknown": "deny",
5555
"destructive": "deny",
5656
"blocked": "deny"
5757
},
5858
"allowlist": [],
59-
"denylist": ["rm -rf /"]
59+
"denylist": [
60+
"rm -rf /"
61+
]
62+
},
63+
"schedules": {
64+
"enabled": true,
65+
"max_concurrent": 2,
66+
"dangerous": {
67+
"classes": {
68+
"network_egress": "allow"
69+
}
70+
}
6071
}
61-
}
72+
}

docs/CONFIG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,41 @@ engine. Every field has an `ODEK_SCHEDULES_*` environment override.
516516
| `allow_telegram_management` | `ODEK_SCHEDULES_ALLOW_TELEGRAM_MANAGEMENT` | `true` | Allow the Telegram `/schedule` commands to create/remove/toggle/run jobs. When false, the bot still lists and previews jobs but mutations must go through `odek schedule`. |
517517
| `telegram_admin_chats` | `ODEK_SCHEDULES_TELEGRAM_ADMIN_CHATS` | `[]` | Comma-separated list of operator chat IDs. These IDs may use mutating `/schedule` commands **and** `/restart`. When empty, the bot falls back to `telegram.default_chat_id`. Read-only commands are unaffected. |
518518
| `telegram_admin_users` | `ODEK_SCHEDULES_TELEGRAM_ADMIN_USERS` | `[]` | Comma-separated list of operator user IDs. These IDs may use mutating `/schedule` commands **and** `/restart`. Read-only commands are unaffected. |
519+
| `dangerous` | see below | `{}` | Schedule-specific override for the dangerous-operations policy. |
520+
521+
### Schedule-specific dangerous policy
522+
523+
Scheduled jobs run unattended, so by default the scheduler denies any class that would require an approval prompt (`network_egress`, `system_write`, `code_execution`, `install`, `unknown`). You can override this for cron jobs without widening the policy for interactive CLI/REPL/WebUI use.
524+
525+
```json
526+
{
527+
"schedules": {
528+
"dangerous": {
529+
"classes": {
530+
"network_egress": "allow",
531+
"system_write": "allow"
532+
},
533+
"allowlist": ["curl -s https://example.com/feed.xml"]
534+
}
535+
}
536+
}
537+
```
538+
539+
Environment overrides:
540+
541+
| Env | Format |
542+
|---|---|
543+
| `ODEK_SCHEDULES_DANGEROUS_CLASSES` | JSON object, e.g. `{"network_egress":"allow","system_write":"allow"}` |
544+
| `ODEK_SCHEDULES_DANGEROUS_ALLOWLIST` | Comma-separated command strings |
545+
| `ODEK_SCHEDULES_DANGEROUS_DENYLIST` | Comma-separated command strings |
546+
| `ODEK_SCHEDULES_DANGEROUS_ACTION` | Global default action: `allow`, `deny`, or `prompt` |
547+
| `ODEK_SCHEDULES_DANGEROUS_NON_INTERACTIVE` | `allow`, `deny`, or `prompt` (ignored: scheduled runs force `deny`) |
548+
549+
Safety floor that cannot be overridden:
550+
- `non_interactive` is always `deny` (no human is present to approve).
551+
- `destructive` and `blocked` classes are always denied.
552+
553+
Project-level `odek.json` cannot set `schedules.dangerous`; configure it via `~/.odek/config.json` or environment variables.
519554

520555
Full guide: [docs/SCHEDULES.md](SCHEDULES.md).
521556

0 commit comments

Comments
 (0)