返回 DeepSeek-Reasonix
project_scope.go
根目录 / internal / config / project_scope.go
1 package config
2
3 import (
4 "encoding/json"
5 "fmt"
6 "maps"
7 "os"
8 "path/filepath"
9 "reflect"
10 "slices"
11 "strings"
12
13 "reasonix/internal/permission"
14 "reasonix/internal/permissionpreset"
15 )
16
17 // heldScope is what the user granted, held across the project merge. A
18 // project reasonix.toml arrives with a checkout, so it is untrusted input: it
19 // may narrow these grants and never widen them. The slices are copies because
20 // decoding the project file writes into the arrays the user's decode left.
21 type heldScope struct {
22 sandbox SandboxConfig
23 permissions PermissionsConfig
24 approvalMode string
25 shell ShellConfig
26 rgPath string
27 lsp map[string]LSPServer
28 browser BrowserConfig
29 network NetworkConfig
30 endpoints heldEndpoints
31 bot BotConfig
32 // Regional display preferences: a repository must not alter how spend reads.
33 language, currency, displayCurrency string
34 }
35
36 func holdUserScope(c *Config) heldScope {
37 s, p, b := c.Sandbox, c.Permissions, c.Browser
38 s.AllowWrite, s.ForbidRead = slices.Clone(s.AllowWrite), slices.Clone(s.ForbidRead)
39 b.ChromeArgs = slices.Clone(b.ChromeArgs)
40 p.Allow, p.Ask, p.Deny = slices.Clone(p.Allow), slices.Clone(p.Ask), slices.Clone(p.Deny)
41 return heldScope{
42 sandbox: s,
43 permissions: p,
44 approvalMode: c.Desktop.DefaultToolApprovalMode,
45 shell: c.Tools.Shell,
46 rgPath: c.Tools.Search.RgPath,
47 lsp: maps.Clone(c.LSP.Servers),
48 browser: b,
49 network: c.Network,
50 endpoints: holdUserEndpoints(c),
51 bot: cloneBotConfig(c.Bot),
52 language: c.Desktop.Language,
53 currency: c.Desktop.Currency,
54 displayCurrency: c.Billing.DisplayCurrency,
55 }
56 }
57
58 // IgnoredProjectReason says why a project value was not applied.
59 type IgnoredProjectReason string
60
61 const (
62 // ProjectWidensUser: the value would loosen what the user configured.
63 ProjectWidensUser IgnoredProjectReason = "widens_user_setting"
64 // ProjectOutsideWorkspace: a path that resolves outside the workspace.
65 ProjectOutsideWorkspace IgnoredProjectReason = "outside_workspace"
66 // ProjectUserOnly: a setting only the user config may choose.
67 ProjectUserOnly IgnoredProjectReason = "user_only"
68 // ProjectAwaitingApproval: a program the project names that nobody approved.
69 ProjectAwaitingApproval IgnoredProjectReason = "awaiting_approval"
70 // ProjectApprovalUnavailable: the approval record could not be read.
71 ProjectApprovalUnavailable IgnoredProjectReason = "approval_store_unavailable"
72 // ProjectProgramWritable: a single program where sandboxed commands write.
73 ProjectProgramWritable IgnoredProjectReason = "program_in_writable_location"
74 )
75
76 var ignoredProjectReasonText = map[IgnoredProjectReason]string{
77 ProjectWidensUser: "a project file may only narrow this setting; set it in your user config to change it",
78 ProjectOutsideWorkspace: "the path resolves outside this workspace",
79 ProjectUserOnly: "only your user config sets this",
80 ProjectAwaitingApproval: "it runs only after you approve it; run `reasonix trust` in this workspace",
81 ProjectApprovalUnavailable: "the approval record could not be read, so it stays off",
82 ProjectProgramWritable: "it names a file in this workspace or another place sandboxed commands can write; point it at an installed program",
83 }
84
85 // IgnoredProjectSetting is one value a project file set that the load refused.
86 type IgnoredProjectSetting struct {
87 Key string
88 Value string
89 Reason IgnoredProjectReason
90 }
91
92 type projectScopeReport struct {
93 ignored []IgnoredProjectSetting
94 pending []ProjectProgram
95 admitted []ProjectProgram
96 }
97
98 // IgnoredProjectSettings lists the project values this load refused.
99 func (c *Config) IgnoredProjectSettings() []IgnoredProjectSetting {
100 if c == nil {
101 return nil
102 }
103 return slices.Clone(c.projectScope.ignored)
104 }
105
106 func (c *Config) ignoreProject(key, value string, reason IgnoredProjectReason) {
107 c.projectScope.ignored = append(c.projectScope.ignored, IgnoredProjectSetting{Key: key, Value: value, Reason: reason})
108 // Legacy permission declarations remain inspectable but do not signal a
109 // broken configuration or grant authority merely by being in a checkout.
110 if key != "permissions.allow" && key != "sandbox.allow_write" && key != "sandbox.workspace_root" {
111 c.addLoadWarning(fmt.Sprintf("project config sets %s = %q; ignored: %s", key, value, ignoredProjectReasonText[reason]))
112 }
113 }
114
115 // narrow applies the project-only-narrows rule to everything the project merge
116 // may have written, and gates the programs the project names.
117 func (h heldScope) narrow(c *Config, root string) {
118 ws := workspaceDir(root)
119 // A checkout can only reuse authority held by the user, not create it.
120 grants := NewProjectGrantStore(reasonixHomeDir())
121 grant, err := grants.Grant(ws)
122 if err != nil {
123 c.addLoadWarning(fmt.Sprintf("project grants %s could not be read (%v); access may require approval", grants.Path(), err))
124 }
125 if ws == "" {
126 grant = ProjectGrant{}
127 }
128 h.permissions.Allow = appendMissing(h.permissions.Allow, grant.Allow...)
129 h.sandbox.AllowWrite = appendMissing(h.sandbox.AllowWrite, grant.AllowWrite...)
130 c.Desktop.Language, c.Desktop.Currency, c.Billing.DisplayCurrency = h.language, h.currency, h.displayCurrency
131 h.narrowSandbox(c, ws)
132 h.narrowPermissions(c)
133 if permissionpreset.NormalizeDefault(c.Desktop.DefaultToolApprovalMode) != permissionpreset.NormalizeDefault(h.approvalMode) {
134 c.ignoreProject("desktop.default_tool_approval_mode", c.Desktop.DefaultToolApprovalMode, ProjectUserOnly)
135 }
136 c.Desktop.DefaultToolApprovalMode = h.approvalMode
137 if !reflect.DeepEqual(c.Network, h.network) {
138 c.ignoreProject("network", "proxy_mode="+c.Network.ProxyMode, ProjectUserOnly)
139 }
140 c.Network = h.network
141 // [bot] opens the machine to chat accounts and routes their messages into
142 // sessions; only the user's own config may say which.
143 if !reflect.DeepEqual(c.Bot, h.bot) {
144 c.ignoreProject("bot", "", ProjectUserOnly)
145 }
146 c.Bot = h.bot
147 home := reasonixHomeDir()
148 store := NewProjectProgramStore(home)
149 h.gatePrograms(c, store, ws)
150 h.endpoints.gateEndpoints(c, store, ws)
151 }
152
153 func (h heldScope) narrowSandbox(c *Config, ws string) {
154 s, u := &c.Sandbox, h.sandbox
155 if bashJails(u.Bash) && !bashJails(s.Bash) {
156 c.ignoreProject("sandbox.bash", s.Bash, ProjectWidensUser)
157 s.Bash = u.Bash
158 }
159 if s.Network && !u.Network {
160 c.ignoreProject("sandbox.network", "true", ProjectWidensUser)
161 s.Network = false
162 }
163 s.ForbidRead = appendMissing(u.ForbidRead, s.ForbidRead...)
164 if s.WorkspaceRoot != u.WorkspaceRoot && !c.equivalentConfigPath(ws, s.WorkspaceRoot, u.WorkspaceRoot) {
165 if dir, ok := c.workspacePath(ws, s.WorkspaceRoot); ok {
166 s.WorkspaceRoot = dir
167 } else {
168 c.ignoreProject("sandbox.workspace_root", s.WorkspaceRoot, ProjectOutsideWorkspace)
169 s.WorkspaceRoot = u.WorkspaceRoot
170 }
171 } else {
172 s.WorkspaceRoot = u.WorkspaceRoot
173 }
174 allow := slices.Clone(u.AllowWrite)
175 // Only the admitted workspace root counts: a rejected project root cannot
176 // authorize its own extra directories, and a narrowed root stays narrow.
177 coveredRoots := append(slices.Clone(u.AllowWrite), s.WorkspaceRoot)
178 for _, entry := range s.AllowWrite {
179 if slices.Contains(u.AllowWrite, entry) || c.authorizedPath(ws, coveredRoots, entry) {
180 continue
181 }
182 if dir, ok := c.workspacePath(ws, entry); ok {
183 allow = appendMissing(allow, dir)
184 } else {
185 c.ignoreProject("sandbox.allow_write", entry, ProjectOutsideWorkspace)
186 }
187 }
188 s.AllowWrite = allow
189 }
190
191 func (h heldScope) narrowPermissions(c *Config) {
192 p, u := &c.Permissions, h.permissions
193 if normalizedMode(p.Mode) != normalizedMode(u.Mode) {
194 c.ignoreProject("permissions.mode", p.Mode, ProjectUserOnly)
195 }
196 if p.AllowDynamicBash && !u.AllowDynamicBash {
197 c.ignoreProject("permissions.allow_dynamic_bash", "true", ProjectUserOnly)
198 }
199 for _, rule := range p.Allow {
200 if !slices.Contains(u.Allow, rule) && !slices.ContainsFunc(u.Allow, func(allowed string) bool { return permission.RuleCoversString(allowed, rule) }) {
201 c.ignoreProject("permissions.allow", rule, ProjectUserOnly)
202 }
203 }
204 p.Mode, p.AllowDynamicBash, p.Allow = u.Mode, u.AllowDynamicBash, u.Allow
205 p.Ask = appendMissing(u.Ask, p.Ask...)
206 p.Deny = appendMissing(u.Deny, p.Deny...)
207 }
208
209 // bashJails mirrors BashModeForGOOS off Windows: only an explicit "off" runs
210 // bash unconfined, so every other spelling counts as the jail.
211 func bashJails(mode string) bool { return strings.TrimSpace(mode) != "off" }
212
213 func normalizedMode(mode string) string { return strings.ToLower(strings.TrimSpace(mode)) }
214
215 // workspacePath resolves a project-declared path the way the confiner will and
216 // reports whether it stays inside ws. The resolved form is what gets stored, so
217 // a link swapped in after this check cannot move what was approved.
218 func (c *Config) workspacePath(ws, path string) (string, bool) {
219 resolved, ok := c.configPath(ws, path)
220 if !ok {
221 return "", false
222 }
223 return resolved, configDirectoryCovers(ws, resolved)
224 }
225
226 // expandSandboxPath expands ${VAR} from the process environment alone: a
227 // workspace .env must not steer where writes land or which reads are refused,
228 // and a project path still holding "${" is refused rather than expanded again.
229 func (c *Config) expandSandboxPath(path string) string {
230 return ExpandVars(path)
231 }
232
233 // workspaceDir is root absolute and symlink-free, or "" when it cannot be
234 // resolved; an unresolvable workspace contains nothing.
235 func workspaceDir(root string) string {
236 abs, err := filepath.Abs(root)
237 if err != nil {
238 return ""
239 }
240 real, err := filepath.EvalSymlinks(abs)
241 if err != nil {
242 return ""
243 }
244 if info, err := os.Stat(real); err != nil || !info.IsDir() {
245 return ""
246 }
247 return filepath.Clean(real)
248 }
249
250 func appendMissing(base []string, extra ...string) []string {
251 out := slices.Clone(base)
252 for _, v := range extra {
253 if !slices.Contains(out, v) {
254 out = append(out, v)
255 }
256 }
257 return out
258 }
259
260 // cloneBotConfig deep-copies b: the project decode writes into the maps and
261 // slices the user's decode left behind.
262 func cloneBotConfig(b BotConfig) BotConfig {
263 data, err := json.Marshal(b)
264 if err != nil {
265 return BotConfig{}
266 }
267 var out BotConfig
268 if json.Unmarshal(data, &out) != nil {
269 return BotConfig{}
270 }
271 return out
272 }
273
273 lines GO