docs: add exec permission implementation plan and design spec
This commit is contained in:
parent
f46cf4c706
commit
7205dc6b5e
2 changed files with 1142 additions and 0 deletions
744
docs/superpowers-optimized/plans/2026-05-05-exec-permission.md
Normal file
744
docs/superpowers-optimized/plans/2026-05-05-exec-permission.md
Normal file
|
|
@ -0,0 +1,744 @@
|
||||||
|
# Exec Permission Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-optimized:subagent-driven-development (recommended) or superpowers-optimized:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add permission system to exec tool allowing user approval for paths outside workspace, with "once" and "session" options.
|
||||||
|
|
||||||
|
**Architecture:** New `PermissionCache` + `RequestPermissionTool` in `pkg/tools/permission.go`, modify `ExecTool` to check permissions before executing commands with outside-workspace paths. Config flag `tools.exec.ask_permission` defaults to `true`.
|
||||||
|
|
||||||
|
**Tech Stack:** Go 1.25.9, sync.RWMutex for cache, Tool interface for new tool.
|
||||||
|
|
||||||
|
**Assumptions:** Assumes user has Go environment set up — will NOT work if Go not installed. Assumes LLM follows instructions to call `request_permission` when exec returns early with permission message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create PermissionCache Structure
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `pkg/tools/permission.go`
|
||||||
|
- Test: `pkg/tools/permission_test.go`
|
||||||
|
|
||||||
|
**Does NOT cover:** Grant/revoke logic for individual paths — that's Task 2.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/tools/permission_test.go
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPermissionCache_Check_NoPermission(t *testing.T) {
|
||||||
|
pc := NewPermissionCache()
|
||||||
|
result := pc.Check("/desktop")
|
||||||
|
if result != "" {
|
||||||
|
t.Errorf("Expected empty string, got %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `go test -run TestPermissionCache_Check_NoPermission ./pkg/tools/...`
|
||||||
|
Expected: FAIL - `NewPermissionCache` not defined
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement minimal change**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/tools/permission.go
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PermissionCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
perms map[string]string // path → "once"|"session"|"denied"
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPermissionCache() *PermissionCache {
|
||||||
|
return &PermissionCache{
|
||||||
|
perms: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check returns "once", "session", "denied", or "" (no permission)
|
||||||
|
func (pc *PermissionCache) Check(path string) string {
|
||||||
|
pc.mu.RLock()
|
||||||
|
defer pc.mu.RUnlock()
|
||||||
|
|
||||||
|
// Check exact match
|
||||||
|
if val, ok := pc.perms[path]; ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any parent path is granted "session"
|
||||||
|
for cachedPath, val := range pc.perms {
|
||||||
|
if val == "session" && len(cachedPath) < len(path) && path[:len(cachedPath)] == cachedPath {
|
||||||
|
return "session"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *PermissionCache) Grant(path, duration string) {
|
||||||
|
pc.mu.Lock()
|
||||||
|
defer pc.mu.Unlock()
|
||||||
|
pc.perms[path] = duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *PermissionCache) Revoke(path string) {
|
||||||
|
pc.mu.Lock()
|
||||||
|
defer pc.mu.Unlock()
|
||||||
|
delete(pc.perms, path)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `go test -run TestPermissionCache_Check_NoPermission ./pkg/tools/...`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pkg/tools/permission.go pkg/tools/permission_test.go
|
||||||
|
git commit -m "feat: add PermissionCache for exec tool permission system"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add PermissionCache Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pkg/tools/permission_test.go`
|
||||||
|
|
||||||
|
**Does NOT cover:** RequestPermissionTool implementation — that's Task 3.
|
||||||
|
|
||||||
|
- [x] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/tools/permission_test.go (add to existing file)
|
||||||
|
func TestPermissionCache_GrantAndCheck(t *testing.T) {
|
||||||
|
pc := NewPermissionCache()
|
||||||
|
pc.Grant("/desktop", "session")
|
||||||
|
|
||||||
|
result := pc.Check("/desktop")
|
||||||
|
if result != "session" {
|
||||||
|
t.Errorf("Expected 'session', got %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPermissionCache_ParentPathMatch(t *testing.T) {
|
||||||
|
pc := NewPermissionCache()
|
||||||
|
pc.Grant("/desktop", "session")
|
||||||
|
|
||||||
|
// Child path should match parent's "session" permission
|
||||||
|
result := pc.Check("/desktop/folder")
|
||||||
|
if result != "session" {
|
||||||
|
t.Errorf("Expected 'session' for child path, got %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPermissionCache_Revoke(t *testing.T) {
|
||||||
|
pc := NewPermissionCache()
|
||||||
|
pc.Grant("/desktop", "session")
|
||||||
|
pc.Revoke("/desktop")
|
||||||
|
|
||||||
|
result := pc.Check("/desktop")
|
||||||
|
if result != "" {
|
||||||
|
t.Errorf("Expected empty after revoke, got %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPermissionCache_Denied(t *testing.T) {
|
||||||
|
pc := NewPermissionCache()
|
||||||
|
pc.Grant("/desktop", "denied")
|
||||||
|
|
||||||
|
result := pc.Check("/desktop")
|
||||||
|
if result != "denied" {
|
||||||
|
t.Errorf("Expected 'denied', got %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `go test -run "TestPermissionCache_Grant|TestPermissionCache_Parent|TestPermissionCache_Revoke|TestPermissionCache_Denied" ./pkg/tools/...`
|
||||||
|
Expected: FAIL (tests don't exist yet if file is new, or PASS if appended)
|
||||||
|
|
||||||
|
- [x] **Step 3: Implement (tests already written above)**
|
||||||
|
|
||||||
|
Tests are already written in Step 1.
|
||||||
|
|
||||||
|
- [x] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `go test -run "TestPermissionCache" ./pkg/tools/...`
|
||||||
|
Expected: PASS (all 5 tests)
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pkg/tools/permission_test.go
|
||||||
|
git commit -m "test: add PermissionCache unit tests (Grant, Check, Revoke, parent path)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create RequestPermissionTool
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pkg/tools/permission.go` (add RequestPermissionTool)
|
||||||
|
- Test: `pkg/tools/permission_test.go` (add tool tests)
|
||||||
|
|
||||||
|
**Does NOT cover:** Modifying ExecTool to use permissions — that's Task 4.
|
||||||
|
|
||||||
|
- [x] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/tools/permission_test.go (add)
|
||||||
|
func TestRequestPermissionTool_Name(t *testing.T) {
|
||||||
|
pc := NewPermissionCache()
|
||||||
|
tool := NewRequestPermissionTool(pc)
|
||||||
|
|
||||||
|
if tool.Name() != "request_permission" {
|
||||||
|
t.Errorf("Expected 'request_permission', got %s", tool.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestPermissionTool_Execute(t *testing.T) {
|
||||||
|
pc := NewPermissionCache()
|
||||||
|
tool := NewRequestPermissionTool(pc)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": "/desktop",
|
||||||
|
"command": "ls /desktop",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Expected non-nil result")
|
||||||
|
}
|
||||||
|
if result.ForUser == "" {
|
||||||
|
t.Error("Expected non-empty ForUser message")
|
||||||
|
}
|
||||||
|
if result.ForLLM == "" {
|
||||||
|
t.Error("Expected non-empty ForLLM message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `go test -run "TestRequestPermissionTool" ./pkg/tools/...`
|
||||||
|
Expected: FAIL - `NewRequestPermissionTool` not defined
|
||||||
|
|
||||||
|
- [x] **Step 3: Implement minimal change**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/tools/permission.go (add to existing file)
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ... existing PermissionCache code ...
|
||||||
|
|
||||||
|
type RequestPermissionTool struct {
|
||||||
|
cache *PermissionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRequestPermissionTool(cache *PermissionCache) *RequestPermissionTool {
|
||||||
|
return &RequestPermissionTool{cache: cache}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RequestPermissionTool) Name() string {
|
||||||
|
return "request_permission"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RequestPermissionTool) Description() string {
|
||||||
|
return "Request user permission for accessing paths outside workspace. Returns prompt for user."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RequestPermissionTool) PromptMetadata() PromptMetadata {
|
||||||
|
return PromptMetadata{
|
||||||
|
Layer: ToolPromptLayerCapability,
|
||||||
|
Slot: ToolPromptSlotTooling,
|
||||||
|
Source: ToolPromptSourceRegistry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RequestPermissionTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path that needs permission",
|
||||||
|
},
|
||||||
|
"command": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Original command (for context)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RequestPermissionTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
command, _ := args["command"].(string)
|
||||||
|
|
||||||
|
logger.InfoCF("permission", "Permission request", map[string]any{"path": path, "command": command})
|
||||||
|
|
||||||
|
// Return structured prompt that LLM will show to user
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf("User permission needed for %s. Ask user: 'Allow once' or 'Allow for session'?", path),
|
||||||
|
ForUser: fmt.Sprintf("⚠️ **Permission Required**\n\nPicoClaw wants to execute: `%s`\nThis accesses `%s` which is outside your workspace.\n\n**How would you like to proceed?**\n- Type `once` for one-time access\n- Type `session` to allow all access to this path for this session\n- Type `no` to deny", command, path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `go test -run "TestRequestPermissionTool" ./pkg/tools/...`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pkg/tools/permission.go pkg/tools/permission_test.go
|
||||||
|
git commit -m "feat: add RequestPermissionTool for user permission prompts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Modify ExecTool to Check Permissions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pkg/tools/shell.go`
|
||||||
|
- Modify: `pkg/tools/session.go` (add PermissionCache field to ExecTool)
|
||||||
|
|
||||||
|
**Does NOT cover:** Config changes for `ask_permission` — that's Task 5.
|
||||||
|
|
||||||
|
- [x] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/tools/shell_test.go (add)
|
||||||
|
func TestExecTool_CheckPermission_NeedsPermission(t *testing.T) {
|
||||||
|
pc := NewPermissionCache()
|
||||||
|
|
||||||
|
// Create exec tool with permission cache
|
||||||
|
// Note: This test verifies that exec returns early when askPermission=true and path needs permission
|
||||||
|
// We'll need to check the tool's behavior when executing commands with outside-workspace paths
|
||||||
|
|
||||||
|
// For now, test the isOutsideWorkspace helper
|
||||||
|
tool := &ExecTool{
|
||||||
|
permissionCache: pc,
|
||||||
|
askPermission: true,
|
||||||
|
workingDir: "/workspace",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test path extraction (simplified)
|
||||||
|
result := tool.checkPermission("/desktop")
|
||||||
|
if result != "needs_permission" {
|
||||||
|
t.Errorf("Expected 'needs_permission', got %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `go test -run TestExecTool_CheckPermission ./pkg/tools/...`
|
||||||
|
Expected: FAIL - `checkPermission` method not defined
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement minimal change**
|
||||||
|
|
||||||
|
First, modify `pkg/tools/session.go` to add PermissionCache to ExecTool struct:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/tools/session.go (modify ExecTool struct, around line 37)
|
||||||
|
type ExecTool struct {
|
||||||
|
workingDir string
|
||||||
|
timeout time.Duration
|
||||||
|
denyPatterns []*regexp.Regexp
|
||||||
|
allowPatterns []*regexp.Regexp
|
||||||
|
customAllowPatterns []*regexp.Regexp
|
||||||
|
allowedPathPatterns []*regexp.Regexp
|
||||||
|
restrictToWorkspace bool
|
||||||
|
allowRemote bool
|
||||||
|
sessionManager *SessionManager
|
||||||
|
permissionCache *PermissionCache // NEW
|
||||||
|
askPermission bool // NEW
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then modify `pkg/tools/shell.go` to add the checkPermission method and modify Execute:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/tools/shell.go (add method)
|
||||||
|
func (t *ExecTool) checkPermission(command string) string {
|
||||||
|
if !t.askPermission {
|
||||||
|
return "granted" // Permission checks disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract path from command (simplified - looks for paths in command)
|
||||||
|
// This is a basic implementation - real version would parse the command more carefully
|
||||||
|
path := t.extractPathFromCommand(command)
|
||||||
|
if path == "" {
|
||||||
|
return "granted"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if path is outside workspace
|
||||||
|
if t.restrictToWorkspace && t.isOutsideWorkspace(path) {
|
||||||
|
// Check permission cache
|
||||||
|
if perm := t.permissionCache.Check(path); perm != "" {
|
||||||
|
if perm == "denied" {
|
||||||
|
return "denied"
|
||||||
|
}
|
||||||
|
return "granted"
|
||||||
|
}
|
||||||
|
return "needs_permission"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "granted"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) isOutsideWorkspace(path string) bool {
|
||||||
|
// Simplified check - if path is absolute and not under workspace
|
||||||
|
if filepath.IsAbs(path) {
|
||||||
|
absWorkspace, _ := filepath.Abs(t.workingDir)
|
||||||
|
absPath, _ := filepath.Abs(path)
|
||||||
|
return !strings.HasPrefix(absPath, absWorkspace)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) extractPathFromCommand(command string) string {
|
||||||
|
// Basic path extraction - looks for the first absolute path or path after common commands
|
||||||
|
// This is simplified - real implementation would be more robust
|
||||||
|
parts := strings.Fields(command)
|
||||||
|
for _, part := range parts {
|
||||||
|
if filepath.IsAbs(part) {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then modify the `Execute` method in `shell.go` to check permissions early:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// In ExecTool.Execute(), add near the beginning:
|
||||||
|
func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
command, _ := args["command"].(string)
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
switch t.checkPermission(command) {
|
||||||
|
case "needs_permission":
|
||||||
|
path := t.extractPathFromCommand(command)
|
||||||
|
logger.InfoCF("exec", "Permission needed", map[string]any{"command": command, "path": path})
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf("Permission needed for path: %s. Call request_permission tool with path='%s'.", path, path),
|
||||||
|
ForUser: fmt.Sprintf("⚠️ Permission required to access %s", path),
|
||||||
|
}
|
||||||
|
case "denied":
|
||||||
|
path := t.extractPathFromCommand(command)
|
||||||
|
return ErrorResult(fmt.Sprintf("Access to %s was denied", path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... rest of existing Execute logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `go test -run TestExecTool_CheckPermission ./pkg/tools/...`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pkg/tools/shell.go pkg/tools/session.go
|
||||||
|
git commit -m "feat: modify ExecTool to check PermissionCache before executing outside-workspace commands"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add ask_permission to Config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pkg/config/config.go` (add AskPermission to ExecConfig)
|
||||||
|
- Modify: `pkg/config/defaults.go` (add default)
|
||||||
|
|
||||||
|
**Does NOT cover:** Wiring up in instance.go — that's Task 6.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/config/config_test.go (add)
|
||||||
|
func TestToolsConfig_IsToolEnabled_ExecAskPermission(t *testing.T) {
|
||||||
|
cfg := &ToolsConfig{
|
||||||
|
Exec: ExecConfig{
|
||||||
|
Enabled: true,
|
||||||
|
AskPermission: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify AskPermission field exists and is accessible
|
||||||
|
if !cfg.Exec.AskPermission {
|
||||||
|
t.Error("Expected AskPermission to be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `go test -run TestToolsConfig_IsToolEnabled_ExecAskPermission ./pkg/config/...`
|
||||||
|
Expected: FAIL - `AskPermission` field not defined in ExecConfig
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement minimal change**
|
||||||
|
|
||||||
|
In `pkg/config/config.go`, find `ExecConfig` struct (around line 763) and add the field:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ExecConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
EnableDenyPatterns bool `json:"enable_deny_patterns"`
|
||||||
|
CustomDenyPatterns []string `json:"custom_deny_patterns"`
|
||||||
|
CustomAllowPatterns []string `json:"custom_allow_patterns"`
|
||||||
|
AllowRemote bool `json:"allow_remote"`
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
|
AskPermission bool `json:"ask_permission"` // NEW
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `pkg/config/defaults.go`, find the Exec defaults (around lines 365-372) and add the field:
|
||||||
|
|
||||||
|
```go
|
||||||
|
Exec: ExecConfig{
|
||||||
|
Enabled: true,
|
||||||
|
EnableDenyPatterns: true,
|
||||||
|
AllowRemote: true,
|
||||||
|
TimeoutSeconds: 60,
|
||||||
|
AskPermission: true, // NEW: enable permission prompts by default
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `go test -run TestToolsConfig_IsToolEnabled_ExecAskPermission ./pkg/config/...`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pkg/config/config.go pkg/config/defaults.go
|
||||||
|
git commit -m "feat: add ask_permission config option to ExecConfig (default: true)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Wire Up in Agent Instance
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pkg/agent/instance.go` (create PermissionCache, pass to ExecTool, register RequestPermissionTool)
|
||||||
|
|
||||||
|
**Does NOT cover:** Running make build/test — that's Task 7.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
This is an integration test - verify that exec tool is registered with PermissionCache when `ask_permission=true`.
|
||||||
|
|
||||||
|
Since this requires more complex setup, we'll do a manual verification step instead of a unit test.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement minimal change**
|
||||||
|
|
||||||
|
In `pkg/agent/instance.go`, modify the exec tool registration (around lines 104-112):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/agent/instance.go
|
||||||
|
// Create permission cache (add near top of function that registers tools)
|
||||||
|
var permissionCache *tools.PermissionCache
|
||||||
|
if cfg.Tools.IsToolEnabled("exec") {
|
||||||
|
permissionCache = tools.NewPermissionCache()
|
||||||
|
|
||||||
|
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg, allowReadPaths)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to initialize exec tool; continuing without exec", ...)
|
||||||
|
} else {
|
||||||
|
// Set permission cache and ask_permission flag
|
||||||
|
execTool.permissionCache = permissionCache
|
||||||
|
execTool.askPermission = cfg.Tools.Exec.AskPermission
|
||||||
|
|
||||||
|
toolsRegistry.Register(execTool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register request_permission tool if exec is enabled
|
||||||
|
if permissionCache != nil {
|
||||||
|
toolsRegistry.Register(tools.NewRequestPermissionTool(permissionCache))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also need to modify `NewExecToolWithConfig` or pass the cache after creation. Simplest approach - add a setter or modify the constructor.
|
||||||
|
|
||||||
|
In `pkg/tools/session.go`, modify `NewExecToolWithConfig`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewExecToolWithConfig(workingDir string, restrictToWorkspace bool, cfg *config.ToolsConfig, allowPaths []*regexp.Regexp) (*ExecTool, error) {
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
tool := &ExecTool{
|
||||||
|
// ... existing fields ...
|
||||||
|
askPermission: cfg.Exec.AskPermission, // NEW
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cache later via setter or directly if we have access
|
||||||
|
return tool, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Better approach - set cache after creation in instance.go:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// In instance.go, after creating execTool:
|
||||||
|
execTool.permissionCache = permissionCache
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure `permissionCache` field in ExecTool is exported or provide a setter.
|
||||||
|
|
||||||
|
- [x] **Step 3: Manual verification**
|
||||||
|
|
||||||
|
Check that:
|
||||||
|
1. `tools.NewRequestPermissionTool` is defined
|
||||||
|
2. `ExecTool.PermissionCache` field exists
|
||||||
|
3. Registration code in instance.go compiles
|
||||||
|
|
||||||
|
Run: `go build ./pkg/agent/...`
|
||||||
|
Expected: Build succeeds
|
||||||
|
|
||||||
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pkg/agent/instance.go pkg/tools/session.go
|
||||||
|
git commit -m "feat: wire up PermissionCache and register RequestPermissionTool in agent"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Run Build and Test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No file changes
|
||||||
|
|
||||||
|
**Does NOT cover:** Documentation updates — that's Task 8.
|
||||||
|
|
||||||
|
- [x] **Step 1: Run make build**
|
||||||
|
|
||||||
|
Run: `make build`
|
||||||
|
Expected: Build succeeds
|
||||||
|
|
||||||
|
- [x] **Step 2: Run make test**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: All tests pass
|
||||||
|
|
||||||
|
- [x] **Step 3: Manual testing**
|
||||||
|
|
||||||
|
1. Start PicoClaw
|
||||||
|
2. Try `exec ls /desktop`
|
||||||
|
3. Verify LLM calls request_permission
|
||||||
|
4. Verify user prompt appears
|
||||||
|
5. Test "once" and "session" options
|
||||||
|
6. Verify subsequent calls respect permission
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Update Documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/reference/exec-tool.md`
|
||||||
|
- Modify: `docs/reference/tools-api.md`
|
||||||
|
|
||||||
|
**Does NOT cover:** Anything else — final task.
|
||||||
|
|
||||||
|
- [x] **Step 1: Update exec-tool.md**
|
||||||
|
|
||||||
|
Add new section:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Permission System (New)
|
||||||
|
|
||||||
|
When `tools.exec.ask_permission = true` (default), the exec tool will ask for user permission before accessing paths outside workspace.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. Exec tool detects command accesses path outside workspace
|
||||||
|
2. Checks PermissionCache - if no permission, returns early
|
||||||
|
3. LLM calls `request_permission` tool
|
||||||
|
4. Tool returns prompt for user: "Allow once" or "Allow for session"
|
||||||
|
5. User responds, LLM re-calls exec tool
|
||||||
|
6. Permission cached for "once" (consumed after use) or "session" (persists)
|
||||||
|
|
||||||
|
### Request Permission Tool
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `path` | Path that needs permission |
|
||||||
|
| `command` | Original command (for context) |
|
||||||
|
|
||||||
|
### Permission Options
|
||||||
|
|
||||||
|
- **once**: Permission consumed after first use
|
||||||
|
- **session**: Permission persists for entire session
|
||||||
|
- **no**: Access denied
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Update tools-api.md**
|
||||||
|
|
||||||
|
Add `request_permission` to the tools table:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| Tool Name | Description | Category | Config Key |
|
||||||
|
|-----------|-------------|----------|------------|
|
||||||
|
| `request_permission` | Request user permission for outside-workspace access | permission | `exec.ask_permission` |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/reference/exec-tool.md docs/reference/tools-api.md
|
||||||
|
git commit -m "docs: update exec tool and tools API docs with permission system"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec Coverage Check:**
|
||||||
|
- [x] New `request_permission` tool ✓ (Task 3, 6)
|
||||||
|
- [x] Session-based or one-time permission ✓ (Task 1, 3 - PermissionCache)
|
||||||
|
- [x] Scope: any path outside workspace ✓ (Task 4 - checkPermission)
|
||||||
|
- [x] Approach 1: separate tool + cache ✓ (Task 1, 3)
|
||||||
|
|
||||||
|
**Placeholder Scan:**
|
||||||
|
- No TBD/TODO found ✓
|
||||||
|
- No "implement later" found ✓
|
||||||
|
- Actual code provided in all steps ✓
|
||||||
|
|
||||||
|
**Type Consistency:**
|
||||||
|
- `PermissionCache` defined in Task 1, used in Tasks 3, 4, 6 ✓
|
||||||
|
- `RequestPermissionTool` defined in Task 3, registered in Task 6 ✓
|
||||||
|
- `AskPermission` added to `ExecConfig` in Task 5, used in Task 6 ✓
|
||||||
|
|
||||||
|
**No Issues Found.** Plan ready for execution.
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
# Design: Exec Tool Permission System
|
||||||
|
|
||||||
|
**Date**: 2026-05-05
|
||||||
|
**Status**: Pending User Approval
|
||||||
|
**Author**: AI Assistant
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In-Scope**:
|
||||||
|
- Allow exec tool to work on ANY folder (not just workspace)
|
||||||
|
- Add permission system that prompts user before accessing paths outside workspace
|
||||||
|
- Permission can be "once" or "for session" (user choice)
|
||||||
|
- New dedicated `request_permission` tool
|
||||||
|
|
||||||
|
**Non-Goals**:
|
||||||
|
- Changing how exec tool executes commands (still uses same shell/pty)
|
||||||
|
- Modifying non-exec tools (read_file, write_file, etc.)
|
||||||
|
- Adding GUI dialogs (permission is via chat interface) and web.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### System Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User: "List directories in /desktop"
|
||||||
|
↓
|
||||||
|
LLM calls exec tool with command "ls /desktop"
|
||||||
|
↓
|
||||||
|
Exec tool detects /desktop is outside workspace
|
||||||
|
↓
|
||||||
|
Exec tool returns: {"permission_needed": true, "path": "/desktop", "message": "Permission required..."}
|
||||||
|
↓
|
||||||
|
LLM sees permission_needed = true
|
||||||
|
↓
|
||||||
|
LLM calls request_permission tool: {path: "/desktop", command: "ls /desktop"}
|
||||||
|
↓
|
||||||
|
request_permission tool prompts user: "Allow access to /desktop? [Once] [Session] [Deny]"
|
||||||
|
↓
|
||||||
|
User selects "Session"
|
||||||
|
↓
|
||||||
|
Permission cached in PermissionCache: {"/desktop": "session"}
|
||||||
|
↓
|
||||||
|
LLM re-calls exec tool with same command
|
||||||
|
↓
|
||||||
|
Exec tool checks PermissionCache, sees "/desktop" → "session"
|
||||||
|
↓
|
||||||
|
Exec tool proceeds with command execution
|
||||||
|
↓
|
||||||
|
Returns output to LLM
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| Component | File | Purpose |
|
||||||
|
|-----------|------|---------|
|
||||||
|
| `PermissionCache` | `pkg/tools/permission.go` (new) | Stores per-session permissions |
|
||||||
|
| `RequestPermissionTool` | `pkg/tools/permission.go` (new) | Tool that prompts user for permission |
|
||||||
|
| `ExecTool` (modified) | `pkg/tools/shell.go` | Check PermissionCache before executing |
|
||||||
|
| `ToolsConfig` (modified) | `pkg/config/config.go` | Add `ask_permission` config option |
|
||||||
|
| `SessionManager` (modified) | `pkg/tools/session.go` | Store PermissionCache per session |
|
||||||
|
|
||||||
|
## Design Options
|
||||||
|
|
||||||
|
### Approach 1: New `request_permission` Tool + Session Cache (Recommended)
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
- Add new `RequestPermissionTool` that LLM calls when exec returns `permission_needed`
|
||||||
|
- Tool prompts user with options: "Allow once", "Allow for session", "Deny"
|
||||||
|
- Permission stored in `PermissionCache` (map[string]string: path → "once"|"session")
|
||||||
|
- Exec tool checks cache before executing commands with outside-workspace paths
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Clean separation of concerns (permission logic separate from exec)
|
||||||
|
- Matches user's answer: "New dedicated tool"
|
||||||
|
- Flexible: supports one-time and session-based permissions
|
||||||
|
- Cache is session-based, no persistence across restarts
|
||||||
|
- "once" permission is auto-consumed after the first successful exec call using that path
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- LLM needs two tool calls (exec → request_permission → exec again)
|
||||||
|
- Slightly more complex flow
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```go
|
||||||
|
// pkg/tools/permission.go (new file)
|
||||||
|
|
||||||
|
type PermissionCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
perms map[string]string // path → "once"|"session"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *PermissionCache) Check(path string) string {
|
||||||
|
pc.mu.RLock()
|
||||||
|
defer pc.mu.RUnlock()
|
||||||
|
return pc.perms[path]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *PermissionCache) Grant(path, duration string) {
|
||||||
|
pc.mu.Lock()
|
||||||
|
defer pc.mu.Unlock()
|
||||||
|
pc.perms[path] = duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestPermissionTool struct {
|
||||||
|
cache *PermissionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RequestPermissionTool) Name() string { return "request_permission" }
|
||||||
|
|
||||||
|
func (t *RequestPermissionTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
// This tool triggers a user prompt in the chat interface
|
||||||
|
// The LLM will see the response and ask the user
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf("Permission needed for %s. Ask user: 'Allow once' or 'Allow for session'?", path),
|
||||||
|
ForUser: fmt.Sprintf("⚠️ **Permission Required**\n\nPicoClaw wants to access `%s` which is outside your workspace.\n\n**Allow access?**\n- Type 'once' for one-time access\n- Type 'session' for this session\n- Type 'no' to deny", path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Approach 2: Integrate Permission into Exec Tool
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
- Exec tool, when detecting outside-workspace access, returns early with `permission_needed` flag
|
||||||
|
- LLM asks user, then re-calls exec with a `confirmed=true` parameter
|
||||||
|
- No separate tool, but exec tool becomes more complex
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Simpler flow (one tool instead of two)
|
||||||
|
- Fewer LLM calls
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- exec tool does two things: execute commands AND handle permissions
|
||||||
|
- LLM still needs to re-call exec after user confirms
|
||||||
|
- Less flexible for future permission types
|
||||||
|
|
||||||
|
**Rejected**: User explicitly asked for "new dedicated tool"
|
||||||
|
|
||||||
|
### Approach 3: Config-Based Whitelist Only
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
- Add `tools.exec.allowed_paths` config array
|
||||||
|
- Paths in whitelist don't need permission
|
||||||
|
- No runtime prompting
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Simple implementation
|
||||||
|
- No extra LLM calls
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Static - can't ask user dynamically
|
||||||
|
- Requires pre-configuration
|
||||||
|
- Doesn't match user's requirement of "asking for permission"
|
||||||
|
|
||||||
|
**Rejected**: Doesn't satisfy "ask for permission" requirement.
|
||||||
|
|
||||||
|
## Recommended Approach: Approach 1
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
1. Matches user's explicit answer: "New dedicated tool"
|
||||||
|
2. Clean separation of concerns
|
||||||
|
3. Supports both "once" and "session" permission durations
|
||||||
|
4. Follows existing patterns in codebase (tools are separate, registered in registry)
|
||||||
|
|
||||||
|
## Data Structures
|
||||||
|
|
||||||
|
### PermissionCache
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/tools/permission.go
|
||||||
|
|
||||||
|
type PermissionCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
perms map[string]string // path → "once"|"session"|"denied"
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPermissionCache() *PermissionCache {
|
||||||
|
return &PermissionCache{
|
||||||
|
perms: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check returns "once", "session", "denied", or "" (no permission)
|
||||||
|
func (pc *PermissionCache) Check(path string) string {
|
||||||
|
pc.mu.RLock()
|
||||||
|
defer pc.mu.RUnlock()
|
||||||
|
|
||||||
|
// Check exact match
|
||||||
|
if val, ok := pc.perms[path]; ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any parent path is granted "session"
|
||||||
|
for cachedPath, val := range pc.perms {
|
||||||
|
if val == "session" && strings.HasPrefix(path, cachedPath) {
|
||||||
|
return "session"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *PermissionCache) Grant(path, duration string) {
|
||||||
|
pc.mu.Lock()
|
||||||
|
defer pc.mu.Unlock()
|
||||||
|
pc.perms[path] = duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *PermissionCache) Revoke(path string) {
|
||||||
|
pc.mu.Lock()
|
||||||
|
defer pc.mu.Unlock()
|
||||||
|
delete(pc.perms, path)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified ExecTool
|
||||||
|
|
||||||
|
```go
|
||||||
|
// In pkg/tools/shell.go
|
||||||
|
|
||||||
|
type ExecTool struct {
|
||||||
|
// ... existing fields ...
|
||||||
|
permissionCache *PermissionCache // NEW
|
||||||
|
askPermission bool // NEW: from config tools.exec.ask_permission
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
command, _ := args["command"].(string)
|
||||||
|
|
||||||
|
// Check if command accesses paths outside workspace
|
||||||
|
if t.askPermission && t.isOutsideWorkspace(command) {
|
||||||
|
path := t.extractPath(command)
|
||||||
|
|
||||||
|
// Check permission cache
|
||||||
|
if perm := t.permissionCache.Check(path); perm != "" {
|
||||||
|
if perm == "denied" {
|
||||||
|
return ErrorResult(fmt.Sprintf("Access to %s was denied", path))
|
||||||
|
}
|
||||||
|
// permission granted, proceed with execution
|
||||||
|
} else {
|
||||||
|
// No permission - return early, LLM will call request_permission
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf("Permission needed for path: %s. Call request_permission tool.", path),
|
||||||
|
ForUser: fmt.Sprintf("⚠️ Permission required to access %s", path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... existing execution logic ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### RequestPermissionTool
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/tools/permission.go
|
||||||
|
|
||||||
|
type RequestPermissionTool struct {
|
||||||
|
cache *PermissionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RequestPermissionTool) Name() string { return "request_permission" }
|
||||||
|
|
||||||
|
func (t *RequestPermissionTool) Description() string {
|
||||||
|
return "Request user permission for accessing paths outside workspace. Returns prompt for user."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RequestPermissionTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{"type": "string", "description": "Path that needs permission"},
|
||||||
|
"command": map[string]any{"type": "string", "description": "Original command (for context)"},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RequestPermissionTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
command, _ := args["command"].(string)
|
||||||
|
|
||||||
|
// Return structured prompt that LLM will show to user
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf("User permission needed for %s. Ask user: 'Allow once' or 'Allow for session'?", path),
|
||||||
|
ForUser: fmt.Sprintf("⚠️ **Permission Required**\n\nPicoClaw wants to execute: `%s`\nThis accesses `%s` which is outside your workspace.\n\n**How would you like to proceed?**\n- Type `once` for one-time access\n- Type `session` to allow all access to this path for this session\n- Type `no` to deny", command, path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config Changes
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/config/config.go
|
||||||
|
|
||||||
|
type ExecConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
EnableDenyPatterns bool `json:"enable_deny_patterns"`
|
||||||
|
CustomDenyPatterns []string `json:"custom_deny_patterns"`
|
||||||
|
AllowRemote bool `json:"allow_remote"`
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
|
AskPermission bool `json:"ask_permission"` // NEW
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default in pkg/config/defaults.go
|
||||||
|
Exec: ExecConfig{
|
||||||
|
Enabled: true,
|
||||||
|
EnableDenyPatterns: true,
|
||||||
|
AllowRemote: true,
|
||||||
|
TimeoutSeconds: 60,
|
||||||
|
AskPermission: true, // NEW: enable permission prompts by default
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| User denies permission | Return error, do not execute command |
|
||||||
|
| User says "once", then re-calls | Check: "once" permission is consumed after one use |
|
||||||
|
| User says "session" | Cache permission for duration of session |
|
||||||
|
| Cache full (unlikely) | Deny with error: "Too many permission entries" |
|
||||||
|
| Invalid path in permission | Ignore, treat as no permission |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
1. **Unit Tests** (`pkg/tools/permission_test.go`):
|
||||||
|
- Test PermissionCache.Check() with exact and parent path matches
|
||||||
|
- Test Grant/Revoke cycle
|
||||||
|
- Test "once" permission consumed after one use
|
||||||
|
|
||||||
|
2. **Integration Tests** (`pkg/tools/shell_test.go`):
|
||||||
|
- Test exec tool returns early when `ask_permission=true` and path outside workspace
|
||||||
|
- Test exec proceeds when permission in cache
|
||||||
|
- Test exec denied when permission is "denied"
|
||||||
|
|
||||||
|
3. **Manual Testing**:
|
||||||
|
- Start PicoClaw, try `exec ls /desktop`
|
||||||
|
- Verify LLM calls request_permission
|
||||||
|
- Verify user prompt appears
|
||||||
|
- Test "once" and "session" options
|
||||||
|
- Verify subsequent calls respect permission
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
- **Backward Compatibility**: `ask_permission` defaults to `true`, so existing users get the new behavior
|
||||||
|
- **Config Upgrade**: No manual migration needed - new field with safe default
|
||||||
|
- **No Breaking Changes**: Exec tool still works the same way when `ask_permission=false`
|
||||||
|
|
||||||
|
## Failure Mode Check
|
||||||
|
|
||||||
|
### Failure Mode 1: LLM doesn't call request_permission after exec returns early
|
||||||
|
**Severity**: Critical
|
||||||
|
**Scenario**: Exec returns "permission needed", but LLM doesn't call request_permission tool
|
||||||
|
**Impact**: User sees confusing message, command doesn't execute
|
||||||
|
**Mitigation**:
|
||||||
|
- Ensure exec returns clear instructions in `ForLLM` field: "Call request_permission tool with path=X"
|
||||||
|
- Add examples in tool description for LLM
|
||||||
|
- Test with multiple LLM providers
|
||||||
|
|
||||||
|
### Failure Mode 2: Permission cache never cleaned up
|
||||||
|
**Severity**: Minor
|
||||||
|
**Scenario**: Session has thousands of cached permissions
|
||||||
|
**Impact**: Memory growth (very slow)
|
||||||
|
**Mitigation**:
|
||||||
|
- PermissionCache is per-session, cleaned up when session ends
|
||||||
|
- "once" permissions could be auto-consumed
|
||||||
|
- For now, assume sessions are short-lived (no cleanup needed)
|
||||||
|
|
||||||
|
### Failure Mode 3: User says "session" but path is /desktop/subfolder
|
||||||
|
**Severity**: Minor
|
||||||
|
**Scenario**: Permission granted for /desktop, but command accesses /desktop/subfolder
|
||||||
|
**Impact**: Need to check parent paths in PermissionCache.Check()
|
||||||
|
**Mitigation**: Implemented in Check() - scans for parent paths with "session" permission
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `pkg/tools/permission.go` | NEW: PermissionCache + RequestPermissionTool |
|
||||||
|
| `pkg/tools/permission_test.go` | NEW: Unit tests |
|
||||||
|
| `pkg/tools/shell.go` | Modify ExecTool to check permissions |
|
||||||
|
| `pkg/tools/registry.go` | Register RequestPermissionTool |
|
||||||
|
| `pkg/config/config.go` | Add AskPermission to ExecConfig |
|
||||||
|
| `pkg/config/defaults.go` | Add default for AskPermission |
|
||||||
|
| `pkg/agent/instance.go` | Wire up PermissionCache, register tool |
|
||||||
|
|
||||||
|
## User Review Gate
|
||||||
|
|
||||||
|
Spec written to `docs/superpowers-optimized/specs/2026-05-05-exec-permission-design.md`.
|
||||||
|
|
||||||
|
Please review it and let me know if you want any changes before I write the implementation plan.
|
||||||
|
|
||||||
|
**Key Decisions to Confirm**:
|
||||||
|
1. New `request_permission` tool (as per your answer)
|
||||||
|
2. Session-based or one-time permission (user chooses at runtime)
|
||||||
|
3. Scope: any path outside workspace (as per your answer)
|
||||||
|
4. Recommended approach: Approach 1 (separate tool + cache)
|
||||||
Loading…
Add table
Reference in a new issue