on main help sign in
Merge pull request #328 from charmbracelet/cwd-permissions

chore: improve permissions

commitd931f57ecf9c8ed4a893bcb270df739cbfbcff44 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 29 Jul 2025, 16:27 (1 year ago) parentdbffab80c1b71c
11 files changed+255−62
internal/fsext/expand.go+29 internal/fsext/ls.go+1−1 internal/llm/agent/agent.go+2−2 internal/llm/prompt/coder.go+2−6 internal/llm/tools/edit.go+3−18 internal/llm/tools/ls.go+76−11 internal/llm/tools/multiedit.go+2−12 internal/llm/tools/view.go+51−5 internal/llm/tools/write.go+1−6 internal/permission/permission.go+11−1 internal/tui/components/dialogs/permissions/permissions.go+77
internal/fsext/expand.gonew file+29
@@ -0,0 +1,29 @@
1+package fsext
2+
3+import (
4+ "os"
5+ "strings"
6+
7+ "mvdan.cc/sh/v3/expand"
8+ "mvdan.cc/sh/v3/syntax"
9+)
10+
11+// Expand is a wrapper around [expand.Literal]. It will escape the input
12+// string, expand any shell symbols (such as '~') and resolve any environment
13+// variables.
14+func Expand(s string) (string, error) {
15+ if s == "" {
16+ return "", nil
17+ }
18+ p := syntax.NewParser()
19+ word, err := p.Document(strings.NewReader(s))
20+ if err != nil {
21+ return "", err
22+ }
23+ cfg := &expand.Config{
24+ Env: expand.FuncEnviron(os.Getenv),
25+ ReadDir2: os.ReadDir,
26+ GlobStar: true,
27+ }
28+ return expand.Literal(cfg, word)
29+}
internal/fsext/ls.go+1−1
@@ -156,7 +156,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st
156156
157157 return nil
158158 })
159− if err != nil {
159+ if err != nil && len(results) == 0 {
160160 return nil, truncated, err
161161 }
162162
internal/llm/agent/agent.go+2−2
@@ -181,9 +181,9 @@ func NewAgent(
181181 tools.NewFetchTool(permissions, cwd),
182182 tools.NewGlobTool(cwd),
183183 tools.NewGrepTool(cwd),
184− tools.NewLsTool(cwd),
184+ tools.NewLsTool(permissions, cwd),
185185 tools.NewSourcegraphTool(),
186− tools.NewViewTool(lspClients, cwd),
186+ tools.NewViewTool(lspClients, permissions, cwd),
187187 tools.NewWriteTool(lspClients, permissions, history, cwd),
188188 }
189189
internal/llm/prompt/coder.go+2−6
@@ -1,7 +1,6 @@
11package prompt
22
33import (
4− "context"
54 _ "embed"
65 "fmt"
76 "log/slog"
@@ -38,10 +37,7 @@ func getEnvironmentInfo() string {
3837 isGit := isGitRepo(cwd)
3938 platform := runtime.GOOS
4039 date := time.Now().Format("1/2/2006")
41− ls := tools.NewLsTool(cwd)
42− r, _ := ls.Run(context.Background(), tools.ToolCall{
43− Input: `{"path":"."}`,
44− })
40+ output, _ := tools.ListDirectoryTree(cwd, nil)
4541 return fmt.Sprintf(`Here is useful information about the environment you are running in:
4642<env>
4743Working directory: %s
@@ -52,7 +48,7 @@ Today's date: %s
5248<project>
5349%s
5450</project>
55− `, cwd, boolToYesNo(isGit), platform, date, r.Content)
51+ `, cwd, boolToYesNo(isGit), platform, date, output)
5652}
5753
5854func isGitRepo(dir string) bool {
internal/llm/tools/edit.go+3−18
@@ -215,15 +215,10 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string,
215215 content,
216216 strings.TrimPrefix(filePath, e.workingDir),
217217 )
218− rootDir := e.workingDir
219− permissionPath := filepath.Dir(filePath)
220− if strings.HasPrefix(filePath, rootDir) {
221− permissionPath = rootDir
222− }
223218 p := e.permissions.Request(
224219 permission.CreatePermissionRequest{
225220 SessionID: sessionID,
226− Path: permissionPath,
221+ Path: filePath,
227222 ToolCallID: call.ID,
228223 ToolName: EditToolName,
229224 Action: "write",
@@ -341,15 +336,10 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
341336 strings.TrimPrefix(filePath, e.workingDir),
342337 )
343338
344− rootDir := e.workingDir
345− permissionPath := filepath.Dir(filePath)
346− if strings.HasPrefix(filePath, rootDir) {
347− permissionPath = rootDir
348− }
349339 p := e.permissions.Request(
350340 permission.CreatePermissionRequest{
351341 SessionID: sessionID,
352− Path: permissionPath,
342+ Path: filePath,
353343 ToolCallID: call.ID,
354344 ToolName: EditToolName,
355345 Action: "write",
@@ -476,15 +466,10 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
476466 newContent,
477467 strings.TrimPrefix(filePath, e.workingDir),
478468 )
479− rootDir := e.workingDir
480− permissionPath := filepath.Dir(filePath)
481− if strings.HasPrefix(filePath, rootDir) {
482− permissionPath = rootDir
483− }
484469 p := e.permissions.Request(
485470 permission.CreatePermissionRequest{
486471 SessionID: sessionID,
487− Path: permissionPath,
472+ Path: filePath,
488473 ToolCallID: call.ID,
489474 ToolName: EditToolName,
490475 Action: "write",
internal/llm/tools/ls.go+76−11
@@ -9,6 +9,7 @@ import (
99 "strings"
1010
1111 "github.com/charmbracelet/crush/internal/fsext"
12+ "github.com/charmbracelet/crush/internal/permission"
1213)
1314
1415type LSParams struct {
@@ -16,6 +17,11 @@ type LSParams struct {
1617 Ignore []string `json:"ignore"`
1718}
1819
20+type LSPermissionsParams struct {
21+ Path string `json:"path"`
22+ Ignore []string `json:"ignore"`
23+}
24+
1925type TreeNode struct {
2026 Name string `json:"name"`
2127 Path string `json:"path"`
@@ -29,7 +35,8 @@ type LSResponseMetadata struct {
2935}
3036
3137type lsTool struct {
32− workingDir string
38+ workingDir string
39+ permissions permission.Service
3340}
3441
3542const (
@@ -71,9 +78,10 @@ TIPS:
7178- Combine with other tools for more effective exploration`
7279)
7380
74−func NewLsTool(workingDir string) BaseTool {
81+func NewLsTool(permissions permission.Service, workingDir string) BaseTool {
7582 return &lsTool{
76− workingDir: workingDir,
83+ workingDir: workingDir,
84+ permissions: permissions,
7785 }
7886}
7987
@@ -113,24 +121,61 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
113121 searchPath = l.workingDir
114122 }
115123
124+ var err error
125+ searchPath, err = fsext.Expand(searchPath)
126+ if err != nil {
127+ return ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
128+ }
129+
116130 if !filepath.IsAbs(searchPath) {
117131 searchPath = filepath.Join(l.workingDir, searchPath)
118132 }
119133
120− if _, err := os.Stat(searchPath); os.IsNotExist(err) {
121− return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
134+ // Check if directory is outside working directory and request permission if needed
135+ absWorkingDir, err := filepath.Abs(l.workingDir)
136+ if err != nil {
137+ return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
122138 }
123139
124− files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
140+ absSearchPath, err := filepath.Abs(searchPath)
125141 if err != nil {
126− return ToolResponse{}, fmt.Errorf("error listing directory: %w", err)
142+ return ToolResponse{}, fmt.Errorf("error resolving search path: %w", err)
127143 }
128144
129− tree := createFileTree(files)
130− output := printTree(tree, searchPath)
145+ relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
146+ if err != nil || strings.HasPrefix(relPath, "..") {
147+ // Directory is outside working directory, request permission
148+ sessionID, messageID := GetContextValues(ctx)
149+ if sessionID == "" || messageID == "" {
150+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing directories outside working directory")
151+ }
131152
132− if truncated {
133− output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
153+ granted := l.permissions.Request(
154+ permission.CreatePermissionRequest{
155+ SessionID: sessionID,
156+ Path: absSearchPath,
157+ ToolCallID: call.ID,
158+ ToolName: LSToolName,
159+ Action: "list",
160+ Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
161+ Params: LSPermissionsParams(params),
162+ },
163+ )
164+
165+ if !granted {
166+ return ToolResponse{}, permission.ErrorPermissionDenied
167+ }
168+ }
169+
170+ output, err := ListDirectoryTree(searchPath, params.Ignore)
171+ if err != nil {
172+ return ToolResponse{}, err
173+ }
174+
175+ // Get file count for metadata
176+ files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
177+ if err != nil {
178+ return ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
134179 }
135180
136181 return WithResponseMetadata(
@@ -142,6 +187,26 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
142187 ), nil
143188}
144189
190+func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
191+ if _, err := os.Stat(searchPath); os.IsNotExist(err) {
192+ return "", fmt.Errorf("path does not exist: %s", searchPath)
193+ }
194+
195+ files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
196+ if err != nil {
197+ return "", fmt.Errorf("error listing directory: %w", err)
198+ }
199+
200+ tree := createFileTree(files)
201+ output := printTree(tree, searchPath)
202+
203+ if truncated {
204+ output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
205+ }
206+
207+ return output, nil
208+}
209+
145210func createFileTree(sortedPaths []string) []*TreeNode {
146211 root := []*TreeNode{}
147212 pathMap := make(map[string]*TreeNode)
internal/llm/tools/multiedit.go+2−12
@@ -249,15 +249,10 @@ func (m *multiEditTool) processMultiEditWithCreation(ctx context.Context, params
249249
250250 // Check permissions
251251 _, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
252− rootDir := m.workingDir
253− permissionPath := filepath.Dir(params.FilePath)
254− if strings.HasPrefix(params.FilePath, rootDir) {
255− permissionPath = rootDir
256− }
257252
258253 p := m.permissions.Request(permission.CreatePermissionRequest{
259254 SessionID: sessionID,
260− Path: permissionPath,
255+ Path: params.FilePath,
261256 ToolCallID: call.ID,
262257 ToolName: MultiEditToolName,
263258 Action: "write",
@@ -364,15 +359,10 @@ func (m *multiEditTool) processMultiEditExistingFile(ctx context.Context, params
364359
365360 // Generate diff and check permissions
366361 _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
367− rootDir := m.workingDir
368− permissionPath := filepath.Dir(params.FilePath)
369− if strings.HasPrefix(params.FilePath, rootDir) {
370− permissionPath = rootDir
371− }
372362
373363 p := m.permissions.Request(permission.CreatePermissionRequest{
374364 SessionID: sessionID,
375− Path: permissionPath,
365+ Path: params.FilePath,
376366 ToolCallID: call.ID,
377367 ToolName: MultiEditToolName,
378368 Action: "write",
internal/llm/tools/view.go+51−5
@@ -12,6 +12,7 @@ import (
1212 "unicode/utf8"
1313
1414 "github.com/charmbracelet/crush/internal/lsp"
15+ "github.com/charmbracelet/crush/internal/permission"
1516)
1617
1718type ViewParams struct {
@@ -20,9 +21,16 @@ type ViewParams struct {
2021 Limit int `json:"limit"`
2122}
2223
24+type ViewPermissionsParams struct {
25+ FilePath string `json:"file_path"`
26+ Offset int `json:"offset"`
27+ Limit int `json:"limit"`
28+}
29+
2330type viewTool struct {
24− lspClients map[string]*lsp.Client
25− workingDir string
31+ lspClients map[string]*lsp.Client
32+ workingDir string
33+ permissions permission.Service
2634}
2735
2836type ViewResponseMetadata struct {
@@ -46,6 +54,7 @@ HOW TO USE:
4654- Provide the path to the file you want to view
4755- Optionally specify an offset to start reading from a specific line
4856- Optionally specify a limit to control how many lines are read
57+- Do not use this for directories use the ls tool instead
4958
5059FEATURES:
5160- Displays file contents with line numbers for easy reference
@@ -72,10 +81,11 @@ TIPS:
7281- When viewing large files, use the offset parameter to read specific sections`
7382)
7483
75−func NewViewTool(lspClients map[string]*lsp.Client, workingDir string) BaseTool {
84+func NewViewTool(lspClients map[string]*lsp.Client, permissions permission.Service, workingDir string) BaseTool {
7685 return &viewTool{
77− lspClients: lspClients,
78− workingDir: workingDir,
86+ lspClients: lspClients,
87+ workingDir: workingDir,
88+ permissions: permissions,
7989 }
8090}
8191
@@ -122,6 +132,42 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
122132 filePath = filepath.Join(v.workingDir, filePath)
123133 }
124134
135+ // Check if file is outside working directory and request permission if needed
136+ absWorkingDir, err := filepath.Abs(v.workingDir)
137+ if err != nil {
138+ return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
139+ }
140+
141+ absFilePath, err := filepath.Abs(filePath)
142+ if err != nil {
143+ return ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
144+ }
145+
146+ relPath, err := filepath.Rel(absWorkingDir, absFilePath)
147+ if err != nil || strings.HasPrefix(relPath, "..") {
148+ // File is outside working directory, request permission
149+ sessionID, messageID := GetContextValues(ctx)
150+ if sessionID == "" || messageID == "" {
151+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing files outside working directory")
152+ }
153+
154+ granted := v.permissions.Request(
155+ permission.CreatePermissionRequest{
156+ SessionID: sessionID,
157+ Path: absFilePath,
158+ ToolCallID: call.ID,
159+ ToolName: ViewToolName,
160+ Action: "read",
161+ Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
162+ Params: ViewPermissionsParams(params),
163+ },
164+ )
165+
166+ if !granted {
167+ return ToolResponse{}, permission.ErrorPermissionDenied
168+ }
169+ }
170+
125171 // Check if file exists
126172 fileInfo, err := os.Stat(filePath)
127173 if err != nil {
internal/llm/tools/write.go+1−6
@@ -172,15 +172,10 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
172172 strings.TrimPrefix(filePath, w.workingDir),
173173 )
174174
175− rootDir := w.workingDir
176− permissionPath := filepath.Dir(filePath)
177− if strings.HasPrefix(filePath, rootDir) {
178− permissionPath = rootDir
179− }
180175 p := w.permissions.Request(
181176 permission.CreatePermissionRequest{
182177 SessionID: sessionID,
183− Path: permissionPath,
178+ Path: filePath,
184179 ToolCallID: call.ID,
185180 ToolName: WriteToolName,
186181 Action: "write",
internal/permission/permission.go+11−1
@@ -3,6 +3,7 @@ package permission
33import (
44 "context"
55 "errors"
6+ "os"
67 "path/filepath"
78 "slices"
89 "sync"
@@ -145,7 +146,16 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
145146 return true
146147 }
147148
148− dir := filepath.Dir(opts.Path)
149+ fileInfo, err := os.Stat(opts.Path)
150+ dir := opts.Path
151+ if err == nil {
152+ if fileInfo.IsDir() {
153+ dir = opts.Path
154+ } else {
155+ dir = filepath.Dir(opts.Path)
156+ }
157+ }
158+
149159 if dir == "." {
150160 dir = s.workingDir
151161 }
internal/tui/components/dialogs/permissions/permissions.go+77
@@ -321,6 +321,34 @@ func (p *permissionDialogCmp) renderHeader() string {
321321 )
322322 case tools.FetchToolName:
323323 headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
324+ case tools.ViewToolName:
325+ params := p.permission.Params.(tools.ViewPermissionsParams)
326+ fileKey := t.S().Muted.Render("File")
327+ filePath := t.S().Text.
328+ Width(p.width - lipgloss.Width(fileKey)).
329+ Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
330+ headerParts = append(headerParts,
331+ lipgloss.JoinHorizontal(
332+ lipgloss.Left,
333+ fileKey,
334+ filePath,
335+ ),
336+ baseStyle.Render(strings.Repeat(" ", p.width)),
337+ )
338+ case tools.LSToolName:
339+ params := p.permission.Params.(tools.LSPermissionsParams)
340+ pathKey := t.S().Muted.Render("Directory")
341+ pathValue := t.S().Text.
342+ Width(p.width - lipgloss.Width(pathKey)).
343+ Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.Path)))
344+ headerParts = append(headerParts,
345+ lipgloss.JoinHorizontal(
346+ lipgloss.Left,
347+ pathKey,
348+ pathValue,
349+ ),
350+ baseStyle.Render(strings.Repeat(" ", p.width)),
351+ )
324352 }
325353
326354 return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
@@ -347,6 +375,10 @@ func (p *permissionDialogCmp) getOrGenerateContent() string {
347375 content = p.generateMultiEditContent()
348376 case tools.FetchToolName:
349377 content = p.generateFetchContent()
378+ case tools.ViewToolName:
379+ content = p.generateViewContent()
380+ case tools.LSToolName:
381+ content = p.generateLSContent()
350382 default:
351383 content = p.generateDefaultContent()
352384 }
@@ -486,6 +518,45 @@ func (p *permissionDialogCmp) generateFetchContent() string {
486518 return ""
487519}
488520
521+func (p *permissionDialogCmp) generateViewContent() string {
522+ t := styles.CurrentTheme()
523+ baseStyle := t.S().Base.Background(t.BgSubtle)
524+ if pr, ok := p.permission.Params.(tools.ViewPermissionsParams); ok {
525+ content := fmt.Sprintf("File: %s", fsext.PrettyPath(pr.FilePath))
526+ if pr.Offset > 0 {
527+ content += fmt.Sprintf("\nStarting from line: %d", pr.Offset+1)
528+ }
529+ if pr.Limit > 0 && pr.Limit != 2000 { // 2000 is the default limit
530+ content += fmt.Sprintf("\nLines to read: %d", pr.Limit)
531+ }
532+
533+ finalContent := baseStyle.
534+ Padding(1, 2).
535+ Width(p.contentViewPort.Width()).
536+ Render(content)
537+ return finalContent
538+ }
539+ return ""
540+}
541+
542+func (p *permissionDialogCmp) generateLSContent() string {
543+ t := styles.CurrentTheme()
544+ baseStyle := t.S().Base.Background(t.BgSubtle)
545+ if pr, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
546+ content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(pr.Path))
547+ if len(pr.Ignore) > 0 {
548+ content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(pr.Ignore, ", "))
549+ }
550+
551+ finalContent := baseStyle.
552+ Padding(1, 2).
553+ Width(p.contentViewPort.Width()).
554+ Render(content)
555+ return finalContent
556+ }
557+ return ""
558+}
559+
489560func (p *permissionDialogCmp) generateDefaultContent() string {
490561 t := styles.CurrentTheme()
491562 baseStyle := t.S().Base.Background(t.BgSubtle)
@@ -623,6 +694,12 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd {
623694 case tools.FetchToolName:
624695 p.width = int(float64(p.wWidth) * 0.8)
625696 p.height = int(float64(p.wHeight) * 0.3)
697+ case tools.ViewToolName:
698+ p.width = int(float64(p.wWidth) * 0.8)
699+ p.height = int(float64(p.wHeight) * 0.4)
700+ case tools.LSToolName:
701+ p.width = int(float64(p.wWidth) * 0.8)
702+ p.height = int(float64(p.wHeight) * 0.4)
626703 default:
627704 p.width = int(float64(p.wWidth) * 0.7)
628705 p.height = int(float64(p.wHeight) * 0.5)