diff --git a/pkg/agent/agent_message.go b/pkg/agent/agent_message.go index 96b0b08171..4acbd33a21 100644 --- a/pkg/agent/agent_message.go +++ b/pkg/agent/agent_message.go @@ -227,6 +227,13 @@ func (al *AgentLoop) allocateRouteSession(route routing.ResolvedRoute, msg bus.I }) } +func originTopicID(origin *bus.InboundContext) string { + if origin == nil { + return "" + } + return strings.TrimSpace(origin.TopicID) +} + func (al *AgentLoop) processSystemMessage( ctx context.Context, msg bus.InboundMessage, diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go index 0f71c74329..cd18cd33d5 100644 --- a/pkg/agent/pipeline_execute.go +++ b/pkg/agent/pipeline_execute.go @@ -430,6 +430,7 @@ toolLoop: ts.opts.Dispatch.MessageID(), ts.opts.Dispatch.ReplyToMessageID(), ) + execCtx = tools.WithToolTopicID(execCtx, originTopicID(ts.opts.Dispatch.InboundContext)) execCtx = tools.WithToolSessionContext( execCtx, ts.agent.ID, diff --git a/pkg/tools/shared/base.go b/pkg/tools/shared/base.go index 298e1b478a..c50d17d876 100644 --- a/pkg/tools/shared/base.go +++ b/pkg/tools/shared/base.go @@ -45,6 +45,7 @@ type toolCtxKey struct{ name string } var ( ctxKeyChannel = &toolCtxKey{"channel"} ctxKeyChatID = &toolCtxKey{"chatID"} + ctxKeyTopicID = &toolCtxKey{"topicID"} ctxKeyMessageID = &toolCtxKey{"messageID"} ctxKeyReplyToMessageID = &toolCtxKey{"replyToMessageID"} ctxKeyAgentID = &toolCtxKey{"agentID"} @@ -59,6 +60,12 @@ func WithToolContext(ctx context.Context, channel, chatID string) context.Contex return ctx } +// WithToolTopicID returns a child context carrying the inbound topic/thread id. +func WithToolTopicID(ctx context.Context, topicID string) context.Context { + ctx = context.WithValue(ctx, ctxKeyTopicID, topicID) + return ctx +} + // WithToolMessageContext returns a child context carrying inbound message IDs. func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context { ctx = context.WithValue(ctx, ctxKeyMessageID, messageID) @@ -100,6 +107,12 @@ func ToolChatID(ctx context.Context) string { return v } +// ToolTopicID extracts the inbound topic/thread id from ctx, or "" if unset. +func ToolTopicID(ctx context.Context) string { + v, _ := ctx.Value(ctxKeyTopicID).(string) + return v +} + // ToolMessageID extracts the current inbound message ID from ctx, or "" if unset. func ToolMessageID(ctx context.Context) string { v, _ := ctx.Value(ctxKeyMessageID).(string) diff --git a/pkg/tools/shared_facade.go b/pkg/tools/shared_facade.go index 8409ea0605..08ca7a89a7 100644 --- a/pkg/tools/shared_facade.go +++ b/pkg/tools/shared_facade.go @@ -42,6 +42,10 @@ func WithToolContext(ctx context.Context, channel, chatID string) context.Contex return toolshared.WithToolContext(ctx, channel, chatID) } +func WithToolTopicID(ctx context.Context, topicID string) context.Context { + return toolshared.WithToolTopicID(ctx, topicID) +} + func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context { return toolshared.WithToolMessageContext(ctx, messageID, replyToMessageID) } @@ -69,6 +73,10 @@ func ToolChatID(ctx context.Context) string { return toolshared.ToolChatID(ctx) } +func ToolTopicID(ctx context.Context) string { + return toolshared.ToolTopicID(ctx) +} + func ToolMessageID(ctx context.Context) string { return toolshared.ToolMessageID(ctx) } diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index a570ac9ecf..86bade6c8e 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -372,6 +372,13 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult if cwd != "" { cmd.Dir = cwd } + cmd.Env = append(os.Environ(), + "PICOCLAW_TOOL_CHANNEL="+ToolChannel(ctx), + "PICOCLAW_TOOL_CHAT_ID="+ToolChatID(ctx), + "PICOCLAW_TOOL_TOPIC_ID="+ToolTopicID(ctx), + "PICOCLAW_TOOL_MESSAGE_ID="+ToolMessageID(ctx), + "PICOCLAW_TOOL_REPLY_TO_MESSAGE_ID="+ToolReplyToMessageID(ctx), + ) prepareCommandForTermination(cmd) @@ -484,6 +491,13 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn if cwd != "" { cmd.Dir = cwd } + cmd.Env = append(os.Environ(), + "PICOCLAW_TOOL_CHANNEL="+ToolChannel(ctx), + "PICOCLAW_TOOL_CHAT_ID="+ToolChatID(ctx), + "PICOCLAW_TOOL_TOPIC_ID="+ToolTopicID(ctx), + "PICOCLAW_TOOL_MESSAGE_ID="+ToolMessageID(ctx), + "PICOCLAW_TOOL_REPLY_TO_MESSAGE_ID="+ToolReplyToMessageID(ctx), + ) prepareCommandForTermination(cmd) @@ -1073,6 +1087,19 @@ func (t *ExecTool) guardCommand(command, cwd string) string { for _, loc := range matchIndices { raw := cmd[loc[0]:loc[1]] + // Skip slash-containing relative command segments like + // "scripts/foo.sh". The absolute-path regex matches the "/foo.sh" + // substring inside that token, but this is not an absolute path. + if strings.HasPrefix(raw, "/") && loc[0] > 0 { + prev := cmd[loc[0]-1] + if (prev >= 'a' && prev <= 'z') || + (prev >= 'A' && prev <= 'Z') || + (prev >= '0' && prev <= '9') || + prev == '_' || prev == '.' || prev == '-' { + continue + } + } + // Skip URL path components that look like they're from web URLs. // When a URL like "https://github.com" is parsed, the regex captures // "//github.com" as a match (the path portion after "https:"). diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index a8de2f4c9c..ea3129f309 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -703,6 +703,26 @@ func TestShellTool_URLBypassPrevented(t *testing.T) { } } +func TestShellTool_RelativeScriptPathNotMisclassifiedAsAbsolute(t *testing.T) { + tmpDir := t.TempDir() + scriptsDir := filepath.Join(tmpDir, "scripts") + require.NoError(t, os.MkdirAll(scriptsDir, 0o755)) + scriptPath := filepath.Join(scriptsDir, "echo.sh") + require.NoError(t, os.WriteFile(scriptPath, []byte("#!/bin/sh\necho ok\n"), 0o755)) + + tool, err := NewExecTool(tmpDir, true) + require.NoError(t, err) + + result := tool.Execute(context.Background(), map[string]any{ + "action": "run", + "command": "scripts/echo.sh", + "cwd": tmpDir, + }) + if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { + t.Fatalf("relative script path should not be blocked: %s", result.ForLLM) + } +} + func TestShellTool_Background_ReturnsImmediately(t *testing.T) { tool, err := NewExecTool("", false) require.NoError(t, err)