diff --git a/cmd/gateway.go b/cmd/gateway.go index 4d5176b1..7a969dfa 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -127,11 +127,13 @@ func runGateway() { toolsReg.Register(tools.NewSandboxedReadFileTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr)) toolsReg.Register(tools.NewSandboxedWriteFileTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr)) toolsReg.Register(tools.NewSandboxedListFilesTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr)) + toolsReg.Register(tools.NewSandboxedEditTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr)) toolsReg.Register(tools.NewSandboxedExecTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr)) } else { toolsReg.Register(tools.NewReadFileTool(workspace, agentCfg.RestrictToWorkspace)) toolsReg.Register(tools.NewWriteFileTool(workspace, agentCfg.RestrictToWorkspace)) toolsReg.Register(tools.NewListFilesTool(workspace, agentCfg.RestrictToWorkspace)) + toolsReg.Register(tools.NewEditTool(workspace, agentCfg.RestrictToWorkspace)) toolsReg.Register(tools.NewExecTool(workspace, agentCfg.RestrictToWorkspace)) } @@ -239,20 +241,20 @@ func runGateway() { slog.Info("subagent system enabled", "tools", []string{"spawn", "subagent"}) } - // Exec approval system (matching TS exec-approval.ts) + // Exec approval system — always active (deny patterns + safe bins + configurable ask mode) var execApprovalMgr *tools.ExecApprovalManager - if eaCfg := cfg.Tools.ExecApproval; eaCfg.Security != "" || eaCfg.Ask != "" { - approvalCfg := tools.ExecApprovalConfig{ - Security: tools.ExecSecurity(eaCfg.Security), - Ask: tools.ExecAskMode(eaCfg.Ask), + { + approvalCfg := tools.DefaultExecApprovalConfig() + // Override from user config (backward compat: explicit values take precedence) + if eaCfg := cfg.Tools.ExecApproval; eaCfg.Security != "" { + approvalCfg.Security = tools.ExecSecurity(eaCfg.Security) } - if approvalCfg.Security == "" { - approvalCfg.Security = tools.ExecSecurityFull + if eaCfg := cfg.Tools.ExecApproval; eaCfg.Ask != "" { + approvalCfg.Ask = tools.ExecAskMode(eaCfg.Ask) } - if approvalCfg.Ask == "" { - approvalCfg.Ask = tools.ExecAskOff + if len(cfg.Tools.ExecApproval.Allowlist) > 0 { + approvalCfg.Allowlist = cfg.Tools.ExecApproval.Allowlist } - approvalCfg.Allowlist = eaCfg.Allowlist execApprovalMgr = tools.NewExecApprovalManager(approvalCfg) // Wire approval to exec tools in the registry @@ -261,7 +263,7 @@ func runGateway() { aa.SetApprovalManager(execApprovalMgr, "default") } } - slog.Info("exec approval enabled", "security", eaCfg.Security, "ask", eaCfg.Ask) + slog.Info("exec approval enabled", "security", string(approvalCfg.Security), "ask", string(approvalCfg.Ask)) } // --- Enforcement: Policy engines --- @@ -454,6 +456,20 @@ func runGateway() { } } + // Cron tool (agent-facing, matching TS cron-tool.ts) + toolsReg.Register(tools.NewCronTool(cronStore)) + slog.Info("cron tool registered") + + // Session tools (list, status, history, send) + toolsReg.Register(tools.NewSessionsListTool()) + toolsReg.Register(tools.NewSessionStatusTool()) + toolsReg.Register(tools.NewSessionsHistoryTool()) + toolsReg.Register(tools.NewSessionsSendTool()) + + // Message tool (send to channels) + toolsReg.Register(tools.NewMessageTool()) + slog.Info("session + message tools registered") + // Allow read_file to access skills directories (outside workspace). // Skills can live in ~/.goclaw/skills/, ~/.agents/skills/, etc. homeDir, _ := os.UserHomeDir() @@ -469,6 +485,24 @@ func runGateway() { // Memory detection hasMemory := memMgr != nil + // Wire SessionStoreAware + BusAware on tools that need them + for _, name := range []string{"sessions_list", "session_status", "sessions_history", "sessions_send"} { + if t, ok := toolsReg.Get(name); ok { + if sa, ok := t.(tools.SessionStoreAware); ok { + sa.SetSessionStore(sessStore) + } + if ba, ok := t.(tools.BusAware); ok { + ba.SetMessageBus(msgBus) + } + } + } + // Wire BusAware on message tool + if t, ok := toolsReg.Get("message"); ok { + if ba, ok := t.(tools.BusAware); ok { + ba.SetMessageBus(msgBus) + } + } + // Create all agents agentRouter := agent.NewRouter() @@ -564,6 +598,13 @@ func runGateway() { // Channel manager channelMgr := channels.NewManager(msgBus) + // Wire channel sender on message tool (now that channelMgr exists) + if t, ok := toolsReg.Get("message"); ok { + if cs, ok := t.(tools.ChannelSenderAware); ok { + cs.SetChannelSender(channelMgr.SendToChannel) + } + } + // Managed mode: load channel instances from DB first. var instanceLoader *channels.InstanceLoader var loadedNames map[string]struct{} diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index b600be05..40c78569 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -140,6 +140,16 @@ func wireManagedExtras( } } } + if editTool, ok := toolsReg.Get("edit"); ok { + if ia, ok := editTool.(tools.InterceptorAware); ok { + if contextFileInterceptor != nil { + ia.SetContextFileInterceptor(contextFileInterceptor) + } + if stores.Memory != nil { + ia.SetMemoryInterceptor(tools.NewMemoryInterceptor(stores.Memory, workspace)) + } + } + } // Wire memory store on memory tools (search + get) if stores.Memory != nil { diff --git a/cmd/onboard_auto.go b/cmd/onboard_auto.go index 15c69fe5..9d55bf18 100644 --- a/cmd/onboard_auto.go +++ b/cmd/onboard_auto.go @@ -210,6 +210,10 @@ func saveCleanConfig(cfgPath string, cfg *config.Config) error { "enabled": cfg.Tools.Browser.Enabled, "headless": cfg.Tools.Browser.Headless, }, + "execApproval": map[string]interface{}{ + "security": nonEmpty(cfg.Tools.ExecApproval.Security, "full"), + "ask": nonEmpty(cfg.Tools.ExecApproval.Ask, "off"), + }, } // Build agents section. diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index d3c307b7..11a1032c 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -58,8 +58,14 @@ var coreToolSummaries = map[string]string{ "web_fetch": "Fetch and extract content from a URL", "cron": "Manage scheduled jobs and reminders", "skill_search": "Search available skills by keyword (weather, translate, github, etc.)", - "browser": "Browse web pages interactively", - "tts": "Convert text to speech audio", + "browser": "Browse web pages interactively", + "tts": "Convert text to speech audio", + "edit": "Edit a file by replacing exact text matches", + "message": "Send a message to a channel (Telegram, Discord, etc.)", + "sessions_list": "List sessions for this agent", + "session_status": "Show session status (model, tokens, compaction count)", + "sessions_history": "Fetch message history for a session", + "sessions_send": "Send a message into another session", } // BuildSystemPrompt constructs the full system prompt with all sections. diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 86c7ce8e..9c263c39 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -51,6 +51,10 @@ func Default() *Config { Enabled: true, Headless: true, }, + ExecApproval: ExecApprovalCfg{ + Security: "full", + Ask: "off", + }, }, Sessions: SessionsConfig{ Storage: "~/.goclaw/sessions", diff --git a/internal/store/pg/cron.go b/internal/store/pg/cron.go index e4e717de..03c30c5b 100644 --- a/internal/store/pg/cron.go +++ b/internal/store/pg/cron.go @@ -171,6 +171,42 @@ func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store. if patch.DeleteAfterRun != nil { updates["delete_after_run"] = *patch.DeleteAfterRun } + + // Update agent_id column + if patch.AgentID != nil { + if *patch.AgentID == "" { + updates["agent_id"] = nil + } else if aid, parseErr := uuid.Parse(*patch.AgentID); parseErr == nil { + updates["agent_id"] = aid + } + } + + // Update payload JSONB — fetch current, merge patch fields, re-serialize + needsPayloadUpdate := patch.Message != "" || patch.Deliver != nil || patch.Channel != nil || patch.To != nil + if needsPayloadUpdate { + var payloadJSON []byte + if scanErr := s.db.QueryRow("SELECT payload FROM cron_jobs WHERE id = $1", id).Scan(&payloadJSON); scanErr == nil { + var payload store.CronPayload + json.Unmarshal(payloadJSON, &payload) + + if patch.Message != "" { + payload.Message = patch.Message + } + if patch.Deliver != nil { + payload.Deliver = *patch.Deliver + } + if patch.Channel != nil { + payload.Channel = *patch.Channel + } + if patch.To != nil { + payload.To = *patch.To + } + + merged, _ := json.Marshal(payload) + updates["payload"] = merged + } + } + updates["updated_at"] = time.Now() if err := execMapUpdate(context.Background(), s.db, "cron_jobs", id, updates); err != nil { diff --git a/internal/tools/cron.go b/internal/tools/cron.go new file mode 100644 index 00000000..6692b506 --- /dev/null +++ b/internal/tools/cron.go @@ -0,0 +1,320 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// CronTool lets agents manage Gateway cron jobs. +// Matching OpenClaw src/agents/tools/cron-tool.ts. +type CronTool struct { + cronStore store.CronStore +} + +func NewCronTool(cronStore store.CronStore) *CronTool { + return &CronTool{cronStore: cronStore} +} + +func (t *CronTool) Name() string { return "cron" } + +func (t *CronTool) Description() string { + return `Manage Gateway cron jobs (status/list/add/update/remove/run/runs). + +ACTIONS: +- status: Check cron scheduler status +- list: List jobs (use includeDisabled:true to include disabled) +- add: Create job (requires job object, see schema below) +- update: Modify job (requires jobId + patch object) +- remove: Delete job (requires jobId) +- run: Trigger job immediately (requires jobId) +- runs: Get job run history (requires jobId) + +JOB SCHEMA (for add action): +{ + "name": "string (required, lowercase slug)", + "schedule": { ... }, // Required: when to run + "message": "string", // Required: what message to send to the agent + "deliver": true|false, // Optional: deliver result to channel (default false) + "channel": "telegram", // Optional: target channel for delivery + "to": "chat-id", // Optional: target chat/recipient ID + "agentId": "agent-uuid", // Optional: which agent handles the job (default: current) + "deleteAfterRun": true // Optional: auto-delete after execution (default true for "at" schedule) +} + +SCHEDULE TYPES (schedule.kind): +- "at": One-shot at absolute time + { "kind": "at", "atMs": } +- "every": Recurring interval + { "kind": "every", "everyMs": } +- "cron": Cron expression + { "kind": "cron", "expr": "<5-field cron expression>", "tz": "" } + +CRITICAL CONSTRAINTS: +- name must be a valid slug (lowercase letters, numbers, hyphens only) +- message is required for add action +- schedule is required for add action +- Default: jobs run as isolated agent turns with the specified message + +Use jobId as the canonical identifier; id is accepted for compatibility.` +} + +func (t *CronTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "description": "The cron action to perform", + "enum": []string{"status", "list", "add", "update", "remove", "run", "runs"}, + }, + "includeDisabled": map[string]interface{}{ + "type": "boolean", + "description": "Include disabled jobs in list (default false)", + }, + "job": map[string]interface{}{ + "type": "object", + "description": "Job definition for add action (name, schedule, message, deliver, channel, to, agentId, deleteAfterRun)", + "additionalProperties": true, + }, + "jobId": map[string]interface{}{ + "type": "string", + "description": "Job ID for update/remove/run/runs actions", + }, + "id": map[string]interface{}{ + "type": "string", + "description": "Backward compatibility alias for jobId", + }, + "patch": map[string]interface{}{ + "type": "object", + "description": "Patch object for update action", + "additionalProperties": true, + }, + "runMode": map[string]interface{}{ + "type": "string", + "description": "Run mode: 'due' (only if due) or 'force' (immediate)", + "enum": []string{"due", "force"}, + }, + }, + "required": []string{"action"}, + } +} + +func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + action, _ := args["action"].(string) + if action == "" { + return ErrorResult("action parameter is required") + } + + switch action { + case "status": + return t.handleStatus() + case "list": + return t.handleList(args) + case "add": + return t.handleAdd(args) + case "update": + return t.handleUpdate(args) + case "remove": + return t.handleRemove(args) + case "run": + return t.handleRun(args) + case "runs": + return t.handleRuns(args) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s", action)) + } +} + +func (t *CronTool) handleStatus() *Result { + status := t.cronStore.Status() + data, _ := json.MarshalIndent(status, "", " ") + return NewResult(string(data)) +} + +func (t *CronTool) handleList(args map[string]interface{}) *Result { + includeDisabled, _ := args["includeDisabled"].(bool) + jobs := t.cronStore.ListJobs(includeDisabled) + + result := map[string]interface{}{ + "jobs": jobs, + "count": len(jobs), + } + data, _ := json.MarshalIndent(result, "", " ") + return NewResult(string(data)) +} + +func (t *CronTool) handleAdd(args map[string]interface{}) *Result { + jobObj, ok := args["job"].(map[string]interface{}) + if !ok { + return ErrorResult("job object is required for add action") + } + + name, _ := jobObj["name"].(string) + if name == "" { + return ErrorResult("job.name is required") + } + + scheduleObj, ok := jobObj["schedule"].(map[string]interface{}) + if !ok { + return ErrorResult("job.schedule is required") + } + + message, _ := jobObj["message"].(string) + if message == "" { + return ErrorResult("job.message is required") + } + + // Parse schedule + schedule := store.CronSchedule{ + Kind: stringFromMap(scheduleObj, "kind"), + } + if schedule.Kind == "" { + return ErrorResult("job.schedule.kind is required (at, every, or cron)") + } + + switch schedule.Kind { + case "at": + if v, ok := numberFromMap(scheduleObj, "atMs"); ok { + ms := int64(v) + schedule.AtMS = &ms + } else { + return ErrorResult("job.schedule.atMs is required for 'at' schedule") + } + case "every": + if v, ok := numberFromMap(scheduleObj, "everyMs"); ok { + ms := int64(v) + schedule.EveryMS = &ms + } else { + return ErrorResult("job.schedule.everyMs is required for 'every' schedule") + } + case "cron": + schedule.Expr = stringFromMap(scheduleObj, "expr") + if schedule.Expr == "" { + return ErrorResult("job.schedule.expr is required for 'cron' schedule") + } + schedule.TZ = stringFromMap(scheduleObj, "tz") + default: + return ErrorResult(fmt.Sprintf("invalid schedule kind: %s (must be at, every, or cron)", schedule.Kind)) + } + + // Optional fields + deliver, _ := jobObj["deliver"].(bool) + channel, _ := jobObj["channel"].(string) + to, _ := jobObj["to"].(string) + agentID, _ := jobObj["agentId"].(string) + + job, err := t.cronStore.AddJob(name, schedule, message, deliver, channel, to, agentID) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to create cron job: %v", err)) + } + + data, _ := json.MarshalIndent(map[string]interface{}{"job": job}, "", " ") + return NewResult(string(data)) +} + +func (t *CronTool) handleUpdate(args map[string]interface{}) *Result { + jobID := resolveJobID(args) + if jobID == "" { + return ErrorResult("jobId is required for update action") + } + + patchObj, ok := args["patch"].(map[string]interface{}) + if !ok { + return ErrorResult("patch object is required for update action") + } + + var patch store.CronJobPatch + // Re-marshal and unmarshal to leverage JSON tags + patchJSON, _ := json.Marshal(patchObj) + json.Unmarshal(patchJSON, &patch) + + job, err := t.cronStore.UpdateJob(jobID, patch) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to update cron job: %v", err)) + } + + data, _ := json.MarshalIndent(map[string]interface{}{"job": job}, "", " ") + return NewResult(string(data)) +} + +func (t *CronTool) handleRemove(args map[string]interface{}) *Result { + jobID := resolveJobID(args) + if jobID == "" { + return ErrorResult("jobId is required for remove action") + } + + if err := t.cronStore.RemoveJob(jobID); err != nil { + return ErrorResult(fmt.Sprintf("failed to remove cron job: %v", err)) + } + + data, _ := json.MarshalIndent(map[string]interface{}{"deleted": true, "jobId": jobID}, "", " ") + return NewResult(string(data)) +} + +func (t *CronTool) handleRun(args map[string]interface{}) *Result { + jobID := resolveJobID(args) + if jobID == "" { + return ErrorResult("jobId is required for run action") + } + + runMode, _ := args["runMode"].(string) + force := runMode == "force" + + ran, reason, err := t.cronStore.RunJob(jobID, force) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to run cron job: %v", err)) + } + + result := map[string]interface{}{ + "ran": ran, + "jobId": jobID, + } + if !ran && reason != "" { + result["reason"] = reason + } + data, _ := json.MarshalIndent(result, "", " ") + return NewResult(string(data)) +} + +func (t *CronTool) handleRuns(args map[string]interface{}) *Result { + jobID := resolveJobID(args) + + limit := 20 + if v, ok := numberFromMap(args, "limit"); ok { + limit = int(v) + } + + entries := t.cronStore.GetRunLog(jobID, limit) + + result := map[string]interface{}{ + "entries": entries, + "count": len(entries), + } + data, _ := json.MarshalIndent(result, "", " ") + return NewResult(string(data)) +} + +// --- helpers --- + +func resolveJobID(args map[string]interface{}) string { + if id, ok := args["jobId"].(string); ok && id != "" { + return id + } + if id, ok := args["id"].(string); ok && id != "" { + return id + } + return "" +} + +func stringFromMap(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func numberFromMap(m map[string]interface{}, key string) (float64, bool) { + v, ok := m[key].(float64) + return v, ok +} diff --git a/internal/tools/edit.go b/internal/tools/edit.go new file mode 100644 index 00000000..4c6499be --- /dev/null +++ b/internal/tools/edit.go @@ -0,0 +1,202 @@ +package tools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/sandbox" +) + +// EditTool performs search-and-replace edits on files. +// Supports context file interceptor (managed mode) and sandbox routing. +type EditTool struct { + workspace string + restrict bool + sandboxMgr sandbox.Manager + contextFileIntc *ContextFileInterceptor + memIntc *MemoryInterceptor +} + +func (t *EditTool) SetContextFileInterceptor(intc *ContextFileInterceptor) { + t.contextFileIntc = intc +} + +func (t *EditTool) SetMemoryInterceptor(intc *MemoryInterceptor) { + t.memIntc = intc +} + +func NewEditTool(workspace string, restrict bool) *EditTool { + return &EditTool{workspace: workspace, restrict: restrict} +} + +func NewSandboxedEditTool(workspace string, restrict bool, mgr sandbox.Manager) *EditTool { + return &EditTool{workspace: workspace, restrict: restrict, sandboxMgr: mgr} +} + +func (t *EditTool) SetSandboxKey(key string) {} + +func (t *EditTool) Name() string { return "edit" } +func (t *EditTool) Description() string { + return "Edit a file by replacing exact text matches. Use old_string/new_string for precise edits without rewriting the entire file." +} + +func (t *EditTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "Path to the file to edit", + }, + "old_string": map[string]interface{}{ + "type": "string", + "description": "Exact text to find (must match uniquely unless replace_all is true)", + }, + "new_string": map[string]interface{}{ + "type": "string", + "description": "Replacement text", + }, + "replace_all": map[string]interface{}{ + "type": "boolean", + "description": "Replace all occurrences (default: false, requires unique match)", + }, + }, + "required": []string{"path", "old_string", "new_string"}, + } +} + +func (t *EditTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + path, _ := args["path"].(string) + oldStr, _ := args["old_string"].(string) + newStr, _ := args["new_string"].(string) + replaceAll, _ := args["replace_all"].(bool) + + if path == "" { + return ErrorResult("path is required") + } + if oldStr == "" { + return ErrorResult("old_string is required") + } + if oldStr == newStr { + return ErrorResult("old_string and new_string are identical") + } + + // Virtual FS: context files (managed mode) + if t.contextFileIntc != nil { + if content, handled, err := t.contextFileIntc.ReadFile(ctx, path); handled { + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read context file: %v", err)) + } + if content == "" { + return ErrorResult(fmt.Sprintf("context file not found: %s", path)) + } + newContent, result := applyEdit(content, oldStr, newStr, replaceAll) + if result != nil { + return result + } + if _, err := t.contextFileIntc.WriteFile(ctx, path, newContent); err != nil { + return ErrorResult(fmt.Sprintf("failed to write context file: %v", err)) + } + return SilentResult(fmt.Sprintf("Context file edited: %s", path)) + } + } + + // Virtual FS: memory files (managed mode) + if t.memIntc != nil { + if content, handled, err := t.memIntc.ReadFile(ctx, path); handled { + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read memory file: %v", err)) + } + if content == "" { + return ErrorResult(fmt.Sprintf("memory file not found: %s", path)) + } + newContent, result := applyEdit(content, oldStr, newStr, replaceAll) + if result != nil { + return result + } + if _, err := t.memIntc.WriteFile(ctx, path, newContent); err != nil { + return ErrorResult(fmt.Sprintf("failed to write memory file: %v", err)) + } + return SilentResult(fmt.Sprintf("Memory file edited: %s", path)) + } + } + + // Sandbox routing + sandboxKey := ToolSandboxKeyFromCtx(ctx) + if t.sandboxMgr != nil && sandboxKey != "" { + return t.executeInSandbox(ctx, path, oldStr, newStr, replaceAll, sandboxKey) + } + + // Host execution + resolved, err := resolvePath(path, t.workspace, t.restrict) + if err != nil { + return ErrorResult(err.Error()) + } + + data, err := os.ReadFile(resolved) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read file: %v", err)) + } + + content := string(data) + newContent, result := applyEdit(content, oldStr, newStr, replaceAll) + if result != nil { + return result + } + + if err := os.MkdirAll(filepath.Dir(resolved), 0755); err != nil { + return ErrorResult(fmt.Sprintf("failed to create directory: %v", err)) + } + + if err := os.WriteFile(resolved, []byte(newContent), 0644); err != nil { + return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) + } + + count := strings.Count(content, oldStr) + return SilentResult(fmt.Sprintf("File edited: %s (%d replacement(s))", path, count)) +} + +func (t *EditTool) executeInSandbox(ctx context.Context, path, oldStr, newStr string, replaceAll bool, sandboxKey string) *Result { + sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace) + if err != nil { + return ErrorResult(fmt.Sprintf("sandbox error: %v", err)) + } + + bridge := sandbox.NewFsBridge(sb.ID(), "/workspace") + content, err := bridge.ReadFile(ctx, path) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read file: %v", err)) + } + + newContent, result := applyEdit(content, oldStr, newStr, replaceAll) + if result != nil { + return result + } + + if err := bridge.WriteFile(ctx, path, newContent); err != nil { + return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) + } + + count := strings.Count(content, oldStr) + return SilentResult(fmt.Sprintf("File edited: %s (%d replacement(s))", path, count)) +} + +// applyEdit performs the search-and-replace. Returns (newContent, nil) on success +// or ("", errorResult) on failure. +func applyEdit(content, oldStr, newStr string, replaceAll bool) (string, *Result) { + count := strings.Count(content, oldStr) + if count == 0 { + return "", ErrorResult("old_string not found in file") + } + if !replaceAll && count > 1 { + return "", ErrorResult(fmt.Sprintf("old_string found %d times — use replace_all=true or provide a more specific match", count)) + } + + if replaceAll { + return strings.ReplaceAll(content, oldStr, newStr), nil + } + return strings.Replace(content, oldStr, newStr, 1), nil +} diff --git a/internal/tools/exec_approval.go b/internal/tools/exec_approval.go index 9dc0c388..ead52043 100644 --- a/internal/tools/exec_approval.go +++ b/internal/tools/exec_approval.go @@ -53,23 +53,27 @@ func DefaultExecApprovalConfig() ExecApprovalConfig { } // safeBins are command names that are always considered safe. -// Matching TS exec-approval.ts safeBins list. +// Only includes read-only, text processing, and dev tools. +// Infrastructure/network tools (docker, kubectl, terraform, ansible, +// curl, wget, ssh, scp, rsync) are excluded — they require approval +// when ask mode is "on-miss". var safeBins = map[string]bool{ + // Read-only / info tools "cat": true, "echo": true, "ls": true, "pwd": true, "head": true, "tail": true, "wc": true, "sort": true, "uniq": true, "grep": true, "find": true, "which": true, "whoami": true, "date": true, "env": true, "printenv": true, "uname": true, "id": true, "hostname": true, "df": true, "du": true, "free": true, "uptime": true, "file": true, "stat": true, "dirname": true, "basename": true, "realpath": true, + // Text processing "jq": true, "yq": true, "sed": true, "awk": true, "tr": true, "cut": true, "diff": true, "patch": true, "tee": true, "xargs": true, + // Dev tools (core purpose of a coding agent) "git": true, "node": true, "npm": true, "npx": true, "yarn": true, "pnpm": true, "bun": true, "deno": true, "python": true, "python3": true, "pip": true, "pip3": true, "go": true, "cargo": true, "rustc": true, "make": true, "cmake": true, "gcc": true, "g++": true, "clang": true, "java": true, "javac": true, "mvn": true, "gradle": true, - "docker": true, "kubectl": true, "terraform": true, "ansible": true, - "curl": true, "wget": true, "ssh": true, "scp": true, "rsync": true, } // ApprovalDecision is the user's response to an approval request. diff --git a/internal/tools/message.go b/internal/tools/message.go new file mode 100644 index 00000000..58e6648c --- /dev/null +++ b/internal/tools/message.go @@ -0,0 +1,98 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +// MessageTool allows the agent to proactively send messages to channels. +type MessageTool struct { + sender ChannelSender + msgBus *bus.MessageBus +} + +func NewMessageTool() *MessageTool { return &MessageTool{} } + +func (t *MessageTool) SetChannelSender(s ChannelSender) { t.sender = s } +func (t *MessageTool) SetMessageBus(b *bus.MessageBus) { t.msgBus = b } + +func (t *MessageTool) Name() string { return "message" } +func (t *MessageTool) Description() string { + return "Send a message to a channel (Telegram, Discord, etc.) or the current chat." +} + +func (t *MessageTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "description": "Action to perform: 'send'", + "enum": []string{"send"}, + }, + "channel": map[string]interface{}{ + "type": "string", + "description": "Channel name (default: current channel from context)", + }, + "target": map[string]interface{}{ + "type": "string", + "description": "Chat ID to send to (default: current chat from context)", + }, + "message": map[string]interface{}{ + "type": "string", + "description": "Message content to send", + }, + }, + "required": []string{"action", "message"}, + } +} + +func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + action, _ := args["action"].(string) + if action != "send" { + return ErrorResult(fmt.Sprintf("unsupported action: %s (only 'send' is supported)", action)) + } + + message, _ := args["message"].(string) + if message == "" { + return ErrorResult("message is required") + } + + channel, _ := args["channel"].(string) + if channel == "" { + channel = ToolChannelFromCtx(ctx) + } + if channel == "" { + return ErrorResult("channel is required (no current channel in context)") + } + + target, _ := args["target"].(string) + if target == "" { + target = ToolChatIDFromCtx(ctx) + } + if target == "" { + return ErrorResult("target chat ID is required (no current chat in context)") + } + + // Prefer direct channel sender (channels.Manager.SendToChannel) + if t.sender != nil { + if err := t.sender(ctx, channel, target, message); err != nil { + return ErrorResult(fmt.Sprintf("failed to send message: %v", err)) + } + return SilentResult(fmt.Sprintf(`{"status":"sent","channel":"%s","target":"%s"}`, channel, target)) + } + + // Fallback: publish via message bus outbound queue + if t.msgBus != nil { + t.msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: channel, + ChatID: target, + Content: message, + }) + return SilentResult(fmt.Sprintf(`{"status":"queued","channel":"%s","target":"%s"}`, channel, target)) + } + + return ErrorResult("no channel sender or message bus available") +} diff --git a/internal/tools/policy.go b/internal/tools/policy.go index 9eb2cf06..2753ade9 100644 --- a/internal/tools/policy.go +++ b/internal/tools/policy.go @@ -62,6 +62,7 @@ var toolAliases = map[string]string{ // Subagent deny lists — tools subagents cannot use. var subagentDenyList = []string{ + "exec", // subagents should not shell out — main agent can still exec "gateway", "agents_list", "whatsapp_login", "session_status", "cron", "memory_search", "memory_get", "sessions_send", } diff --git a/internal/tools/sessions.go b/internal/tools/sessions.go new file mode 100644 index 00000000..1458c5c6 --- /dev/null +++ b/internal/tools/sessions.go @@ -0,0 +1,402 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ============================================================ +// sessions_list +// ============================================================ + +type SessionsListTool struct { + sessions store.SessionStore +} + +func NewSessionsListTool() *SessionsListTool { return &SessionsListTool{} } + +func (t *SessionsListTool) SetSessionStore(s store.SessionStore) { t.sessions = s } + +func (t *SessionsListTool) Name() string { return "sessions_list" } +func (t *SessionsListTool) Description() string { + return "List sessions for this agent with optional filters." +} + +func (t *SessionsListTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "limit": map[string]interface{}{ + "type": "number", + "description": "Max sessions to return (default 20)", + }, + "active_minutes": map[string]interface{}{ + "type": "number", + "description": "Only show sessions active in the last N minutes", + }, + }, + } +} + +func (t *SessionsListTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + if t.sessions == nil { + return ErrorResult("session store not available") + } + + limit := 20 + if v, ok := args["limit"].(float64); ok && int(v) > 0 { + limit = int(v) + } + + var activeMinutes int + if v, ok := args["active_minutes"].(float64); ok && int(v) > 0 { + activeMinutes = int(v) + } + + agentID := resolveAgentIDString(ctx) + sessions := t.sessions.List(agentID) + + // Filter by active_minutes + if activeMinutes > 0 { + cutoff := time.Now().Add(-time.Duration(activeMinutes) * time.Minute) + var filtered []store.SessionInfo + for _, s := range sessions { + if s.Updated.After(cutoff) { + filtered = append(filtered, s) + } + } + sessions = filtered + } + + // Apply limit + if len(sessions) > limit { + sessions = sessions[:limit] + } + + type sessionEntry struct { + Key string `json:"key"` + MessageCount int `json:"message_count"` + Updated string `json:"updated"` + } + + entries := make([]sessionEntry, 0, len(sessions)) + for _, s := range sessions { + entries = append(entries, sessionEntry{ + Key: s.Key, + MessageCount: s.MessageCount, + Updated: s.Updated.Format(time.RFC3339), + }) + } + + out, _ := json.Marshal(map[string]interface{}{ + "count": len(entries), + "sessions": entries, + }) + return SilentResult(string(out)) +} + +// ============================================================ +// session_status +// ============================================================ + +type SessionStatusTool struct { + sessions store.SessionStore +} + +func NewSessionStatusTool() *SessionStatusTool { return &SessionStatusTool{} } + +func (t *SessionStatusTool) SetSessionStore(s store.SessionStore) { t.sessions = s } + +func (t *SessionStatusTool) Name() string { return "session_status" } +func (t *SessionStatusTool) Description() string { + return "Show session status: model, tokens, compaction count, channel, last update." +} + +func (t *SessionStatusTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "session_key": map[string]interface{}{ + "type": "string", + "description": "Session key to inspect (default: current session)", + }, + }, + } +} + +func (t *SessionStatusTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + if t.sessions == nil { + return ErrorResult("session store not available") + } + + sessionKey, _ := args["session_key"].(string) + if sessionKey == "" { + sessionKey = ToolSandboxKeyFromCtx(ctx) // sandboxKey == sessionKey in registry + } + if sessionKey == "" { + return ErrorResult("session_key is required (could not detect current session)") + } + + // Security: validate session belongs to current agent + agentID := resolveAgentIDString(ctx) + if agentID != "" && !strings.HasPrefix(sessionKey, "agent:"+agentID+":") { + return ErrorResult("access denied: session belongs to a different agent") + } + + data := t.sessions.GetOrCreate(sessionKey) + + var lines []string + lines = append(lines, fmt.Sprintf("Session: %s", data.Key)) + if data.Model != "" { + lines = append(lines, fmt.Sprintf("Model: %s", data.Model)) + } + if data.Provider != "" { + lines = append(lines, fmt.Sprintf("Provider: %s", data.Provider)) + } + if data.Channel != "" { + lines = append(lines, fmt.Sprintf("Channel: %s", data.Channel)) + } + lines = append(lines, fmt.Sprintf("Messages: %d", len(data.Messages))) + lines = append(lines, fmt.Sprintf("Tokens: %d input / %d output", data.InputTokens, data.OutputTokens)) + lines = append(lines, fmt.Sprintf("Compactions: %d", data.CompactionCount)) + if data.Summary != "" { + lines = append(lines, fmt.Sprintf("Has summary: yes (%d chars)", len(data.Summary))) + } + if data.Label != "" { + lines = append(lines, fmt.Sprintf("Label: %s", data.Label)) + } + lines = append(lines, fmt.Sprintf("Updated: %s", data.Updated.Format(time.RFC3339))) + + return SilentResult(strings.Join(lines, "\n")) +} + +// ============================================================ +// sessions_history +// ============================================================ + +const ( + historyMaxCharsPerMessage = 4000 + historyMaxTotalBytes = 80 * 1024 +) + +type SessionsHistoryTool struct { + sessions store.SessionStore +} + +func NewSessionsHistoryTool() *SessionsHistoryTool { return &SessionsHistoryTool{} } + +func (t *SessionsHistoryTool) SetSessionStore(s store.SessionStore) { t.sessions = s } + +func (t *SessionsHistoryTool) Name() string { return "sessions_history" } +func (t *SessionsHistoryTool) Description() string { + return "Fetch message history for a session." +} + +func (t *SessionsHistoryTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "session_key": map[string]interface{}{ + "type": "string", + "description": "Session key to fetch history from", + }, + "limit": map[string]interface{}{ + "type": "number", + "description": "Max messages to return (default 20)", + }, + "include_tools": map[string]interface{}{ + "type": "boolean", + "description": "Include tool call/result messages (default false)", + }, + }, + "required": []string{"session_key"}, + } +} + +func (t *SessionsHistoryTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + if t.sessions == nil { + return ErrorResult("session store not available") + } + + sessionKey, _ := args["session_key"].(string) + if sessionKey == "" { + return ErrorResult("session_key is required") + } + + limit := 20 + if v, ok := args["limit"].(float64); ok && int(v) > 0 { + limit = int(v) + } + + includeTools, _ := args["include_tools"].(bool) + + // Security: validate session belongs to current agent + agentID := resolveAgentIDString(ctx) + if agentID != "" && !strings.HasPrefix(sessionKey, "agent:"+agentID+":") { + return ErrorResult("access denied: session belongs to a different agent") + } + + history := t.sessions.GetHistory(sessionKey) + if history == nil { + return SilentResult(`{"session_key":"` + sessionKey + `","messages":[],"count":0}`) + } + + // Filter tool messages + type msgEntry struct { + Role string `json:"role"` + Content string `json:"content"` + } + var entries []msgEntry + for _, m := range history { + if !includeTools && m.Role == "tool" { + continue + } + // Skip assistant messages that are only tool calls with no text + if !includeTools && m.Role == "assistant" && len(m.ToolCalls) > 0 && strings.TrimSpace(m.Content) == "" { + continue + } + + content := m.Content + // Truncate per-message + if utf8.RuneCountInString(content) > historyMaxCharsPerMessage { + runes := []rune(content) + content = string(runes[:historyMaxCharsPerMessage]) + "... [truncated]" + } + + entries = append(entries, msgEntry{Role: m.Role, Content: content}) + } + + // Keep last N + if len(entries) > limit { + entries = entries[len(entries)-limit:] + } + + out, _ := json.Marshal(map[string]interface{}{ + "session_key": sessionKey, + "messages": entries, + "count": len(entries), + }) + + // Cap total bytes + if len(out) > historyMaxTotalBytes { + return SilentResult(fmt.Sprintf( + `{"session_key":"%s","error":"history too large (%d bytes), use smaller limit","count":%d}`, + sessionKey, len(out), len(entries), + )) + } + + return SilentResult(string(out)) +} + +// ============================================================ +// sessions_send +// ============================================================ + +type SessionsSendTool struct { + sessions store.SessionStore + msgBus *bus.MessageBus +} + +func NewSessionsSendTool() *SessionsSendTool { return &SessionsSendTool{} } + +func (t *SessionsSendTool) SetSessionStore(s store.SessionStore) { t.sessions = s } +func (t *SessionsSendTool) SetMessageBus(b *bus.MessageBus) { t.msgBus = b } + +func (t *SessionsSendTool) Name() string { return "sessions_send" } +func (t *SessionsSendTool) Description() string { + return "Send a message into another session. Use session_key or label to identify the target." +} + +func (t *SessionsSendTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "session_key": map[string]interface{}{ + "type": "string", + "description": "Target session key", + }, + "label": map[string]interface{}{ + "type": "string", + "description": "Target session label (alternative to session_key)", + }, + "message": map[string]interface{}{ + "type": "string", + "description": "Message to send", + }, + }, + "required": []string{"message"}, + } +} + +func (t *SessionsSendTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + if t.sessions == nil { + return ErrorResult("session store not available") + } + if t.msgBus == nil { + return ErrorResult("message bus not available") + } + + sessionKey, _ := args["session_key"].(string) + label, _ := args["label"].(string) + message, _ := args["message"].(string) + + if message == "" { + return ErrorResult("message is required") + } + if sessionKey == "" && label == "" { + return ErrorResult("either session_key or label is required") + } + + agentID := resolveAgentIDString(ctx) + + // Resolve by label if needed + if sessionKey == "" && label != "" { + sessions := t.sessions.List(agentID) + for _, s := range sessions { + // Check if label matches by loading session data + data := t.sessions.GetOrCreate(s.Key) + if data.Label == label { + sessionKey = s.Key + break + } + } + if sessionKey == "" { + return ErrorResult(fmt.Sprintf("no session found with label: %s", label)) + } + } + + // Security: validate target session belongs to same agent + if agentID != "" && !strings.HasPrefix(sessionKey, "agent:"+agentID+":") { + return ErrorResult("access denied: target session belongs to a different agent") + } + + // Publish as an inbound message (same mechanism as channels) + t.msgBus.PublishInbound(bus.InboundMessage{ + Channel: "system", + SenderID: "session_send_tool", + ChatID: sessionKey, + Content: message, + PeerKind: "direct", + }) + + return SilentResult(fmt.Sprintf(`{"status":"accepted","session_key":"%s"}`, sessionKey)) +} + +// ============================================================ +// helpers +// ============================================================ + +func resolveAgentIDString(ctx context.Context) string { + id := store.AgentIDFromContext(ctx) + if id.String() == "00000000-0000-0000-0000-000000000000" { + return "" // standalone mode + } + return id.String() +} diff --git a/internal/tools/shell.go b/internal/tools/shell.go index 6dfb8c57..3c33880b 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -33,6 +33,25 @@ var defaultDenyPatterns = []*regexp.Regexp{ // Dangerous eval from untrusted sources regexp.MustCompile(`\beval\s*\$`), // eval $(...) regexp.MustCompile(`\bbase64\s+-d\b.*\|\s*(ba)?sh\b`), // base64 -d | sh + + // Bypass-resistant patterns (long flags) + regexp.MustCompile(`\brm\s+.*--recursive`), // rm --recursive (bypasses rm -rf check) + regexp.MustCompile(`\brm\s+.*--force`), // rm --force + + // Privilege escalation + regexp.MustCompile(`\bsudo\b`), // sudo + regexp.MustCompile(`\bsu\s+-`), // su - (switch user) + regexp.MustCompile(`\bnsenter\b`), // namespace escape + regexp.MustCompile(`\bunshare\b`), // namespace manipulation + + // Dangerous path operations on root filesystem + regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\s+/`), // chmod 777 /... + regexp.MustCompile(`\bchown\b.*\s+/`), // chown ... /... + + // Environment variable injection + regexp.MustCompile(`\bLD_PRELOAD\s*=`), // Linux library injection + regexp.MustCompile(`\bDYLD_INSERT_LIBRARIES\s*=`), // macOS library injection + regexp.MustCompile(`\bLD_LIBRARY_PATH\s*=`), // library path hijack } // ExecTool executes shell commands, optionally inside a sandbox container. diff --git a/internal/tools/types.go b/internal/tools/types.go index 5ab3bae0..2a3490e7 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -3,6 +3,7 @@ package tools import ( "context" + "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -64,6 +65,25 @@ type PathAllowable interface { AllowPaths(...string) } +// SessionStoreAware tools can receive a SessionStore for session queries. +type SessionStoreAware interface { + SetSessionStore(store.SessionStore) +} + +// BusAware tools can receive a MessageBus for publishing messages. +type BusAware interface { + SetMessageBus(*bus.MessageBus) +} + +// ChannelSender abstracts sending a message to a channel. +// Implemented by channels.Manager.SendToChannel. +type ChannelSender func(ctx context.Context, channel, chatID, content string) error + +// ChannelSenderAware tools can receive a channel sender function. +type ChannelSenderAware interface { + SetChannelSender(ChannelSender) +} + // ToProviderDef converts a Tool to a providers.ToolDefinition for LLM APIs. func ToProviderDef(t Tool) providers.ToolDefinition { return providers.ToolDefinition{ diff --git a/ui/web/src/pages/cron/cron-page.tsx b/ui/web/src/pages/cron/cron-page.tsx index cd854bf3..50d05f6e 100644 --- a/ui/web/src/pages/cron/cron-page.tsx +++ b/ui/web/src/pages/cron/cron-page.tsx @@ -104,7 +104,7 @@ export function CronPage() { {formatSchedule(job)} - {job.message} + {job.payload?.message} {job.agentId ? ( diff --git a/ui/web/src/pages/cron/hooks/use-cron.ts b/ui/web/src/pages/cron/hooks/use-cron.ts index 57cb789f..d961ba73 100644 --- a/ui/web/src/pages/cron/hooks/use-cron.ts +++ b/ui/web/src/pages/cron/hooks/use-cron.ts @@ -10,23 +10,29 @@ export interface CronSchedule { tz?: string; } +export interface CronPayload { + kind: string; + message: string; + deliver: boolean; + channel: string; + to: string; +} + export interface CronJob { id: string; name: string; agentId?: string; enabled: boolean; schedule: CronSchedule; - message: string; - deliver: boolean; - channel: string; - to: string; + payload: CronPayload; createdAtMs: number; updatedAtMs: number; deleteAfterRun?: boolean; state?: { - lastRunMs?: number; - nextRunMs?: number; - runCount?: number; + nextRunAtMs?: number; + lastRunAtMs?: number; + lastStatus?: string; + lastError?: string; }; }