返回 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 "regexp"
8 "strings"
9 "time"
10
11 "reasonix/internal/permissionpreset"
12 "reasonix/internal/sandbox"
13 "reasonix/internal/tool"
14 )
15
16 func (b bash) Schema() json.RawMessage {
17 if b.resolved().Kind == sandbox.ShellPowerShell {
18 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"]}`)
19 }
20 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"]}`)
21 }
22
23 func (b bash) DeclareWriteAccess(args json.RawMessage) (tool.WriteAccessDeclaration, error) {
24 var p bashParams
25 if err := json.Unmarshal(args, &p); err != nil {
26 return tool.WriteAccessDeclaration{}, fmt.Errorf("invalid args: %w", err)
27 }
28 if err := validateBashWriteDirs(p); err != nil {
29 return tool.WriteAccessDeclaration{}, err
30 }
31 return tool.WriteAccessDeclaration{
32 Directories: append([]string(nil), p.AdditionalWriteDirs...),
33 Justification: strings.TrimSpace(p.Justification),
34 RequestedPreset: strings.TrimSpace(p.SandboxPermissions),
35 DenialID: strings.TrimSpace(p.DenialID),
36 }, nil
37 }
38
39 func validateBashWriteDirs(p bashParams) error {
40 preset := strings.TrimSpace(p.SandboxPermissions)
41 if preset != "" && preset != string(permissionpreset.WorkspaceWrite) && preset != string(permissionpreset.DangerFullAccess) {
42 return fmt.Errorf("sandbox_permissions must be workspace-write or danger-full-access")
43 }
44 if preset != "" && strings.TrimSpace(p.Justification) == "" {
45 return fmt.Errorf("justification is required when sandbox_permissions is set")
46 }
47 if preset == string(permissionpreset.DangerFullAccess) && strings.TrimSpace(p.DenialID) == "" {
48 return fmt.Errorf("denial_id is required when sandbox_permissions is danger-full-access")
49 }
50 if len(p.AdditionalWriteDirs) == 0 {
51 return nil
52 }
53 if strings.TrimSpace(p.Justification) == "" {
54 return fmt.Errorf("justification is required when additional_write_dirs is set")
55 }
56 for _, dir := range p.AdditionalWriteDirs {
57 dir = strings.TrimSpace(dir)
58 if dir == "" {
59 return fmt.Errorf("additional_write_dirs entries must be non-empty directories")
60 }
61 if strings.ContainsAny(dir, "*?[") {
62 return fmt.Errorf("additional_write_dirs %q must be a concrete directory, not a glob", dir)
63 }
64 }
65 return nil
66 }
67
68 func validateBashParams(p bashParams) error {
69 if p.Command == "" {
70 return fmt.Errorf("command is required")
71 }
72 if p.TimeoutMS < 0 {
73 return fmt.Errorf("timeout_ms must be positive")
74 }
75 return validateBashWriteDirs(p)
76 }
77
78 func bashPreflightFailure(ex *tool.ShellExecution, start time.Time, err error) (tool.DetailedResult, error) {
79 ex.State = tool.ShellStateNotRun
80 ex.FailurePhase = tool.ShellPhasePreflight
81 ex.MutationRisk = tool.ShellMutationNotStarted
82 ex.DurationMs = time.Since(start).Milliseconds()
83 return tool.DetailedResult{Execution: ex}, err
84 }
85
86 func bashLaunchFailure(ex *tool.ShellExecution, start time.Time, err error) (tool.DetailedResult, error) {
87 ex.State = tool.ShellStateNotRun
88 // prepareLaunch has not entered the native runner yet. Its failures are
89 // missing host dependencies (sandbox backend or session temp), not ACL/token
90 // authorization and not a child-process launch.
91 ex.FailurePhase = tool.ShellPhaseDependency
92 ex.MutationRisk = tool.ShellMutationNotStarted
93 ex.DurationMs = time.Since(start).Milliseconds()
94 return tool.DetailedResult{Execution: ex}, err
95 }
96
97 func (b bash) appendWriteHints(ctx context.Context, out string, err error, p bashParams, wrapped bool) string {
98 out = appendSessionDataHint(out, b.guard.CommandHint(b.workDir, p.Command))
99 if wrapped {
100 out = appendSandboxWriteHint(out, err, p, b.specForCall(ctx), string(sandbox.PermissionPresetFrom(ctx)))
101 }
102 return out
103 }
104
105 func (b bash) specForCall(ctx context.Context) sandbox.Spec {
106 spec := b.sb
107 preset := sandbox.PermissionPresetFrom(ctx)
108 switch preset {
109 case permissionpreset.ReadOnly:
110 spec.Mode = "enforce"
111 spec.ReadOnly = true
112 spec.WriteRoots = nil
113 spec.MinimalWrites = true
114 case permissionpreset.WorkspaceWrite:
115 // Permission presets own the enforcement decision. A legacy
116 // [sandbox].bash="off" cannot silently turn workspace access into an
117 // unconfined shell.
118 spec.Mode = "enforce"
119 spec.ReadOnly = false
120 spec.MinimalWrites = true
121 if len(spec.WriteRoots) == 0 && strings.TrimSpace(b.workDir) != "" {
122 spec.WriteRoots = []string{b.workDir}
123 }
124 case permissionpreset.DangerFullAccess:
125 spec.Mode = "off"
126 spec.ReadOnly = false
127 }
128 // Windows has no OS-level shell sandbox: demanding one made every
129 // restricted-preset shell call fail closed (#10292). Presets stay tool-layer
130 // boundaries there and bash runs as the OS user after the approval gate.
131 if !sandbox.OSSandboxSupported() {
132 spec.Mode = "off"
133 }
134 if preset == permissionpreset.WorkspaceWrite {
135 if b.rootSet != nil {
136 spec.WriteRoots = b.rootSet.EffectiveSandboxRoots(ctx)
137 } else if extra := sandbox.PerCallWriteRoots(ctx); len(extra) > 0 {
138 spec.WriteRoots = sandbox.CollapseWriteRoots(append(append([]string{}, spec.WriteRoots...), extra...))
139 }
140 }
141 if spec.ProtectedWriteRoots == nil && b.guard.stateRoot != "" {
142 spec.ProtectedWriteRoots = sandbox.ProtectedWriteRoots(b.guard.stateRoot)
143 }
144 return spec
145 }
146
147 func bashWriteDeniedHint() string {
148 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."
149 }
150
151 func looksLikeSandboxWriteDenial(out string, err error) bool {
152 if err == nil {
153 return false
154 }
155 msg := strings.ToLower(out)
156 if err != nil {
157 msg += "\n" + strings.ToLower(err.Error())
158 }
159 for _, needle := range []string{
160 "operation not permitted",
161 "read-only file system",
162 "erofs",
163 "access is denied",
164 "permissionerror: [errno 13] permission denied",
165 } {
166 if strings.Contains(msg, needle) {
167 return true
168 }
169 }
170 // A bare "permission denied" can be an HTTP response or application-level
171 // error. Accept it only in the standard local filesystem diagnostic shape
172 // emitted by shells and file utilities.
173 return localFilePermissionDenied.MatchString(msg) || windowsChildProcessDenied.MatchString(msg)
174 }
175
176 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`)
177 var windowsChildProcessDenied = regexp.MustCompile(`\b(?:spawn(?:sync)?|exec(?:file|sync)?)\s+eperm\b`)
178
179 func appendSandboxWriteHint(out string, err error, p bashParams, spec sandbox.Spec, preset string) string {
180 if !spec.Enforce() || strings.TrimSpace(preset) == string(permissionpreset.DangerFullAccess) || !looksLikeSandboxWriteDenial(out, err) {
181 return out
182 }
183 hint := bashWriteDeniedHint()
184 if len(p.AdditionalWriteDirs) > 0 || windowsChildProcessDenied.MatchString(strings.ToLower(out+"\n"+err.Error())) {
185 hint = "The command encountered a permission denial under the OS sandbox. Additional writable directories may not resolve a child-process or named-object denial."
186 }
187 if denialID := sandbox.IssueDenial(p.Command, preset); denialID != "" {
188 hint += " If the command cannot be expressed with additional_write_dirs, request danger-full-access for this exact retry with denial_id " + denialID + "."
189 }
190 return appendSessionDataHint(out, hint)
191 }
192
192 lines GO