| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "slices" |
| 8 | "strings" |
| 9 | |
| 10 | "reasonix/internal/tool" |
| 11 | ) |
| 12 | |
| 13 | // ManagedConfigPaths is the set of Reasonix-owned configuration FILES a file |
| 14 | // tool may write outside the workspace roots, each write gated by a fresh |
| 15 | // human approval (see tool.ConfigWriteApprover). The zero value matches |
| 16 | // nothing, preserving plain workspace confinement. Entries are individual |
| 17 | // files, never directories: the Reasonix home also holds credentials (.env), |
| 18 | // global hooks (settings.json), skills, and session stores, which must not |
| 19 | // become writable through this escape hatch. |
| 20 | type ManagedConfigPaths struct { |
| 21 | paths []string |
| 22 | } |
| 23 | |
| 24 | // NewManagedConfigPaths resolves each candidate file to an absolute, |
| 25 | // symlink-free path (mirroring realRoots), dropping empty or unresolvable |
| 26 | // entries. |
| 27 | func NewManagedConfigPaths(paths []string) ManagedConfigPaths { |
| 28 | out := make([]string, 0, len(paths)) |
| 29 | for _, p := range paths { |
| 30 | if strings.TrimSpace(p) == "" { |
| 31 | continue |
| 32 | } |
| 33 | if real, err := realPath(p); err == nil { |
| 34 | out = append(out, real) |
| 35 | } |
| 36 | } |
| 37 | return ManagedConfigPaths{paths: out} |
| 38 | } |
| 39 | |
| 40 | // Match reports whether target resolves to exactly one of the managed config |
| 41 | // files. Exact file equality with no case folding: this is an allow-side rule, |
| 42 | // and folding an allow rule on a case-sensitive filesystem would wave a |
| 43 | // genuinely different file through (see withinFold). |
| 44 | func (m ManagedConfigPaths) Match(target string) bool { |
| 45 | if len(m.paths) == 0 { |
| 46 | return false |
| 47 | } |
| 48 | abs, err := realPath(target) |
| 49 | if err != nil { |
| 50 | return false |
| 51 | } |
| 52 | return slices.Contains(m.paths, abs) |
| 53 | } |
| 54 | |
| 55 | // approve asks the user whether this managed-config write may proceed, via the |
| 56 | // approver carried on ctx. No approver — a headless run, or a sub-agent whose |
| 57 | // parent has no interactive frontend — fails closed. The error text is written |
| 58 | // for the model: it names the boundary and the durable ways forward. |
| 59 | func (m ManagedConfigPaths) approve(ctx context.Context, target string) error { |
| 60 | approver, ok := tool.ConfigWriteApproverFrom(ctx) |
| 61 | if !ok { |
| 62 | return fmt.Errorf("path %q is a Reasonix-managed config file outside the writable roots; writing it requires interactive user approval, which this session cannot provide. "+ |
| 63 | "Ask the user to retry in an interactive session, or to add the directory to [sandbox] allow_write in reasonix.toml", target) |
| 64 | } |
| 65 | req := tool.ConfigWriteRequest{Path: target} |
| 66 | if checker, ok := approver.(tool.ConfigWriteSessionChecker); ok && checker.ManagedConfigWriteSessionAllowed(ctx, req) { |
| 67 | return nil |
| 68 | } |
| 69 | allow, reason, err := approver.ApproveManagedConfigWrite(ctx, req) |
| 70 | if err != nil { |
| 71 | return err |
| 72 | } |
| 73 | if !allow { |
| 74 | if strings.TrimSpace(reason) == "" { |
| 75 | reason = "the user declined this Reasonix config write — do not retry it; ask how they would like to proceed." |
| 76 | } |
| 77 | return errors.New(reason) |
| 78 | } |
| 79 | return nil |
| 80 | } |
| 81 |