Skip to content
Merged
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
3 changes: 1 addition & 2 deletions chatapps/configs/slack.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,7 @@ provider:
# Engine Configuration
# -----------------------------------------------------------------------------
#
engine:
work_dir: .
work_dir: ~/HotPlex

# ---------------------------------------------------------------------------
# SECURITY: Path Traversal Protection
Expand Down
22 changes: 22 additions & 0 deletions chatapps/slack/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,17 @@ func (a *Adapter) handleEventCallback(ctx context.Context, eventData json.RawMes
return
}

// Check user permission
if !a.config.IsUserAllowed(msgEvent.User) {
a.Logger().Debug("User blocked", "user_id", msgEvent.User)
return
}

// Check channel permission
if !a.config.ShouldProcessChannel(msgEvent.ChannelType, msgEvent.Channel) {
a.Logger().Debug("Channel blocked by policy", "channel_type", msgEvent.ChannelType, "channel_id", msgEvent.Channel)
return
}
sessionID := a.GetOrCreateSession(msgEvent.Channel+":"+msgEvent.User, msgEvent.User)

msg := &base.ChatMessage{
Expand Down Expand Up @@ -395,6 +406,17 @@ func (a *Adapter) handleSocketModeEvent(eventType string, data json.RawMessage)
return
}

// Check user permission
if !a.config.IsUserAllowed(msgEvent.User) {
a.Logger().Debug("User blocked", "user_id", msgEvent.User)
return
}

// Check channel permission
if !a.config.ShouldProcessChannel(msgEvent.ChannelType, msgEvent.Channel) {
a.Logger().Debug("Channel blocked by policy", "channel_type", msgEvent.ChannelType, "channel_id", msgEvent.Channel)
return
}
sessionID := a.GetOrCreateSession(msgEvent.Channel+":"+msgEvent.User, msgEvent.User)

msg := &base.ChatMessage{
Expand Down
68 changes: 68 additions & 0 deletions chatapps/slack/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ type Config struct {
Mode string
// ServerAddr: HTTP server address (e.g., ":8080")
ServerAddr string

// Permission Policy for Direct Messages
// "allow" - Allow all DMs (default)
// "pairing" - Only allow when user is paired
// "block" - Block all DMs
DMPolicy string

// Permission Policy for Group Messages
// "allow" - Allow all group messages (default)
// "mention" - Only allow when bot is mentioned
// "block" - Block all group messages
GroupPolicy string

// AllowedUsers: List of user IDs who can interact with the bot (whitelist)
AllowedUsers []string
// BlockedUsers: List of user IDs who cannot interact with the bot (blacklist)
BlockedUsers []string
}

// Token format patterns
Expand Down Expand Up @@ -72,3 +89,54 @@ func (c *Config) Validate() error {
func (c *Config) IsSocketMode() bool {
return c.Mode == "socket"
}

// IsUserAllowed checks if a user is allowed to interact with the bot
func (c *Config) IsUserAllowed(userID string) bool {
// Check blocked list first
for _, blocked := range c.BlockedUsers {
if blocked == userID {
return false
}
}

// If allowlist is set, check it
if len(c.AllowedUsers) > 0 {
for _, allowed := range c.AllowedUsers {
if allowed == userID {
return true
}
}
return false
}

// No allowlist, user is allowed
return true
}

// ShouldProcessChannel checks if messages from a channel should be processed
// channelType: "dm" or "channel" or "group"
func (c *Config) ShouldProcessChannel(channelType, channelID string) bool {
switch channelType {
case "dm":
switch c.DMPolicy {
case "block":
return false
case "pairing":
// TODO: Check if user is paired
return true
default: // "allow"
return true
}
case "channel", "group":
switch c.GroupPolicy {
case "block":
return false
case "mention":
// TODO: Check if bot was mentioned
return true
default: // "allow"
return true
}
}
return true
}
4 changes: 4 additions & 0 deletions docs-site/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ export default defineConfig({
]
},
{
<<<<<<< HEAD
text: 'Integrations',
=======
text: 'Connectivity',
>>>>>>> 1b849ff (feat(slack): add permission policy support)
items: [
{ text: 'WebSocket Protocol', link: '/guide/websocket' },
{ text: 'OpenCode HTTP/SSE', link: '/guide/opencode-http' },
Expand Down
13 changes: 13 additions & 0 deletions internal/engine/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ type SessionPool struct {
pending map[string]chan struct{}
}

// blockedEnvPrefixes contains environment variable prefixes that should be filtered
// out for security reasons to prevent injection attacks via environment variables.

// NewSessionPool creates a new session manager with default file-based marker storage.
func NewSessionPool(logger *slog.Logger, timeout time.Duration, opts EngineOptions, cliPath string, prv provider.Provider) *SessionPool {
if logger == nil {
Expand Down Expand Up @@ -218,8 +221,18 @@ func (sm *SessionPool) startSession(ctx context.Context, sessionID string, cfg S
cmd.Dir = cleaned // Fallback to cleaned path if error
}
} else {
// For absolute paths, also clean to resolve . and .. elements
cmd.Dir = filepath.Clean(cfg.WorkDir)
}
if cfg.WorkDir == "." || !filepath.IsAbs(cfg.WorkDir) {
if absPath, err := filepath.Abs(cfg.WorkDir); err == nil {
cmd.Dir = absPath
} else {
cmd.Dir = cfg.WorkDir // Fallback to original if error
}
} else {
cmd.Dir = cfg.WorkDir
}

// Setup process attributes and get job handle (Windows) or zero (Unix)
jobHandle, err := sys.SetupCmdSysProcAttr(cmd)
Expand Down
177 changes: 177 additions & 0 deletions internal/engine/workdir_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package engine

import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
)

// TestStartSession_WorkDirResolution verifies that WorkDir is correctly resolved
func TestStartSession_WorkDirResolution(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current working directory: %v", err)
}

testCases := []struct {
name string
workDir string
expectedDir string
shouldExist bool
}{
{name: "dot_current_dir", workDir: ".", expectedDir: cwd, shouldExist: true},
{name: "absolute_path", workDir: "/tmp", expectedDir: "/tmp", shouldExist: true},
{name: "relative_path_subdir", workDir: "./testdir", expectedDir: filepath.Join(cwd, "testdir"), shouldExist: false},
{name: "path_with_dot_middle", workDir: "/tmp/./hotplex", expectedDir: "/tmp/hotplex", shouldExist: false},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cfg := SessionConfig{WorkDir: tc.workDir}

// Replicate the FIXED logic from pool.go
var resolvedDir string
if cfg.WorkDir == "." || !filepath.IsAbs(cfg.WorkDir) {
cleaned := filepath.Clean(cfg.WorkDir)
if absPath, err := filepath.Abs(cleaned); err == nil {
resolvedDir = absPath
} else {
resolvedDir = cleaned
}
} else {
// For absolute paths, also clean to resolve . and .. elements
resolvedDir = filepath.Clean(cfg.WorkDir)
}

if resolvedDir != tc.expectedDir {
t.Errorf("Resolved dir = %q, want %q", resolvedDir, tc.expectedDir)
}

if tc.shouldExist {
if _, err := os.Stat(resolvedDir); os.IsNotExist(err) {
t.Errorf("Resolved directory does not exist: %s", resolvedDir)
}
}
})
}
}

// TestStartSession_CmdDirAssignment verifies cmd.Dir is correctly assigned
func TestStartSession_CmdDirAssignment(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current working directory: %v", err)
}

testCases := []struct {
name string
workDir string
wantCmdDir string
}{
{"dot", ".", cwd},
{"absolute", "/tmp", "/tmp"},
{"relative", "./subdir", filepath.Join(cwd, "subdir")},
{"path_with_dot", "/tmp/./test", "/tmp/test"},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cfg := SessionConfig{WorkDir: tc.workDir}
cmd := exec.CommandContext(context.Background(), "echo", "test")

// Replicate the FIXED logic from pool.go
if cfg.WorkDir == "." || !filepath.IsAbs(cfg.WorkDir) {
cleaned := filepath.Clean(cfg.WorkDir)
if absPath, err := filepath.Abs(cleaned); err == nil {
cmd.Dir = absPath
} else {
cmd.Dir = cleaned
}
} else {
// For absolute paths, also clean to resolve . and .. elements
cmd.Dir = filepath.Clean(cfg.WorkDir)
}

if cmd.Dir != tc.wantCmdDir {
t.Errorf("cmd.Dir = %q, want %q", cmd.Dir, tc.wantCmdDir)
}
})
}
}

// TestChatAppsWorkDirFunction simulates the chatapps/engine_handler.go flow
func TestChatAppsWorkDirFunction(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current working directory: %v", err)
}

workDirFn := func(sessionID string, configWorkDir string) string {
if configWorkDir != "" {
workDir := expandPathFixed(configWorkDir)
return workDir
}
return "/tmp/hotplex-chatapps"
}

testCases := []struct {
name string
configWorkDir string
expectedWorkDir string
}{
{"dot_config", ".", cwd},
{"absolute_config", "/tmp/myproject", "/tmp/myproject"},
{"empty_config", "", "/tmp/hotplex-chatapps"},
{"tilde_home", "~/project", filepath.Join(os.Getenv("HOME"), "project")},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
workDir := workDirFn("test-session", tc.configWorkDir)

if workDir != tc.expectedWorkDir {
t.Errorf("workDirFn(%q) = %q, want %q", tc.configWorkDir, workDir, tc.expectedWorkDir)
}
})
}
}

// expandPathFixed simulates the FIXED expandPath function from setup.go
func expandPathFixed(path string) string {
if len(path) == 0 {
return path
}

// Handle ~ expansion
if path[0] == '~' {
homeDir, err := os.UserHomeDir()
if err != nil {
return path // Return original path if home dir cannot be determined
}

if len(path) == 1 {
return homeDir
}

// Handle ~/path
if path[1] == '/' || path[1] == filepath.Separator {
return filepath.Join(homeDir, path[2:])
}

// Handle ~username/path (not commonly used, but supported)
return filepath.Join(homeDir, path[1:])
}

// Handle special case: "." should be expanded to current working directory
if path == "." {
cwd, err := os.Getwd()
if err != nil {
return path
}
return cwd
}

return filepath.Clean(path)
}