From 172216e73dfd0ec49002e45ffe799502e9faa4c1 Mon Sep 17 00:00:00 2001 From: Viet Tran Date: Sun, 22 Feb 2026 18:34:05 +0700 Subject: [PATCH] Add edit tool, cron tool, session tools, and message tool with full wiring and exec approval hardening Register edit tool (sandboxed + non-sandboxed variants) with context file and memory interceptors in managed mode. Add cron tool for agent-facing job management. Register session tools (list, status, history, send) and message tool with proper SessionStoreAware, BusAware, and ChannelSenderAware wiring. Harden exec approval system to always be active with deny patterns + safe bins, defaulting to full --- cmd/gateway.go | 63 +++- cmd/gateway_managed.go | 10 + cmd/onboard_auto.go | 4 + internal/agent/systemprompt.go | 10 +- internal/config/config_load.go | 4 + internal/store/pg/cron.go | 36 +++ internal/tools/cron.go | 320 +++++++++++++++++++ internal/tools/edit.go | 202 ++++++++++++ internal/tools/exec_approval.go | 10 +- internal/tools/message.go | 98 ++++++ internal/tools/policy.go | 1 + internal/tools/sessions.go | 402 ++++++++++++++++++++++++ internal/tools/shell.go | 19 ++ internal/tools/types.go | 20 ++ ui/web/src/pages/cron/cron-page.tsx | 2 +- ui/web/src/pages/cron/hooks/use-cron.ts | 20 +- 16 files changed, 1197 insertions(+), 24 deletions(-) create mode 100644 internal/tools/cron.go create mode 100644 internal/tools/edit.go create mode 100644 internal/tools/message.go create mode 100644 internal/tools/sessions.go 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; }; }