Files
2026-07-13 13:00:08 +08:00

287 lines
11 KiB
Go

package builtin
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// SessionDataGuard rejects agent writes into Reasonix's own session stores:
// <state root>/sessions and <state root>/projects/<slug>/sessions. The runtime
// is the only writer of those files (CAS ledger + autosave); an agent editing
// them from inside a chat races the app's own saves, which surfaces to the user
// as endless "conflict copy" forks — the agent sees its write "not take",
// retries, and loops. The zero value is unconfined, matching the confine
// helpers, so tools registered at init keep their historical behavior.
//
// allowRoots are the explicitly configured [sandbox] allow_write entries: a
// user who deliberately lists a session directory there keeps raw access, so
// the guard only blocks the accidental self-write path (a workspace root that
// happens to cover the state root, e.g. a home-directory workspace).
type SessionDataGuard struct {
stateRoot string
allowRoots []string
hintNeedles []string
}
// NewSessionDataGuard builds a guard for the given Reasonix state root
// (config.MemoryUserDir()) and the explicit allow_write entries. Both are
// resolved to absolute, symlink-free paths once here, mirroring realRoots.
// An empty stateRoot yields an unconfined guard.
func NewSessionDataGuard(stateRoot string, allowRoots []string) SessionDataGuard {
g := SessionDataGuard{}
if strings.TrimSpace(stateRoot) == "" {
return g
}
real, err := realPath(stateRoot)
if err != nil {
return g
}
g.stateRoot = real
g.allowRoots = realRoots(allowRoots)
g.hintNeedles = sessionHintNeedles(stateRoot, real, g.allowRoots)
return g
}
// Check returns an error when target resolves into a guarded session store and
// is not covered by an explicit allow_write root. The error text is written for
// the model: it names why the write is refused and the durable ways forward.
func (g SessionDataGuard) Check(target string) error {
if g.stateRoot == "" {
return nil
}
abs, err := realPath(target)
if err != nil {
return nil // can't resolve -> let the caller's normal error path handle it
}
if g.deniesSecurity(abs) {
return fmt.Errorf("path %q is a Reasonix security boundary file (%s holds the global hooks and hook trust store; hooks execute arbitrary shell commands on every future session). Agents may not modify it. "+
"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",
target, g.stateRoot)
}
if !g.denies(abs) {
return nil
}
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. "+
"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",
target, g.stateRoot)
}
// securityStateFile reports whether name (a state-root-direct file name,
// already case-folded when the platform folds) is a security boundary rather
// than a mere runtime ledger: settings.json defines the global hooks —
// arbitrary shell commands executed on harness events in every project — and
// trust.json records which projects' hooks are trusted to run at all. An agent
// that can write either one can persist code execution across all future
// sessions, so these deny even when the racing-saves rationale of
// runtimeStateFile does not apply.
func securityStateFile(name string) bool {
switch name {
case "settings.json", "trust.json":
return true
}
return false
}
// deniesSecurity reports whether abs (absolute, symlink-free) is a state-root-
// direct security boundary file (see securityStateFile) not covered by an
// explicit allow_write root. Deny-side, so comparisons fold case on
// case-insensitive platforms, mirroring denies.
func (g SessionDataGuard) deniesSecurity(abs string) bool {
root := g.stateRoot
allow := g.allowRoots
if foldPaths {
abs = strings.ToLower(abs)
root = strings.ToLower(root)
folded := make([]string, len(allow))
for i, a := range allow {
folded[i] = strings.ToLower(a)
}
allow = folded
}
for _, a := range allow {
if within(a, abs) {
return false
}
}
rel, err := filepath.Rel(root, abs)
if err != nil || rel == "." || strings.Contains(rel, string(filepath.Separator)) {
return false
}
return securityStateFile(rel)
}
// runtimeStateFile reports whether name (a state-root-direct file name, already
// case-folded when the platform folds) is a desktop runtime ledger the app
// rewrites wholesale while running — quit snapshots, topic-index rebuilds,
// periodic flushes — so an agent edit vanishes the same way a session-file edit
// does. config.toml / credentials / skills stay writable: editing those on the
// user's request is a legitimate flow with no autonomous rewriter racing it —
// but settings.json / trust.json are a security boundary, not a ledger, and are
// denied separately by securityStateFile.
// heartbeat-tasks.json stays writable too — it is documented as human- and
// AI-editable (desktop/heartbeat.go, and the heartbeat panel tip says "AI
// agents can also edit heartbeat-tasks.json"), so the product explicitly
// accepts agent edits racing the engine there.
func runtimeStateFile(name string) bool {
if strings.HasPrefix(name, "desktop-") {
return true // desktop-tabs.json(+.tmp), desktop-projects.json, desktop-window.json, desktop-workspace…
}
switch name {
case "metrics-pending.json", "crash-pending.json":
return true
}
return false
}
// denies reports whether abs (absolute, symlink-free) is inside a guarded
// session store or a runtime ledger file, and not explicitly allowed. All
// comparisons are deny-side, so they fold case on case-insensitive platforms:
// EvalSymlinks keeps the caller's spelling, and on default macOS/Windows
// volumes ~/.reasonix/SESSIONS reaches the very same files (the same shape as
// the Windows lease-key case split fixed in #6023).
func (g SessionDataGuard) denies(abs string) bool {
root := g.stateRoot
allow := g.allowRoots
if foldPaths {
abs = strings.ToLower(abs)
root = strings.ToLower(root)
folded := make([]string, len(allow))
for i, a := range allow {
folded[i] = strings.ToLower(a)
}
allow = folded
}
for _, a := range allow {
if within(a, abs) {
return false
}
}
if within(filepath.Join(root, "sessions"), abs) {
return true
}
// State-root-direct runtime ledgers (desktop-tabs.json & friends).
if rel, err := filepath.Rel(root, abs); err == nil && rel != "." && !strings.Contains(rel, string(filepath.Separator)) {
if runtimeStateFile(rel) {
return true
}
}
// <state root>/projects/<slug>/sessions/** — every per-project store, so
// the slug segment is matched positionally rather than enumerated.
projects := filepath.Join(root, "projects")
if !within(projects, abs) {
return false
}
rel, err := filepath.Rel(projects, abs)
if err != nil {
return false
}
parts := strings.Split(rel, string(filepath.Separator))
return len(parts) >= 2 && parts[1] == "sessions"
}
// CommandHint returns a warning to append to bash output when the command
// references the guarded state trees, and "" otherwise. bash cannot know what a
// command actually wrote (off mode runs raw, and write roots may legitimately
// cover the state root), so this is a lexical check on the command text —
// enough to break the agent's "write → app overwrites it → looks like my write
// failed → retry" loop, which is how session-data self-writes burn tokens in
// the wild. It never blocks: reading session files for diagnostics is
// legitimate. workDir is the directory the command runs in: when it sits
// inside the state root (the desktop Global workspace lives at
// <state root>/global-workspace), relative references like ../sessions reach
// the stores without ever spelling an absolute path, so relative forms are
// matched too — and a workDir already inside a guarded store warns on every
// command.
func (g SessionDataGuard) CommandHint(workDir, command string) string {
if g.stateRoot == "" || command == "" {
return ""
}
warn := fmt.Sprintf("WARNING: this command referenced Reasonix's own session/state data under %s. "+
"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\". "+
"Do not modify session files from a chat — stop retrying and report the underlying problem instead.", g.stateRoot)
haystack := strings.ToLower(filepath.ToSlash(command))
for _, needle := range g.hintNeedles {
if strings.Contains(haystack, needle) {
return warn
}
}
if workDir != "" {
if absWork, err := realPath(workDir); err == nil {
if g.denies(absWork) {
return warn // cwd is already inside a guarded store: every command operates on it
}
if withinFold(g.stateRoot, absWork) {
for _, sub := range []string{"sessions", "projects"} {
rel, err := filepath.Rel(absWork, filepath.Join(g.stateRoot, sub))
if err != nil {
continue
}
if needle := strings.ToLower(filepath.ToSlash(rel)); strings.Contains(haystack, needle) {
return warn
}
}
}
}
}
return ""
}
// sessionHintNeedles precomputes the lowercase, slash-normalized textual forms
// of the guarded trees as they may appear in a command: under the state root as
// given, its symlink-resolved form, and abbreviated variants ("~/", "$HOME/",
// "${HOME}/", and on the config-dir side "%APPDATA%"/"$env:APPDATA") when a
// form sits under the respective base. A tree wholly covered by an allow_write
// root is skipped — the user sanctioned raw access there, so warnings would
// only nag.
func sessionHintNeedles(rawRoot, realRoot string, allowRoots []string) []string {
prefixes := map[string]bool{}
addPrefix := func(p string) {
if p == "" {
return
}
if abs, err := filepath.Abs(p); err == nil {
prefixes[filepath.Clean(abs)] = true
}
}
addPrefix(rawRoot)
addPrefix(realRoot)
home, _ := os.UserHomeDir()
cfgDir, _ := os.UserConfigDir()
var needles []string
abbreviate := func(base, tree string, forms ...string) {
if base == "" {
return
}
rel, err := filepath.Rel(base, tree)
if err != nil || rel == "." || !filepath.IsLocal(rel) {
return
}
slashRel := strings.ToLower(filepath.ToSlash(rel))
for _, form := range forms {
needles = append(needles, form+"/"+slashRel)
}
}
for prefix := range prefixes {
for _, sub := range []string{"sessions", "projects", "desktop-", "metrics-pending.json", "crash-pending.json"} {
tree := filepath.Join(prefix, sub)
if covered := func() bool {
for _, a := range allowRoots {
if withinFold(a, filepath.Join(realRoot, sub)) {
return true
}
}
return false
}(); covered {
continue
}
needles = append(needles, strings.ToLower(filepath.ToSlash(tree)))
abbreviate(home, tree, "~", "$home", "${home}")
abbreviate(cfgDir, tree, "%appdata%", "$env:appdata")
}
}
return needles
}