Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 55 additions & 19 deletions cmd/odek/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -625,38 +625,50 @@ func startSchedulerForBot(ctx context.Context, bot *telegram.Bot, resolved confi

// ── headless agent execution ────────────────────────────────────────────

// runTaskHeadless builds a fresh agent with no terminal renderer and no
// interactive approver and runs one task to completion, returning the final
// text and the tokens it consumed. mcpTools are pre-connected MCP tools shared
// across fires (the builtin tools are rebuilt per call — they're cheap and
// must not be shared concurrently); pass nil for none.
// buildHeadlessDangerConfig assembles the danger policy for an unattended
// scheduled run. It starts from the global config, overlays any schedule-
// specific policy, and then applies a non-overrideable safety floor.
//
// Safety: a scheduled task runs unattended, so there is no human to answer an
// approval prompt. builtinTools is given a nil approver, which means a
// Prompt-class op would fall back to DangerousConfig.NonInteractiveAction().
// To prevent a compromised task (or a permissive "godmode" profile) from
// executing destructive/network operations while no one is watching, we force
// NonInteractive to "deny" and clamp the highest-risk classes to Deny
// regardless of what the resolved config says. This mirrors the untrusted
// sub-agent damage cap.
func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system, task string, mcpTools []odek.Tool) (string, int64, error) {
// Safety floor:
// - non_interactive is forced to "deny" (no human present to approve)
// - destructive and blocked classes are always denied
//
// Schedule-specific overrides can allow network_egress, system_write,
// code_execution, install, or unknown for cron jobs.
func buildHeadlessDangerConfig(resolved config.ResolvedConfig) danger.DangerousConfig {
dangerCfg := resolved.Dangerous
mergeScheduleDangerous(&dangerCfg, resolved.Schedules.Dangerous)

deny := "deny"
dangerCfg.NonInteractive = &deny
if dangerCfg.Classes == nil {
dangerCfg.Classes = make(map[danger.RiskClass]danger.Action)
}
// Non-overrideable floor. Destructive and blocked are irreversible or
// hard-coded malicious; scheduled runs must never execute them.
for _, cls := range []danger.RiskClass{
danger.Destructive,
danger.CodeExecution,
danger.Install,
danger.SystemWrite,
danger.NetworkEgress,
danger.Unknown,
danger.Blocked,
} {
dangerCfg.Classes[cls] = danger.Deny
}
return dangerCfg
}

// runTaskHeadless builds a fresh agent with no terminal renderer and no
// interactive approver and runs one task to completion, returning the final
// text and the tokens it consumed. mcpTools are pre-connected MCP tools shared
// across fires (the builtin tools are rebuilt per call — they're cheap and
// must not be shared concurrently); pass nil for none.
//
// Safety: a scheduled task runs unattended, so there is no human to answer an
// approval prompt. builtinTools is given a nil approver, which means a
// Prompt-class op would fall back to DangerousConfig.NonInteractiveAction().
// The danger policy used is built by buildHeadlessDangerConfig, which applies
// a non-overrideable safety floor on top of the global + schedule-specific
// policy. Project-level odek.json is not allowed to set schedules.dangerous.
func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system, task string, mcpTools []odek.Tool) (string, int64, error) {
dangerCfg := buildHeadlessDangerConfig(resolved)

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

// mergeScheduleDangerous overlays schedule-specific dangerous policy onto the
// global policy. It mutates base in place. Lists are appended; scalar/map
// fields in schedule override global. Schedule policy comes from operator-
// controlled sources only (~/.odek/config.json and ODEK_SCHEDULES_DANGEROUS_*
// env vars); project-level odek.json is rejected by the config loader.
func mergeScheduleDangerous(base *danger.DangerousConfig, schedule danger.DangerousConfig) {
if schedule.Classes != nil {
if base.Classes == nil {
base.Classes = make(map[danger.RiskClass]danger.Action)
}
for k, v := range schedule.Classes {
base.Classes[k] = v
}
}
base.Allowlist = append(base.Allowlist, schedule.Allowlist...)
base.Denylist = append(base.Denylist, schedule.Denylist...)
if schedule.DefaultAction != nil {
base.DefaultAction = schedule.DefaultAction
}
if schedule.NonInteractive != nil {
base.NonInteractive = schedule.NonInteractive
}
}

// buildScheduledMCPTools connects the configured MCP servers ONCE so the
// connections can be reused across every scheduled fire (the MCP client
// serialises calls with a mutex, so sharing across concurrent runs is safe),
Expand Down
130 changes: 130 additions & 0 deletions cmd/odek/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"github.com/BackendStack21/odek/internal/config"
"github.com/BackendStack21/odek/internal/danger"
"github.com/BackendStack21/odek/internal/schedule"
"github.com/BackendStack21/odek/internal/telegram"
)
Expand Down Expand Up @@ -218,3 +219,132 @@ func TestAppendScheduleLog_RedactsSecrets(t *testing.T) {
t.Errorf("log should contain [REDACTED] markers: %q", string(data))
}
}

func TestMergeScheduleDangerous(t *testing.T) {
base := danger.DangerousConfig{
Classes: map[danger.RiskClass]danger.Action{
danger.SystemWrite: danger.Prompt,
},
Allowlist: []string{"base-allow"},
Denylist: []string{"base-deny"},
}
schedule := danger.DangerousConfig{
Classes: map[danger.RiskClass]danger.Action{
danger.NetworkEgress: danger.Allow,
danger.SystemWrite: danger.Allow, // overrides base
},
Allowlist: []string{"schedule-allow"},
Denylist: []string{"schedule-deny"},
}
mergeScheduleDangerous(&base, schedule)

if base.Classes[danger.NetworkEgress] != danger.Allow {
t.Errorf("network_egress not added from schedule: %s", base.Classes[danger.NetworkEgress])
}
if base.Classes[danger.SystemWrite] != danger.Allow {
t.Errorf("system_write not overridden from schedule: %s", base.Classes[danger.SystemWrite])
}
if len(base.Allowlist) != 2 || base.Allowlist[0] != "base-allow" || base.Allowlist[1] != "schedule-allow" {
t.Errorf("allowlist not merged: %v", base.Allowlist)
}
if len(base.Denylist) != 2 || base.Denylist[0] != "base-deny" || base.Denylist[1] != "schedule-deny" {
t.Errorf("denylist not merged: %v", base.Denylist)
}
}

func TestMergeScheduleDangerous_NilBaseClasses(t *testing.T) {
base := danger.DangerousConfig{}
schedule := danger.DangerousConfig{
Classes: map[danger.RiskClass]danger.Action{
danger.NetworkEgress: danger.Allow,
},
}
mergeScheduleDangerous(&base, schedule)
if base.Classes[danger.NetworkEgress] != danger.Allow {
t.Errorf("network_egress not added when base.Classes is nil: %s", base.Classes[danger.NetworkEgress])
}
}

func TestMergeScheduleDangerous_NilScheduleClasses(t *testing.T) {
base := danger.DangerousConfig{
Classes: map[danger.RiskClass]danger.Action{
danger.SystemWrite: danger.Allow,
},
}
schedule := danger.DangerousConfig{
Allowlist: []string{"schedule-allow"},
}
mergeScheduleDangerous(&base, schedule)
if base.Classes[danger.SystemWrite] != danger.Allow {
t.Errorf("base classes mutated when schedule.Classes is nil")
}
if len(base.Allowlist) != 1 || base.Allowlist[0] != "schedule-allow" {
t.Errorf("allowlist not merged when schedule.Classes is nil: %v", base.Allowlist)
}
}

func TestMergeScheduleDangerous_ActionAndNonInteractive(t *testing.T) {
base := danger.DangerousConfig{}
schedule := danger.DangerousConfig{
DefaultAction: strPtr("allow"),
NonInteractive: strPtr("prompt"),
}
mergeScheduleDangerous(&base, schedule)
if base.DefaultAction == nil || *base.DefaultAction != "allow" {
t.Errorf("DefaultAction not overridden")
}
if base.NonInteractive == nil || *base.NonInteractive != "prompt" {
t.Errorf("NonInteractive not overridden")
}
}

func TestBuildHeadlessDangerConfig_Defaults(t *testing.T) {
resolved := config.ResolvedConfig{}
cfg := buildHeadlessDangerConfig(resolved)

if cfg.NonInteractive == nil || *cfg.NonInteractive != "deny" {
t.Errorf("non_interactive should be forced to deny, got %v", cfg.NonInteractive)
}
if cfg.Classes[danger.Destructive] != danger.Deny {
t.Errorf("destructive should be denied, got %s", cfg.Classes[danger.Destructive])
}
if cfg.Classes[danger.Blocked] != danger.Deny {
t.Errorf("blocked should be denied, got %s", cfg.Classes[danger.Blocked])
}
}

func TestBuildHeadlessDangerConfig_ScheduleOverridesAllowed(t *testing.T) {
resolved := config.ResolvedConfig{
Dangerous: danger.DangerousConfig{
Classes: map[danger.RiskClass]danger.Action{
danger.SystemWrite: danger.Prompt,
},
},
Schedules: config.ScheduleConfig{
Dangerous: danger.DangerousConfig{
Classes: map[danger.RiskClass]danger.Action{
danger.NetworkEgress: danger.Allow,
danger.SystemWrite: danger.Allow,
danger.Destructive: danger.Allow, // should be floored back to deny
},
},
},
}
cfg := buildHeadlessDangerConfig(resolved)

if cfg.Classes[danger.NetworkEgress] != danger.Allow {
t.Errorf("network_egress should be allow via schedule override, got %s", cfg.Classes[danger.NetworkEgress])
}
if cfg.Classes[danger.SystemWrite] != danger.Allow {
t.Errorf("system_write should be allow via schedule override, got %s", cfg.Classes[danger.SystemWrite])
}
if cfg.Classes[danger.Destructive] != danger.Deny {
t.Errorf("destructive should remain denied by safety floor, got %s", cfg.Classes[danger.Destructive])
}
if cfg.Classes[danger.Blocked] != danger.Deny {
t.Errorf("blocked should remain denied by safety floor, got %s", cfg.Classes[danger.Blocked])
}
if *cfg.NonInteractive != "deny" {
t.Errorf("non_interactive should remain denied by safety floor, got %s", *cfg.NonInteractive)
}
}
11 changes: 9 additions & 2 deletions docker/config.godmode.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"tool_progress_cleanup": false,
"no_color": false,
"skills": {
"verbose": true
"verbose": false
},
"transcription": {
"model": "tiny",
Expand Down Expand Up @@ -45,5 +45,12 @@
"dangerous": {
"action": "allow",
"non_interactive": "allow"
},
"schedules": {
"enabled": true,
"max_concurrent": 2,
"dangerous": {
"action": "allow"
}
}
}
}
19 changes: 15 additions & 4 deletions docker/config.restricted.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"tool_progress_cleanup": false,
"no_color": false,
"skills": {
"verbose": true
"verbose": false
},
"transcription": {
"model": "tiny",
Expand Down Expand Up @@ -48,14 +48,25 @@
"safe": "allow",
"local_write": "allow",
"install": "prompt",
"network_egress": "prompt",
"network_egress": "allow",
"code_execution": "prompt",
"system_write": "prompt",
"unknown": "deny",
"destructive": "deny",
"blocked": "deny"
},
"allowlist": [],
"denylist": ["rm -rf /"]
"denylist": [
"rm -rf /"
]
},
"schedules": {
"enabled": true,
"max_concurrent": 2,
"dangerous": {
"classes": {
"network_egress": "allow"
}
}
}
}
}
35 changes: 35 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,41 @@ engine. Every field has an `ODEK_SCHEDULES_*` environment override.
| `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`. |
| `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. |
| `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. |
| `dangerous` | see below | `{}` | Schedule-specific override for the dangerous-operations policy. |

### Schedule-specific dangerous policy

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.

```json
{
"schedules": {
"dangerous": {
"classes": {
"network_egress": "allow",
"system_write": "allow"
},
"allowlist": ["curl -s https://example.com/feed.xml"]
}
}
}
```

Environment overrides:

| Env | Format |
|---|---|
| `ODEK_SCHEDULES_DANGEROUS_CLASSES` | JSON object, e.g. `{"network_egress":"allow","system_write":"allow"}` |
| `ODEK_SCHEDULES_DANGEROUS_ALLOWLIST` | Comma-separated command strings |
| `ODEK_SCHEDULES_DANGEROUS_DENYLIST` | Comma-separated command strings |
| `ODEK_SCHEDULES_DANGEROUS_ACTION` | Global default action: `allow`, `deny`, or `prompt` |
| `ODEK_SCHEDULES_DANGEROUS_NON_INTERACTIVE` | `allow`, `deny`, or `prompt` (ignored: scheduled runs force `deny`) |

Safety floor that cannot be overridden:
- `non_interactive` is always `deny` (no human is present to approve).
- `destructive` and `blocked` classes are always denied.

Project-level `odek.json` cannot set `schedules.dangerous`; configure it via `~/.odek/config.json` or environment variables.

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

Expand Down
Loading
Loading