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