| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // SessionDataGuard rejects agent writes into Reasonix's own session stores: |
| 11 | // <state root>/sessions and <state root>/projects/<slug>/sessions. The runtime |
| 12 | // is the only writer of those files (CAS ledger + autosave); an agent editing |
| 13 | // them from inside a chat races the app's own saves, which surfaces to the user |
| 14 | // as endless "conflict copy" forks — the agent sees its write "not take", |
| 15 | // retries, and loops. The zero value is unconfined, matching the confine |
| 16 | // helpers, so tools registered at init keep their historical behavior. |
| 17 | // |
| 18 | // allowRoots are the explicitly configured [sandbox] allow_write entries: a |
| 19 | // user who deliberately lists a session directory there keeps raw access, so |
| 20 | // the guard only blocks the accidental self-write path (a workspace root that |
| 21 | // happens to cover the state root, e.g. a home-directory workspace). |
| 22 | type SessionDataGuard struct { |
| 23 | stateRoot string |
| 24 | allowRoots []string |
| 25 | hintNeedles []string |
| 26 | } |
| 27 | |
| 28 | // NewSessionDataGuard builds a guard for the given Reasonix state root |
| 29 | // (config.MemoryUserDir()) and the explicit allow_write entries. Both are |
| 30 | // resolved to absolute, symlink-free paths once here, mirroring realRoots. |
| 31 | // An empty stateRoot yields an unconfined guard. |
| 32 | func NewSessionDataGuard(stateRoot string, allowRoots []string) SessionDataGuard { |
| 33 | g := SessionDataGuard{} |
| 34 | if strings.TrimSpace(stateRoot) == "" { |
| 35 | return g |
| 36 | } |
| 37 | real, err := realPath(stateRoot) |
| 38 | if err != nil { |
| 39 | return g |
| 40 | } |
| 41 | g.stateRoot = real |
| 42 | g.allowRoots = realRoots(allowRoots) |
| 43 | g.hintNeedles = sessionHintNeedles(stateRoot, real, g.allowRoots) |
| 44 | return g |
| 45 | } |
| 46 | |
| 47 | // Check returns an error when target resolves into a guarded session store and |
| 48 | // is not covered by an explicit allow_write root. The error text is written for |
| 49 | // the model: it names why the write is refused and the durable ways forward. |
| 50 | func (g SessionDataGuard) Check(target string) error { |
| 51 | if g.stateRoot == "" { |
| 52 | return nil |
| 53 | } |
| 54 | abs, err := realPath(target) |
| 55 | if err != nil { |
| 56 | return nil // can't resolve -> let the caller's normal error path handle it |
| 57 | } |
| 58 | if g.deniesSecurity(abs) { |
| 59 | return fmt.Errorf("path %q is a Reasonix security boundary file (%s holds the global hooks; hooks execute arbitrary shell commands on every future session). Agents may not modify it. "+ |
| 60 | "Ask the user to edit it themselves, or to add the directory to [sandbox] allow_write in reasonix.toml if raw access is truly intended", |
| 61 | target, g.stateRoot) |
| 62 | } |
| 63 | if !g.denies(abs) { |
| 64 | return nil |
| 65 | } |
| 66 | return fmt.Errorf("path %q is inside Reasonix's own session/state data (%s); the app is the only writer of these files, and edits from a chat race its saves — that surfaces as repeated save-conflict copies. "+ |
| 67 | "Do not modify session or runtime-state files directly; report the underlying problem instead. If raw access is truly intended, add the directory to [sandbox] allow_write in reasonix.toml", |
| 68 | target, g.stateRoot) |
| 69 | } |
| 70 | |
| 71 | // securityStateFile reports whether name (a state-root-direct file name, |
| 72 | // already case-folded when the platform folds) is a security boundary rather |
| 73 | // than a mere runtime ledger. settings.json defines the global hooks: |
| 74 | // arbitrary shell commands executed on harness events in every project. |
| 75 | func securityStateFile(name string) bool { |
| 76 | switch name { |
| 77 | case "settings.json": |
| 78 | return true |
| 79 | } |
| 80 | return false |
| 81 | } |
| 82 | |
| 83 | // deniesSecurity reports whether abs (absolute, symlink-free) is a state-root- |
| 84 | // direct security boundary file (see securityStateFile) not covered by an |
| 85 | // explicit allow_write root. Deny-side, so comparisons fold case on |
| 86 | // case-insensitive platforms, mirroring denies. |
| 87 | func (g SessionDataGuard) deniesSecurity(abs string) bool { |
| 88 | root := g.stateRoot |
| 89 | allow := g.allowRoots |
| 90 | if foldPaths { |
| 91 | abs = strings.ToLower(abs) |
| 92 | root = strings.ToLower(root) |
| 93 | folded := make([]string, len(allow)) |
| 94 | for i, a := range allow { |
| 95 | folded[i] = strings.ToLower(a) |
| 96 | } |
| 97 | allow = folded |
| 98 | } |
| 99 | for _, a := range allow { |
| 100 | if within(a, abs) { |
| 101 | return false |
| 102 | } |
| 103 | } |
| 104 | rel, err := filepath.Rel(root, abs) |
| 105 | if err != nil || rel == "." || strings.Contains(rel, string(filepath.Separator)) { |
| 106 | return false |
| 107 | } |
| 108 | return securityStateFile(rel) |
| 109 | } |
| 110 | |
| 111 | // runtimeStateFile reports whether name (a state-root-direct file name, already |
| 112 | // case-folded when the platform folds) is a desktop runtime ledger the app |
| 113 | // rewrites wholesale while running — quit snapshots, topic-index rebuilds, |
| 114 | // periodic flushes — so an agent edit vanishes the same way a session-file edit |
| 115 | // does. config.toml / credentials / skills stay writable: editing those on the |
| 116 | // user's request is a legitimate flow with no autonomous rewriter racing it — |
| 117 | // but settings.json is a security boundary, not a ledger, and is denied |
| 118 | // separately by securityStateFile. |
| 119 | // heartbeat-tasks.json stays writable too — it is documented as human- and |
| 120 | // AI-editable (desktop/heartbeat.go, and the heartbeat panel tip says "AI |
| 121 | // agents can also edit heartbeat-tasks.json"), so the product explicitly |
| 122 | // accepts agent edits racing the engine there. |
| 123 | func runtimeStateFile(name string) bool { |
| 124 | if strings.HasPrefix(name, "desktop-") { |
| 125 | return true // desktop-tabs.json(+.tmp), desktop-projects.json, desktop-window.json, desktop-workspace… |
| 126 | } |
| 127 | switch name { |
| 128 | case "metrics-pending.json", "crash-pending.json": |
| 129 | return true |
| 130 | } |
| 131 | return false |
| 132 | } |
| 133 | |
| 134 | // denies reports whether abs (absolute, symlink-free) is inside a guarded |
| 135 | // session store or a runtime ledger file, and not explicitly allowed. All |
| 136 | // comparisons are deny-side, so they fold case on case-insensitive platforms: |
| 137 | // EvalSymlinks keeps the caller's spelling, and on default macOS/Windows |
| 138 | // volumes ~/.reasonix/SESSIONS reaches the very same files (the same shape as |
| 139 | // the Windows lease-key case split fixed in #6023). |
| 140 | func (g SessionDataGuard) denies(abs string) bool { |
| 141 | root := g.stateRoot |
| 142 | allow := g.allowRoots |
| 143 | if foldPaths { |
| 144 | abs = strings.ToLower(abs) |
| 145 | root = strings.ToLower(root) |
| 146 | folded := make([]string, len(allow)) |
| 147 | for i, a := range allow { |
| 148 | folded[i] = strings.ToLower(a) |
| 149 | } |
| 150 | allow = folded |
| 151 | } |
| 152 | for _, a := range allow { |
| 153 | if within(a, abs) { |
| 154 | return false |
| 155 | } |
| 156 | } |
| 157 | if within(filepath.Join(root, "sessions"), abs) { |
| 158 | return true |
| 159 | } |
| 160 | // State-root-direct runtime ledgers (desktop-tabs.json & friends). |
| 161 | if rel, err := filepath.Rel(root, abs); err == nil && rel != "." && !strings.Contains(rel, string(filepath.Separator)) { |
| 162 | if runtimeStateFile(rel) { |
| 163 | return true |
| 164 | } |
| 165 | } |
| 166 | // <state root>/projects/<slug>/sessions/** — every per-project store, so |
| 167 | // the slug segment is matched positionally rather than enumerated. |
| 168 | projects := filepath.Join(root, "projects") |
| 169 | if !within(projects, abs) { |
| 170 | return false |
| 171 | } |
| 172 | rel, err := filepath.Rel(projects, abs) |
| 173 | if err != nil { |
| 174 | return false |
| 175 | } |
| 176 | parts := strings.Split(rel, string(filepath.Separator)) |
| 177 | return len(parts) >= 2 && parts[1] == "sessions" |
| 178 | } |
| 179 | |
| 180 | // CommandHint returns a warning to append to bash output when the command |
| 181 | // references the guarded state trees, and "" otherwise. bash cannot know what a |
| 182 | // command actually wrote (off mode runs raw, and write roots may legitimately |
| 183 | // cover the state root), so this is a lexical check on the command text — |
| 184 | // enough to break the agent's "write → app overwrites it → looks like my write |
| 185 | // failed → retry" loop, which is how session-data self-writes burn tokens in |
| 186 | // the wild. It never blocks: reading session files for diagnostics is |
| 187 | // legitimate. workDir is the directory the command runs in: when it sits |
| 188 | // inside the state root (the desktop Global workspace lives at |
| 189 | // <state root>/global-workspace), relative references like ../sessions reach |
| 190 | // the stores without ever spelling an absolute path, so relative forms are |
| 191 | // matched too — and a workDir already inside a guarded store warns on every |
| 192 | // command. |
| 193 | func (g SessionDataGuard) CommandHint(workDir, command string) string { |
| 194 | if g.stateRoot == "" || command == "" { |
| 195 | return "" |
| 196 | } |
| 197 | warn := fmt.Sprintf("WARNING: this command referenced Reasonix's own session/state data under %s. "+ |
| 198 | "The app is actively saving those files; external modifications conflict with its saves and are preserved as conflict copies, so an edit can look like it \"did not take\". "+ |
| 199 | "Do not modify session files from a chat — stop retrying and report the underlying problem instead.", g.stateRoot) |
| 200 | haystack := strings.ToLower(filepath.ToSlash(command)) |
| 201 | for _, needle := range g.hintNeedles { |
| 202 | if strings.Contains(haystack, needle) { |
| 203 | return warn |
| 204 | } |
| 205 | } |
| 206 | if workDir != "" { |
| 207 | if absWork, err := realPath(workDir); err == nil { |
| 208 | if g.denies(absWork) { |
| 209 | return warn // cwd is already inside a guarded store: every command operates on it |
| 210 | } |
| 211 | if withinFold(g.stateRoot, absWork) { |
| 212 | for _, sub := range []string{"sessions", "projects"} { |
| 213 | rel, err := filepath.Rel(absWork, filepath.Join(g.stateRoot, sub)) |
| 214 | if err != nil { |
| 215 | continue |
| 216 | } |
| 217 | if needle := strings.ToLower(filepath.ToSlash(rel)); strings.Contains(haystack, needle) { |
| 218 | return warn |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | return "" |
| 225 | } |
| 226 | |
| 227 | // sessionHintNeedles precomputes the lowercase, slash-normalized textual forms |
| 228 | // of the guarded trees as they may appear in a command: under the state root as |
| 229 | // given, its symlink-resolved form, and abbreviated variants ("~/", "$HOME/", |
| 230 | // "${HOME}/", and on the config-dir side "%APPDATA%"/"$env:APPDATA") when a |
| 231 | // form sits under the respective base. A tree wholly covered by an allow_write |
| 232 | // root is skipped — the user sanctioned raw access there, so warnings would |
| 233 | // only nag. |
| 234 | func sessionHintNeedles(rawRoot, realRoot string, allowRoots []string) []string { |
| 235 | prefixes := map[string]bool{} |
| 236 | addPrefix := func(p string) { |
| 237 | if p == "" { |
| 238 | return |
| 239 | } |
| 240 | if abs, err := filepath.Abs(p); err == nil { |
| 241 | prefixes[filepath.Clean(abs)] = true |
| 242 | } |
| 243 | } |
| 244 | addPrefix(rawRoot) |
| 245 | addPrefix(realRoot) |
| 246 | home, _ := os.UserHomeDir() |
| 247 | cfgDir, _ := os.UserConfigDir() |
| 248 | |
| 249 | var needles []string |
| 250 | abbreviate := func(base, tree string, forms ...string) { |
| 251 | if base == "" { |
| 252 | return |
| 253 | } |
| 254 | rel, err := filepath.Rel(base, tree) |
| 255 | if err != nil || rel == "." || !filepath.IsLocal(rel) { |
| 256 | return |
| 257 | } |
| 258 | slashRel := strings.ToLower(filepath.ToSlash(rel)) |
| 259 | for _, form := range forms { |
| 260 | needles = append(needles, form+"/"+slashRel) |
| 261 | } |
| 262 | } |
| 263 | for prefix := range prefixes { |
| 264 | for _, sub := range []string{"sessions", "projects", "desktop-", "metrics-pending.json", "crash-pending.json"} { |
| 265 | tree := filepath.Join(prefix, sub) |
| 266 | if covered := func() bool { |
| 267 | for _, a := range allowRoots { |
| 268 | if withinFold(a, filepath.Join(realRoot, sub)) { |
| 269 | return true |
| 270 | } |
| 271 | } |
| 272 | return false |
| 273 | }(); covered { |
| 274 | continue |
| 275 | } |
| 276 | needles = append(needles, strings.ToLower(filepath.ToSlash(tree))) |
| 277 | abbreviate(home, tree, "~", "$home", "${home}") |
| 278 | abbreviate(cfgDir, tree, "%appdata%", "$env:appdata") |
| 279 | } |
| 280 | } |
| 281 | return needles |
| 282 | } |
| 283 |