diff --git a/chatapps/configs/slack.yaml b/chatapps/configs/slack.yaml index c188864f..624fc365 100644 --- a/chatapps/configs/slack.yaml +++ b/chatapps/configs/slack.yaml @@ -104,8 +104,7 @@ provider: # Engine Configuration # ----------------------------------------------------------------------------- # -engine: - work_dir: . + work_dir: ~/HotPlex # --------------------------------------------------------------------------- # SECURITY: Path Traversal Protection diff --git a/chatapps/slack/adapter.go b/chatapps/slack/adapter.go index e1d2c81c..96ebed55 100644 --- a/chatapps/slack/adapter.go +++ b/chatapps/slack/adapter.go @@ -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{ @@ -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{ diff --git a/chatapps/slack/config.go b/chatapps/slack/config.go index d3bac61c..6c96c49b 100644 --- a/chatapps/slack/config.go +++ b/chatapps/slack/config.go @@ -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 @@ -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 +} diff --git a/docs-site/.vitepress/config.mts b/docs-site/.vitepress/config.mts index b55ccc94..d7480306 100644 --- a/docs-site/.vitepress/config.mts +++ b/docs-site/.vitepress/config.mts @@ -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' }, diff --git a/internal/engine/pool.go b/internal/engine/pool.go index 580f56ff..e11c0c12 100644 --- a/internal/engine/pool.go +++ b/internal/engine/pool.go @@ -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 { @@ -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) diff --git a/internal/engine/workdir_integration_test.go b/internal/engine/workdir_integration_test.go new file mode 100644 index 00000000..e9518a93 --- /dev/null +++ b/internal/engine/workdir_integration_test.go @@ -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) +}