mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 20:15:42 +00:00
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:
1 parent
f3f4c67b36
commit
172216e73d
16 files changed
+1197
-24
No files matched your search
+52
-11
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -51,6 +51,10 @@ func Default() *Config {
|
||||
Enabled: true,
|
||||
Headless: true,
|
||||
},
|
||||
ExecApproval: ExecApprovalCfg{
|
||||
Security: "full",
|
||||
Ask: "off",
|
||||
},
|
||||
},
|
||||
Sessions: SessionsConfig{
|
||||
Storage: "~/.goclaw/sessions",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user