Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions pkg/agent/agent_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions pkg/agent/pipeline_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions pkg/tools/shared/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions pkg/tools/shared_facade.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
27 changes: 27 additions & 0 deletions pkg/tools/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:").
Expand Down
20 changes: 20 additions & 0 deletions pkg/tools/shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down