-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathShoferIgnoreController.ts
More file actions
214 lines (193 loc) · 6.07 KB
/
Copy pathShoferIgnoreController.ts
File metadata and controls
214 lines (193 loc) · 6.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
import path from "path"
import { fileExistsAtPath } from "../fs/fs.js"
import fs from "fs/promises"
import fsSync from "fs"
import ignore, { Ignore } from "ignore"
import type { HostDisposable } from "@shofer/types"
import { webviewLog } from "../logging/subsystems.js"
import { getHost } from "@shofer/types"
export const LOCK_TEXT_SYMBOL = "\u{1F512}"
/**
* Controls LLM access to files by enforcing ignore patterns.
* Designed to be instantiated once in Shofer.ts and passed to file manipulation services.
* Uses the 'ignore' library to support standard .gitignore syntax in shoferignore files.
*/
export class ShoferIgnoreController {
private cwd: string
private ignoreInstance: Ignore
private disposables: HostDisposable[] = []
shoferIgnoreContent: string | undefined
constructor(cwd: string) {
this.cwd = cwd
this.ignoreInstance = ignore()
this.shoferIgnoreContent = undefined
// Set up file watcher for shoferignore
this.setupFileWatcher()
}
/**
* Initialize the controller by loading custom patterns
* Must be called after construction and before using the controller
*/
async initialize(): Promise<void> {
await this.loadShoferIgnore()
}
/**
* Set up the file watcher for shoferignore changes
*/
private setupFileWatcher(): void {
const fileWatcher = getHost().watcher.watch(path.join(this.cwd, ".shofer"), "shoferignore")
// Watch for changes and updates
this.disposables.push(
fileWatcher.onChange(() => {
this.loadShoferIgnore()
}),
fileWatcher.onCreate(() => {
this.loadShoferIgnore()
}),
fileWatcher.onDelete(() => {
this.loadShoferIgnore()
}),
)
// Add fileWatcher itself to disposables
this.disposables.push(fileWatcher)
}
/**
* Load custom patterns from shoferignore if it exists
*/
private async loadShoferIgnore(): Promise<void> {
try {
// Reset ignore instance to prevent duplicate patterns
this.ignoreInstance = ignore()
const ignorePath = path.join(this.cwd, ".shofer", "shoferignore")
if (await fileExistsAtPath(ignorePath)) {
const content = await fs.readFile(ignorePath, "utf8")
this.shoferIgnoreContent = content
this.ignoreInstance.add(content)
this.ignoreInstance.add(".shofer/shoferignore")
} else {
this.shoferIgnoreContent = undefined
}
} catch (error) {
// Should never happen: reading file failed even though it exists
webviewLog.error("Unexpected error loading shoferignore:", error)
}
}
/**
* Check if a file should be accessible to the LLM
* Automatically resolves symlinks
* @param filePath - Path to check (relative to cwd)
* @returns true if file is accessible, false if ignored
*/
validateAccess(filePath: string): boolean {
// Always allow access if shoferignore does not exist
if (!this.shoferIgnoreContent) {
return true
}
try {
const absolutePath = path.resolve(this.cwd, filePath)
// Follow symlinks to get the real path
let realPath: string
try {
realPath = fsSync.realpathSync(absolutePath)
} catch {
// If realpath fails (file doesn't exist, broken symlink, etc.),
// use the original path
realPath = absolutePath
}
// Convert real path to relative for shoferignore checking
const relativePath = path.relative(this.cwd, realPath).toPosix()
// Check if the real path is ignored
return !this.ignoreInstance.ignores(relativePath)
} catch {
// Allow access to files outside cwd or on errors (backward compatibility)
return true
}
}
/**
* Check if a terminal command should be allowed to execute based on file access patterns
* @param command - Terminal command to validate
* @returns path of file that is being accessed if it is being accessed, undefined if command is allowed
*/
validateCommand(command: string): string | undefined {
// Always allow if no shoferignore exists
if (!this.shoferIgnoreContent) {
return undefined
}
// Split command into parts and get the base command
const parts = command.trim().split(/\s+/)
const baseCommand = parts[0]!.toLowerCase()
// Commands that read file contents
const fileReadingCommands = [
// Unix commands
"cat",
"less",
"more",
"head",
"tail",
"grep",
"awk",
"sed",
// PowerShell commands and aliases
"get-content",
"gc",
"type",
"select-string",
"sls",
]
if (fileReadingCommands.includes(baseCommand)) {
// Check each argument that could be a file path
for (let i = 1; i < parts.length; i++) {
const arg = parts[i]!
// Skip command flags/options (both Unix and PowerShell style)
if (arg.startsWith("-") || arg.startsWith("/")) {
continue
}
// Ignore PowerShell parameter names
if (arg.includes(":")) {
continue
}
// Validate file access
if (!this.validateAccess(arg)) {
return arg
}
}
}
return undefined
}
/**
* Filter an array of paths, removing those that should be ignored
* @param paths - Array of paths to filter (relative to cwd)
* @returns Array of allowed paths
*/
filterPaths(paths: string[]): string[] {
try {
return paths
.map((p) => ({
path: p,
allowed: this.validateAccess(p),
}))
.filter((x) => x.allowed)
.map((x) => x.path)
} catch (error) {
webviewLog.error("Error filtering paths:", error)
return [] // Fail closed for security
}
}
/**
* Clean up resources when the controller is no longer needed
*/
dispose(): void {
this.disposables.forEach((d) => d.dispose())
this.disposables = []
}
/**
* Get formatted instructions about the shoferignore file for the LLM
* @returns Formatted instructions or undefined if shoferignore doesn't exist
*/
getInstructions(): string | undefined {
if (!this.shoferIgnoreContent) {
return undefined
}
return `# .shoferignore\n\n(The following is provided by a .shofer/shoferignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${this.shoferIgnoreContent}\n.shofer/shoferignore`
}
}