on main help sign in
add command whitelist
commitbc6fadc81a7ddf74c50863d266ac0e506ea7a75a authorTai Groot <tai@taigrr.com> dateTue 22 Jul 2025, 23:25 (1 year ago) parent5c9f168
4 files changed+109−4
internal/app/app.go+5−1 internal/config/config.go+3−2 internal/permission/permission.go+9−1 internal/permission/permission_test.go+92
internal/app/app.go+5−1
@@ -60,12 +60,16 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
6060 messages := message.NewService(q)
6161 files := history.NewService(q, conn)
6262 skipPermissionsRequests := cfg.Options != nil && cfg.Options.SkipPermissionsRequests
63+ allowedCommands := []string{}
64+ if cfg.Options != nil && cfg.Options.AllowedCommands != nil {
65+ allowedCommands = cfg.Options.AllowedCommands
66+ }
6367
6468 app := &App{
6569 Sessions: sessions,
6670 Messages: messages,
6771 History: files,
68− Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests),
72+ Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedCommands),
6973 LSPClients: make(map[string]*lsp.Client),
7074
7175 globalCtx: ctx,
internal/config/config.go+3−2
@@ -126,8 +126,9 @@ type Options struct {
126126 Debug bool `json:"debug,omitempty"`
127127 DebugLSP bool `json:"debug_lsp,omitempty"`
128128 DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty"`
129− DataDirectory string `json:"data_directory,omitempty"` // Relative to the cwd
130− SkipPermissionsRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode)
129+ DataDirectory string `json:"data_directory,omitempty"` // Relative to the cwd
130+ SkipPermissionsRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode)
131+ AllowedCommands []string `json:"allowed_commands,omitempty"` // Commands that don't require permission prompts
131132}
132133
133134type MCPs map[string]MCPConfig
internal/permission/permission.go+9−1
@@ -50,6 +50,7 @@ type permissionService struct {
5050 autoApproveSessions []string
5151 autoApproveSessionsMu sync.RWMutex
5252 skip bool
53+ allowedCommands []string
5354}
5455
5556func (s *permissionService) GrantPersistent(permission PermissionRequest) {
@@ -82,6 +83,12 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
8283 return true
8384 }
8485
86+ // Check if the tool/action combination is in the allowlist
87+ commandKey := opts.ToolName + ":" + opts.Action
88+ if slices.Contains(s.allowedCommands, commandKey) || slices.Contains(s.allowedCommands, opts.ToolName) {
89+ return true
90+ }
91+
8592 s.autoApproveSessionsMu.RLock()
8693 autoApprove := slices.Contains(s.autoApproveSessions, opts.SessionID)
8794 s.autoApproveSessionsMu.RUnlock()
@@ -130,11 +137,12 @@ func (s *permissionService) AutoApproveSession(sessionID string) {
130137 s.autoApproveSessionsMu.Unlock()
131138}
132139
133−func NewPermissionService(workingDir string, skip bool) Service {
140+func NewPermissionService(workingDir string, skip bool, allowedCommands []string) Service {
134141 return &permissionService{
135142 Broker: pubsub.NewBroker[PermissionRequest](),
136143 workingDir: workingDir,
137144 sessionPermissions: make([]PermissionRequest, 0),
138145 skip: skip,
146+ allowedCommands: allowedCommands,
139147 }
140148}
internal/permission/permission_test.gonew file+92
@@ -0,0 +1,92 @@
1+package permission
2+
3+import (
4+ "testing"
5+)
6+
7+func TestPermissionService_AllowedCommands(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ allowedCommands []string
11+ toolName string
12+ action string
13+ expected bool
14+ }{
15+ {
16+ name: "tool in allowlist",
17+ allowedCommands: []string{"bash", "view"},
18+ toolName: "bash",
19+ action: "execute",
20+ expected: true,
21+ },
22+ {
23+ name: "tool:action in allowlist",
24+ allowedCommands: []string{"bash:execute", "edit:create"},
25+ toolName: "bash",
26+ action: "execute",
27+ expected: true,
28+ },
29+ {
30+ name: "tool not in allowlist",
31+ allowedCommands: []string{"view", "ls"},
32+ toolName: "bash",
33+ action: "execute",
34+ expected: false,
35+ },
36+ {
37+ name: "tool:action not in allowlist",
38+ allowedCommands: []string{"bash:read", "edit:create"},
39+ toolName: "bash",
40+ action: "execute",
41+ expected: false,
42+ },
43+ {
44+ name: "empty allowlist",
45+ allowedCommands: []string{},
46+ toolName: "bash",
47+ action: "execute",
48+ expected: false,
49+ },
50+ }
51+
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ service := NewPermissionService("/tmp", false, tt.allowedCommands)
55+
56+ // Create a channel to capture the permission request
57+ // Since we're testing the allowlist logic, we need to simulate the request
58+ ps := service.(*permissionService)
59+
60+ // Test the allowlist logic directly
61+ commandKey := tt.toolName + ":" + tt.action
62+ allowed := false
63+ for _, cmd := range ps.allowedCommands {
64+ if cmd == commandKey || cmd == tt.toolName {
65+ allowed = true
66+ break
67+ }
68+ }
69+
70+ if allowed != tt.expected {
71+ t.Errorf("expected %v, got %v for tool %s action %s with allowlist %v",
72+ tt.expected, allowed, tt.toolName, tt.action, tt.allowedCommands)
73+ }
74+ })
75+ }
76+}
77+
78+func TestPermissionService_SkipMode(t *testing.T) {
79+ service := NewPermissionService("/tmp", true, []string{})
80+
81+ result := service.Request(CreatePermissionRequest{
82+ SessionID: "test-session",
83+ ToolName: "bash",
84+ Action: "execute",
85+ Description: "test command",
86+ Path: "/tmp",
87+ })
88+
89+ if !result {
90+ t.Error("expected permission to be granted in skip mode")
91+ }
92+}