返回 DeepSeek-Reasonix
bash_write.go
根目录 / internal / tool / builtin / bash_write.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "regexp"
10 "strings"
11 "time"
12
13 "reasonix/internal/permissionpreset"
14 "reasonix/internal/sandbox"
15 "reasonix/internal/tool"
16 )
17
18 func (b bash) Schema() json.RawMessage {
19 if b.resolved().Kind == sandbox.ShellPowerShell {
20 return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"PowerShell command to execute"},"description":{"type":"string","description":"Clear 5-10 word active-voice description shown in the UI"},"timeout_ms":{"type":"integer","minimum":1,"description":"Optional foreground timeout in milliseconds, capped by the configured shell timeout"},"run_in_background":{"type":"boolean","description":"Run without a foreground timeout and return a pwsh job id immediately. Read it with job_output or stop it with job_kill."},"additional_write_dirs":{"type":"array","items":{"type":"string"},"description":"Directories this command must write outside the workspace. Directories only, no globs. Accepts absolute paths, workspace-relative paths, ~, and ${HOME}. Request the smallest set needed; the host will not infer paths from the command text."},"sandbox_permissions":{"type":"string","enum":["workspace-write","danger-full-access"],"description":"Optional per-call permission escalation. Use workspace-write for an authorized write while the session is read-only. danger-full-access is accepted only after a host-recorded denial and explicit authorization."},"justification":{"type":"string","description":"Required when additional_write_dirs or sandbox_permissions is set. Explain why the access is needed."},"denial_id":{"type":"string","description":"Host-issued denial identifier required when retrying with danger-full-access."}},"required":["command","description"]}`)
21 }
22 return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"Shell command to execute"},"timeout_ms":{"type":"integer","minimum":1,"description":"Optional foreground timeout in milliseconds, capped by the configured shell timeout"},"run_in_background":{"type":"boolean","description":"Run detached: returns a job id immediately and keeps running across turns (no foreground timeout). Read it with job_output or stop it with job_kill."},"preserve_background_processes":{"type":"boolean","description":"After the shell command exits normally, keep any process-group members it intentionally left behind. Use only for deliberate daemonization, browser/GUI/session launchers such as playwright-cli open, or nohup/disown/setsid; cancellation and timeouts still kill the process group."},"additional_write_dirs":{"type":"array","items":{"type":"string"},"description":"Directories this command must write outside the workspace. Directories only, no globs. Accepts absolute paths, workspace-relative paths, ~, and ${HOME}. Request the smallest set needed; the host will not infer paths from the command text."},"sandbox_permissions":{"type":"string","enum":["workspace-write","danger-full-access"],"description":"Optional per-call permission escalation. Use workspace-write for an authorized write while the session is read-only. danger-full-access is accepted only after a host-recorded denial and explicit authorization."},"justification":{"type":"string","description":"Required when additional_write_dirs or sandbox_permissions is set. Explain why the access is needed."},"denial_id":{"type":"string","description":"Host-issued denial identifier required when retrying with danger-full-access."}},"required":["command"]}`)
23 }
24
25 func (b bash) DeclareWriteAccess(args json.RawMessage) (tool.WriteAccessDeclaration, error) {
26 var p bashParams
27 if err := json.Unmarshal(args, &p); err != nil {
28 return tool.WriteAccessDeclaration{}, fmt.Errorf("invalid args: %w", err)
29 }
30 if err := validateBashWriteDirs(p); err != nil {
31 return tool.WriteAccessDeclaration{}, err
32 }
33 return tool.WriteAccessDeclaration{
34 Directories: append([]string(nil), p.AdditionalWriteDirs...),
35 Justification: strings.TrimSpace(p.Justification),
36 RequestedPreset: strings.TrimSpace(p.SandboxPermissions),
37 DenialID: strings.TrimSpace(p.DenialID),
38 }, nil
39 }
40
41 func validateBashWriteDirs(p bashParams) error {
42 preset := strings.TrimSpace(p.SandboxPermissions)
43 if preset != "" && preset != string(permissionpreset.WorkspaceWrite) && preset != string(permissionpreset.DangerFullAccess) {
44 return fmt.Errorf("sandbox_permissions must be workspace-write or danger-full-access")
45 }
46 if preset != "" && strings.TrimSpace(p.Justification) == "" {
47 return fmt.Errorf("justification is required when sandbox_permissions is set")
48 }
49 if preset == string(permissionpreset.DangerFullAccess) && strings.TrimSpace(p.DenialID) == "" {
50 return fmt.Errorf("denial_id is required when sandbox_permissions is danger-full-access")
51 }
52 if len(p.AdditionalWriteDirs) == 0 {
53 return nil
54 }
55 if strings.TrimSpace(p.Justification) == "" {
56 return fmt.Errorf("justification is required when additional_write_dirs is set")
57 }
58 for _, dir := range p.AdditionalWriteDirs {
59 dir = strings.TrimSpace(dir)
60 if dir == "" {
61 return fmt.Errorf("additional_write_dirs entries must be non-empty directories")
62 }
63 if strings.ContainsAny(dir, "*?[") {
64 return fmt.Errorf("additional_write_dirs %q must be a concrete directory, not a glob", dir)
65 }
66 }
67 return nil
68 }
69
70 func validateBashParams(p bashParams) error {
71 if p.Command == "" {
72 return fmt.Errorf("command is required")
73 }
74 if p.TimeoutMS < 0 {
75 return fmt.Errorf("timeout_ms must be positive")
76 }
77 return validateBashWriteDirs(p)
78 }
79
80 func bashPreflightFailure(ex *tool.ShellExecution, start time.Time, err error) (tool.DetailedResult, error) {
81 ex.State = tool.ShellStateNotRun
82 ex.FailurePhase = tool.ShellPhasePreflight
83 ex.MutationRisk = tool.ShellMutationNotStarted
84 ex.DurationMs = time.Since(start).Milliseconds()
85 return tool.DetailedResult{Execution: ex}, err
86 }
87
88 func bashLaunchFailure(ex *tool.ShellExecution, start time.Time, err error) (tool.DetailedResult, error) {
89 ex.State = tool.ShellStateNotRun
90 // prepareLaunch has not entered the native runner yet. Its failures are
91 // missing host dependencies (sandbox backend or session temp), not ACL/token
92 // authorization and not a child-process launch.
93 ex.FailurePhase = tool.ShellPhaseDependency
94 ex.MutationRisk = tool.ShellMutationNotStarted
95 ex.DurationMs = time.Since(start).Milliseconds()
96 return tool.DetailedResult{Execution: ex}, err
97 }
98
99 func (b bash) appendWriteHints(ctx context.Context, out string, err error, p bashParams, wrapped bool) string {
100 out = appendSessionDataHint(out, b.guard.CommandHint(b.workDir, p.Command))
101 if wrapped {
102 out = appendSandboxWriteHint(out, err, p, b.specForCall(ctx), string(sandbox.PermissionPresetFrom(ctx)), b.workDir)
103 }
104 return out
105 }
106
107 func (b bash) specForCall(ctx context.Context) sandbox.Spec {
108 spec := b.sb
109 preset := sandbox.PermissionPresetFrom(ctx)
110 switch preset {
111 case permissionpreset.ReadOnly:
112 spec.Mode = "enforce"
113 spec.ReadOnly = true
114 spec.WriteRoots = nil
115 spec.MinimalWrites = true
116 case permissionpreset.WorkspaceWrite:
117 // Permission presets own the enforcement decision. A legacy
118 // [sandbox].bash="off" cannot silently turn workspace access into an
119 // unconfined shell.
120 spec.Mode = "enforce"
121 spec.ReadOnly = false
122 spec.MinimalWrites = true
123 if len(spec.WriteRoots) == 0 && strings.TrimSpace(b.workDir) != "" {
124 spec.WriteRoots = []string{b.workDir}
125 }
126 case permissionpreset.DangerFullAccess:
127 spec.Mode = "off"
128 spec.ReadOnly = false
129 }
130 // Windows has no OS-level shell sandbox: demanding one made every
131 // restricted-preset shell call fail closed (#10292). Presets stay tool-layer
132 // boundaries there and bash runs as the OS user after the approval gate.
133 if !sandbox.OSSandboxSupported() {
134 spec.Mode = "off"
135 }
136 if preset == permissionpreset.WorkspaceWrite {
137 if b.rootSet != nil {
138 spec.WriteRoots = b.rootSet.EffectiveSandboxRoots(ctx)
139 } else if extra := sandbox.PerCallWriteRoots(ctx); len(extra) > 0 {
140 spec.WriteRoots = sandbox.CollapseWriteRoots(append(append([]string{}, spec.WriteRoots...), extra...))
141 }
142 }
143 if spec.ProtectedWriteRoots == nil && b.guard.stateRoot != "" {
144 spec.ProtectedWriteRoots = sandbox.ProtectedWriteRoots(b.guard.stateRoot)
145 }
146 return spec
147 }
148
149 func bashWriteDeniedHint() string {
150 return "The OS sandbox blocked a write outside the approved writable roots. Retry the same command with structured additional_write_dirs naming the exact directories (no globs), plus a justification. Example: {\"command\":\"mkdir -p ~/.local/bin && cp tool ~/.local/bin/tool\",\"additional_write_dirs\":[\"~/.local\"],\"justification\":\"install the user-requested local command\"}. Do not retry unconfined and do not omit the directories."
151 }
152
153 func looksLikeSandboxWriteDenial(out string, err error) bool {
154 if err == nil {
155 return false
156 }
157 msg := strings.ToLower(out)
158 if err != nil {
159 msg += "\n" + strings.ToLower(err.Error())
160 }
161 for _, needle := range []string{
162 "operation not permitted",
163 "read-only file system",
164 "erofs",
165 "access is denied",
166 "permissionerror: [errno 13] permission denied",
167 } {
168 if strings.Contains(msg, needle) {
169 return true
170 }
171 }
172 // A bare "permission denied" can be an HTTP response or application-level
173 // error. Accept it only in the standard local filesystem diagnostic shape
174 // emitted by shells and file utilities.
175 return localFilePermissionDenied.MatchString(msg) || windowsChildProcessDenied.MatchString(msg)
176 }
177
178 var localFilePermissionDenied = regexp.MustCompile(`(?m)^(?:bash|zsh|sh|dash|fish|mkdir|touch|cp|mv|rm|ln|install|tee|cat|chmod|chown):[^\n]*permission denied\b`)
179 var windowsChildProcessDenied = regexp.MustCompile(`\b(?:spawn(?:sync)?|exec(?:file|sync)?)\s+eperm\b`)
180
181 func appendSandboxWriteHint(out string, err error, p bashParams, spec sandbox.Spec, preset, workDir string) string {
182 if !spec.Enforce() || strings.TrimSpace(preset) == string(permissionpreset.DangerFullAccess) {
183 return out
184 }
185 // git reports some refused config writes and still exits 0, so the note
186 // rides on the protected path's identity, not on the exit status.
187 if named := gitMetadataNamedIn(out, spec, workDir); len(named) > 0 {
188 hint := gitMetadataDeniedHint(named)
189 if denialID := sandbox.IssueDenial(p.Command, preset); denialID != "" {
190 hint += " If the user asked for exactly this change, request danger-full-access for this exact retry with denial_id " + denialID + "."
191 }
192 return appendSessionDataHint(out, hint)
193 }
194 if !looksLikeSandboxWriteDenial(out, err) {
195 return out
196 }
197 hint := bashWriteDeniedHint()
198 if windowsChildProcessDenied.MatchString(strings.ToLower(out + "\n" + err.Error())) {
199 hint = "The command encountered a permission denial under the OS sandbox. Additional writable directories may not resolve a child-process or named-object denial."
200 } else if dirs := gitWorktreeWriteDirs(workDir, out, spec.WriteRoots); len(dirs) > 0 {
201 paths, _ := json.Marshal(dirs)
202 hint = "Git worktree metadata is outside the writable workspace. Retry this command with additional_write_dirs: " + string(paths) + " and a justification; the host will request approval for these directories."
203 } else if len(p.AdditionalWriteDirs) > 0 {
204 hint = "The command encountered a permission denial under the OS sandbox. Additional writable directories may not resolve a child-process or named-object denial."
205 }
206 if denialID := sandbox.IssueDenial(p.Command, preset); denialID != "" {
207 hint += " If the command cannot be expressed with additional_write_dirs, request danger-full-access for this exact retry with denial_id " + denialID + "."
208 }
209 return appendSessionDataHint(out, hint)
210 }
211
212 // gitMetadataNamedIn returns the protected Git metadata paths a failed
213 // command's output names, spelled as it names them. The paths are the host's
214 // own identities; nothing here reads the wording around them.
215 func gitMetadataNamedIn(output string, spec sandbox.Spec, workDir string) []string {
216 var named []string
217 for _, path := range sandbox.GitMetadataPaths(spec) {
218 for _, spelling := range gitMetadataSpellings(path, workDir) {
219 if containsPathToken(output, spelling) {
220 named = append(named, spelling)
221 break
222 }
223 }
224 }
225 return named
226 }
227
228 // containsPathToken reports whether path occurs in output as a whole path, not
229 // as the tail or prefix of a longer one: `.git` inside `main/.git/objects` is
230 // not the workspace's `.git`. A directory spelling ends in a separator.
231 func containsPathToken(output, path string) bool {
232 dirSpelling := strings.HasSuffix(path, string(filepath.Separator))
233 for from := 0; ; {
234 i := strings.Index(output[from:], path)
235 if i < 0 {
236 return false
237 }
238 start, end := from+i, from+i+len(path)
239 if (start == 0 || !isPathByte(output[start-1])) && (dirSpelling || end == len(output) || !isPathByte(output[end])) {
240 return true
241 }
242 from = start + 1
243 }
244 }
245
246 func isPathByte(c byte) bool {
247 return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("._-/\\~", c) >= 0
248 }
249
250 func gitMetadataSpellings(path, workDir string) []string {
251 out := []string{path}
252 if base, err := sandbox.ResolveAbsPath(workDir); err == nil {
253 if rel, err := filepath.Rel(base, path); err == nil && filepath.IsLocal(strings.TrimSuffix(rel, string(filepath.Separator))) {
254 if strings.HasSuffix(path, string(filepath.Separator)) {
255 rel += string(filepath.Separator)
256 }
257 out = append(out, rel)
258 }
259 }
260 return out
261 }
262
263 func gitMetadataDeniedHint(named []string) string {
264 return "[host] " + sandbox.GitMetadataDeniedCode + ": " + strings.Join(named, ", ") +
265 " is Git configuration or hooks that the host's own git reads, so the sandbox keeps it read-only; a write there did not happen even if the command exited 0. additional_write_dirs cannot grant it, and everything else under .git stays writable."
266 }
267
268 func gitWorktreeWriteDirs(workDir, output string, writeRoots []string) []string {
269 gitDir, commonDir := linkedGitMetadataDirs(workDir)
270 if gitDir == "" {
271 return nil
272 }
273 match := gitWriteDeniedPath.FindStringSubmatch(output)
274 if len(match) != 2 || !filepath.IsAbs(match[1]) {
275 return nil
276 }
277 denied, err := sandbox.ResolveAbsPath(match[1])
278 if err != nil {
279 return nil
280 }
281 objects := filepath.Join(commonDir, "objects")
282 refs := filepath.Join(commonDir, "refs")
283 logs := filepath.Join(commonDir, "logs")
284 if !sandbox.PathWithin(gitDir, denied) && !sandbox.PathWithin(objects, denied) && !sandbox.PathWithin(refs, denied) && !sandbox.PathWithin(logs, denied) {
285 return nil
286 }
287 paths := []string{gitDir, objects}
288 if sandbox.PathWithin(refs, denied) {
289 paths = append(paths, refs)
290 }
291 if sandbox.PathWithin(logs, denied) {
292 paths = append(paths, logs)
293 }
294 var missing []string
295 for _, path := range paths {
296 covered := false
297 for _, root := range writeRoots {
298 if sandbox.PathWithin(root, path) {
299 covered = true
300 break
301 }
302 }
303 if !covered {
304 missing = append(missing, path)
305 }
306 }
307 return missing
308 }
309
310 var gitWriteDeniedPath = regexp.MustCompile(`(?im)['"]([^'"\n]+)['"]:[ \t]*(?:operation not permitted|read-only file system|permission denied)`)
311
312 func linkedGitMetadataDirs(workDir string) (string, string) {
313 for dir := filepath.Clean(workDir); filepath.IsAbs(dir); dir = filepath.Dir(dir) {
314 gitPath := filepath.Join(dir, ".git")
315 pointer, err := readSmallGitFile(gitPath)
316 if err == nil {
317 if !strings.HasPrefix(pointer, "gitdir: ") {
318 return "", ""
319 }
320 gitDir := strings.TrimSpace(strings.TrimPrefix(pointer, "gitdir: "))
321 if !filepath.IsAbs(gitDir) {
322 gitDir = filepath.Join(dir, gitDir)
323 }
324 gitDir, err = filepath.EvalSymlinks(gitDir)
325 if err != nil {
326 return "", ""
327 }
328 common, err := readSmallGitFile(filepath.Join(gitDir, "commondir"))
329 if err != nil {
330 return "", ""
331 }
332 commonDir := common
333 if !filepath.IsAbs(commonDir) {
334 commonDir = filepath.Join(gitDir, commonDir)
335 }
336 commonDir, err = filepath.EvalSymlinks(commonDir)
337 if err != nil || filepath.Dir(gitDir) != filepath.Join(commonDir, "worktrees") {
338 return "", ""
339 }
340 backlink, err := readSmallGitFile(filepath.Join(gitDir, "gitdir"))
341 if err != nil {
342 return "", ""
343 }
344 if !filepath.IsAbs(backlink) {
345 backlink = filepath.Join(gitDir, backlink)
346 }
347 backlink, err = sandbox.ResolveAbsPath(backlink)
348 if err != nil {
349 return "", ""
350 }
351 actualGitPath, err := sandbox.ResolveAbsPath(gitPath)
352 if err != nil || backlink != actualGitPath {
353 return "", ""
354 }
355 for _, path := range []string{"objects", "refs"} {
356 info, err := os.Lstat(filepath.Join(commonDir, path))
357 if err != nil || !info.IsDir() {
358 return "", ""
359 }
360 }
361 if info, err := os.Lstat(filepath.Join(commonDir, "HEAD")); err != nil || !info.Mode().IsRegular() {
362 return "", ""
363 }
364 return gitDir, commonDir
365 } else if !os.IsNotExist(err) {
366 return "", ""
367 }
368 if filepath.Dir(dir) == dir {
369 break
370 }
371 }
372 return "", ""
373 }
374
375 func readSmallGitFile(path string) (string, error) {
376 info, err := os.Lstat(path)
377 if err != nil {
378 return "", err
379 }
380 if !info.Mode().IsRegular() || info.Size() > 4096 {
381 return "", fmt.Errorf("not a small regular Git metadata file: %s", path)
382 }
383 data, err := os.ReadFile(path)
384 return strings.TrimSpace(string(data)), err
385 }
386
386 lines GO