| 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 |