mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-07-28 01:27:58 +00:00
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:
@@ -380,7 +380,9 @@
|
||||
},
|
||||
"cron": {
|
||||
"enabled": true,
|
||||
"exec_timeout_minutes": 5
|
||||
"exec_timeout_minutes": 5,
|
||||
"allow_command": true,
|
||||
"command_allowed_remotes": []
|
||||
},
|
||||
"mcp": {
|
||||
"enabled": false,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user