feat: allow configured remote cron commands

Add tools.cron.command_allowed_remotes so remote channels can be
explicitly allowlisted for command-executing cron jobs. Entries support
channel names, channel:chat_id pairs, and a literal * to allow every
non-empty channel while preserving the default deny posture.

The existing allow_command and command_confirm checks still apply after the
channel gate. Update tests, config examples, and cron documentation for the
new option.
This commit is contained in:
jp39
2026-06-16 15:25:30 +02:00
parent 083e68b49a
commit 3056a540bf
9 changed files with 271 additions and 35 deletions
+3 -1
View File
@@ -380,7 +380,9 @@
},
"cron": {
"enabled": true,
"exec_timeout_minutes": 5
"exec_timeout_minutes": 5,
"allow_command": true,
"command_allowed_remotes": []
},
"mcp": {
"enabled": false,
+5 -1
View File
@@ -1154,7 +1154,9 @@ PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can
"tools": {
"cron": {
"enabled": true,
"exec_timeout_minutes": 5
"exec_timeout_minutes": 5,
"allow_command": true,
"command_allowed_remotes": []
}
}
}
@@ -1162,6 +1164,8 @@ PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can
Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace/cron/`.
Command cron jobs can execute shell commands. By default, remote channels cannot schedule command jobs. To allow specific remote channels, set `command_allowed_remotes` to entries such as `"telegram"` or `"telegram:1234567890"`; use `"*"` only if every non-empty channel should be allowed. This does not bypass `allow_command`, `command_confirm`, or exec safety checks.
### Advanced Topics
| Topic | Description |
+3 -1
View File
@@ -647,7 +647,9 @@ picoclaw agent -m "Hello"
}
},
"cron": {
"exec_timeout_minutes": 5
"exec_timeout_minutes": 5,
"allow_command": true,
"command_allowed_remotes": []
}
},
"heartbeat": {
+21 -5
View File
@@ -43,7 +43,8 @@ the original prompt, delivery target, or command payload.
Remote channel access is scoped to the current `channel/chat_id`: remote callers
can only list, get, or update jobs whose saved `payload.channel` and `payload.to`
match the current conversation. Command jobs include a shell command payload, so
they can only be listed, inspected, or updated from internal channels.
they can only be listed, inspected, or updated from internal channels or remote
channels allowed by `tools.cron.command_allowed_remotes`.
Example tool calls:
@@ -59,7 +60,7 @@ Example tool calls:
(`at_seconds`, `every_seconds`, or `cron_expr`).
Omit `command` to preserve it, set `command` to a non-empty string to replace
it, or set `command` to `""` to clear it. Command updates require the same
internal channel and confirmation gates as command creation.
channel allowlist and confirmation gates as command creation.
## Execution Modes
@@ -104,7 +105,7 @@ If `tools.exec.enabled` is `false`:
- new command jobs are rejected by the cron tool
- existing command jobs publish a `command execution is disabled` error when they fire
`tools.exec.allow_remote` is still enforced by the exec tool, but cron command scheduling already requires an internal channel when the job is created. In practice, reminder jobs can be scheduled from remote channels, while scheduled command jobs are limited to internal channels.
`tools.exec.allow_remote` is still enforced by the exec tool, but cron command scheduling has its own channel gate when the job is created. In practice, reminder jobs can be scheduled from remote channels, while scheduled command jobs are limited to internal channels and configured remote channels.
### `allow_command`
@@ -112,7 +113,19 @@ If `tools.exec.enabled` is `false`:
This is not a hard disable switch. If you set `allow_command` to `false`, PicoClaw still allows a command job when the caller explicitly passes `command_confirm: true`.
Command jobs also require an internal channel. Non-command reminders do not have that restriction.
Command jobs also require either an internal channel or a remote channel allowed by `tools.cron.command_allowed_remotes`. Non-command reminders do not have that restriction.
### `command_allowed_remotes`
`tools.cron.command_allowed_remotes` defaults to an empty list. With the default empty list, remote channels cannot schedule command jobs.
Entries can be either a channel name or a channel plus chat id:
- `telegram` allows command jobs from any Telegram chat.
- `telegram:1234567890` allows command jobs only from that exact Telegram chat id.
- `*` allows command jobs from every non-empty channel.
This setting only controls the remote-channel gate. It does not bypass `tools.cron.allow_command`, `command_confirm`, `tools.exec.enabled`, or the exec tool's command safety checks.
Example:
@@ -122,7 +135,10 @@ Example:
"cron": {
"enabled": true,
"exec_timeout_minutes": 5,
"allow_command": true
"allow_command": true,
"command_allowed_remotes": [
"telegram:1234567890"
]
},
"exec": {
"enabled": true
+6 -5
View File
@@ -311,11 +311,12 @@ as containers, VMs, or an approval flow around build-and-run commands.
The cron tool is used for scheduling periodic tasks.
| Config | Type | Default | Description |
|------------------------|------|---------|------------------------------------------------|
| `enabled` | bool | true | Register the agent-facing cron tool |
| `allow_command` | bool | true | Allow command jobs without extra confirmation |
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
| Config | Type | Default | Description |
|---------------------------|----------|---------|----------------------------------------------------------------|
| `enabled` | bool | true | Register the agent-facing cron tool |
| `allow_command` | bool | true | Allow command jobs without extra confirmation |
| `command_allowed_remotes` | string[] | [] | Remote channels or `channel:chat_id` values allowed for command jobs; `*` allows every channel |
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
For schedule types, execution modes (`deliver`, agent turn, and command jobs), persistence, and the current command-security gates, see [Scheduled Tasks and Cron Jobs](cron.md).
+5 -3
View File
@@ -1028,9 +1028,11 @@ type WebToolsConfig struct {
}
type CronToolsConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
ExecTimeoutMinutes int ` json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
AllowCommand bool ` json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"`
ToolConfig `envPrefix:"PICOCLAW_TOOLS_CRON_"`
// 0 means no timeout.
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"`
AllowCommand bool `json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"`
CommandAllowedRemotes []string `json:"command_allowed_remotes" env:"PICOCLAW_TOOLS_CRON_COMMAND_ALLOWED_REMOTES"`
}
type ExecConfig struct {
+36
View File
@@ -1540,6 +1540,16 @@ func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
}
}
func TestDefaultConfig_CronCommandAllowedRemotesEmpty(t *testing.T) {
cfg := DefaultConfig()
if len(cfg.Tools.Cron.CommandAllowedRemotes) != 0 {
t.Fatalf(
"DefaultConfig().Tools.Cron.CommandAllowedRemotes = %#v, want empty",
cfg.Tools.Cron.CommandAllowedRemotes,
)
}
}
func TestDefaultConfig_HooksDefaults(t *testing.T) {
cfg := DefaultConfig()
if !cfg.Hooks.Enabled {
@@ -1600,6 +1610,32 @@ func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) {
}
}
func TestLoadConfig_CronCommandAllowedRemotes(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(
configPath,
[]byte(`{"version":1,"tools":{"cron":{"command_allowed_remotes":["telegram:1234567890","discord"]}}}`),
0o600,
); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
want := []string{"telegram:1234567890", "discord"}
if len(cfg.Tools.Cron.CommandAllowedRemotes) != len(want) {
t.Fatalf("CommandAllowedRemotes = %#v, want %#v", cfg.Tools.Cron.CommandAllowedRemotes, want)
}
for i := range want {
if cfg.Tools.Cron.CommandAllowedRemotes[i] != want[i] {
t.Fatalf("CommandAllowedRemotes = %#v, want %#v", cfg.Tools.Cron.CommandAllowedRemotes, want)
}
}
}
func TestLoadConfig_WebToolsProxy(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
+49 -16
View File
@@ -26,12 +26,13 @@ type JobExecutor interface {
// CronTool provides scheduling capabilities for the agent
type CronTool struct {
cronService *cron.CronService
executor JobExecutor
msgBus *bus.MessageBus
execTool *ExecTool
allowCommand bool
execEnabled bool
cronService *cron.CronService
executor JobExecutor
msgBus *bus.MessageBus
execTool *ExecTool
allowCommand bool
execEnabled bool
commandAllowedRemotes []string
}
// NewCronTool creates a new CronTool
@@ -42,9 +43,11 @@ func NewCronTool(
) (*CronTool, error) {
allowCommand := true
execEnabled := true
var commandAllowedRemotes []string
if config != nil {
allowCommand = config.Tools.Cron.AllowCommand
execEnabled = config.Tools.Exec.Enabled
commandAllowedRemotes = config.Tools.Cron.CommandAllowedRemotes
}
var execTool *ExecTool
@@ -60,12 +63,13 @@ func NewCronTool(
execTool.SetTimeout(execTimeout)
}
return &CronTool{
cronService: cronService,
executor: executor,
msgBus: msgBus,
execTool: execTool,
allowCommand: allowCommand,
execEnabled: execEnabled,
cronService: cronService,
executor: executor,
msgBus: msgBus,
execTool: execTool,
allowCommand: allowCommand,
execEnabled: execEnabled,
commandAllowedRemotes: commandAllowedRemotes,
}, nil
}
@@ -217,8 +221,10 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
if !t.execEnabled {
return ErrorResult("command execution is disabled")
}
if !constants.IsInternalChannel(channel) {
return ErrorResult("scheduling command execution is restricted to internal channels")
if !constants.IsInternalChannel(channel) && !isCommandAllowedRemote(channel, chatID, t.commandAllowedRemotes) {
return ErrorResult(
"scheduling command execution is restricted to internal channels or configured remote channels",
)
}
if !t.allowCommand && !commandConfirm {
return ErrorResult("command_confirm=true is required when allow_command is disabled")
@@ -483,11 +489,15 @@ func positiveSeconds(args map[string]any, key string) (int64, *ToolResult) {
}
func (t *CronTool) validateCommandMutation(ctx context.Context, args map[string]any) *ToolResult {
channel := ToolChannel(ctx)
chatID := ToolChatID(ctx)
if !t.execEnabled {
return ErrorResult("command execution is disabled")
}
if !constants.IsInternalChannel(ToolChannel(ctx)) {
return ErrorResult("updating command execution is restricted to internal channels")
if !constants.IsInternalChannel(channel) && !isCommandAllowedRemote(channel, chatID, t.commandAllowedRemotes) {
return ErrorResult(
"updating command execution is restricted to internal channels or configured remote channels",
)
}
commandConfirm, _ := args["command_confirm"].(bool)
if !t.allowCommand && !commandConfirm {
@@ -496,6 +506,29 @@ func (t *CronTool) validateCommandMutation(ctx context.Context, args map[string]
return nil
}
func isCommandAllowedRemote(channel, chatID string, allowed []string) bool {
if channel == "" {
return false
}
target := channel
if chatID != "" {
target = channel + ":" + chatID
}
for _, entry := range allowed {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if entry == "*" || entry == channel || entry == target {
return true
}
}
return false
}
func (t *CronTool) canAccessJob(ctx context.Context, job *cron.CronJob) bool {
channel := ToolChannel(ctx)
if constants.IsInternalChannel(channel) {
+143 -3
View File
@@ -87,7 +87,7 @@ func parseCronJobResult(t *testing.T, result *ToolResult) cron.CronJob {
return job
}
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted by default.
func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
@@ -102,8 +102,148 @@ func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
if !result.IsError {
t.Fatal("expected command scheduling to be blocked from remote channel")
}
if !strings.Contains(result.ForLLM, "restricted to internal channels") {
t.Errorf("expected 'restricted to internal channels', got: %s", result.ForLLM)
if !strings.Contains(result.ForLLM, "restricted to internal channels or configured remote channels") {
t.Errorf("expected remote restriction message, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedFromRemoteChannelAllowlist(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if result.IsError {
t.Fatalf("expected command scheduling from allowed remote channel to succeed, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedFromRemoteChatIDAllowlist(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{" telegram:1234567890 "}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "1234567890")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if result.IsError {
t.Fatalf("expected command scheduling from allowed remote chat to succeed, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedFromRemoteWildcardAllowlist(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if result.IsError {
t.Fatalf("expected wildcard allowlist to allow remote command scheduling, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedRemoteWildcardRequiresNonEmptyChannel(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected missing channel to remain blocked even with wildcard allowlist")
}
if !strings.Contains(result.ForLLM, "no session context") {
t.Errorf("expected session context error, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandBlockedFromDifferentRemoteChatID(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram:1234567890"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "other-chat")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"command_confirm": true,
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected command scheduling from non-allowlisted remote chat to fail")
}
if !strings.Contains(result.ForLLM, "restricted to internal channels or configured remote channels") {
t.Errorf("expected remote restriction message, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedRemoteRequiresConfirmWhenAllowCommandDisabled(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.AllowCommand = false
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected allowlisted remote command scheduling to require confirm when allow_command is disabled")
}
if !strings.Contains(result.ForLLM, "command_confirm=true") {
t.Errorf("expected command_confirm requirement message, got: %s", result.ForLLM)
}
}
func TestCronTool_AllowCommandDoesNotBypassRemoteAllowlist(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.AllowCommand = true
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected allow_command=true not to bypass remote allowlist")
}
if !strings.Contains(result.ForLLM, "restricted to internal channels or configured remote channels") {
t.Errorf("expected remote restriction message, got: %s", result.ForLLM)
}
}