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
This commit is contained in:
Viet Tran committed 2026-02-22 18:34:05 +07:00
1 parent f3f4c67b36
commit 172216e73d
16 files changed
+1197 -24

No files matched your search

+52 -11
View File
@@ -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{}
+10
View File
@@ -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 {
+4
View File
@@ -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.
+8 -2
View File
@@ -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.
+4
View File
@@ -51,6 +51,10 @@ func Default() *Config {
Enabled: true,
Headless: true,
},
ExecApproval: ExecApprovalCfg{
Security: "full",
Ask: "off",
},
},
Sessions: SessionsConfig{
Storage: "~/.goclaw/sessions",
+36
View File
@@ -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 {
+320
View File
@@ -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": <unix-milliseconds> }
- "every": Recurring interval
{ "kind": "every", "everyMs": <interval-ms> }
- "cron": Cron expression
{ "kind": "cron", "expr": "<5-field cron expression>", "tz": "<optional-timezone>" }
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
}
+202
View File
@@ -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
}
+7 -3
View File
@@ -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.
+98
View File
@@ -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")
}
+1
View File
@@ -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",
}
+402
View File
@@ -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()
}
+19
View File
@@ -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.
+20
View File
@@ -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{
+1 -1
View File
@@ -104,7 +104,7 @@ export function CronPage() {
<Badge variant="outline">{formatSchedule(job)}</Badge>
</td>
<td className="max-w-[200px] truncate px-4 py-3 text-muted-foreground">
{job.message}
{job.payload?.message}
</td>
<td className="px-4 py-3">
{job.agentId ? (
+13 -7
View File
@@ -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;
};
}