From e9c66dc0568e0e29a97dd73df7f88f07113288c5 Mon Sep 17 00:00:00 2001 From: "Stephen R. van den Berg" Date: Thu, 3 Sep 2026 16:53:40 +0200 Subject: [PATCH 1/3] feat(tools): always gate managed config writes behind fresh approval confineWrite routed a managed config file (config.toml, legacy v0.x config.json) to the per-write fresh human approval only when the target sat outside the write roots; a root widened to cover the Reasonix home made the same files plain allowed writes, silently editable in YOLO. Those files configure providers, sandbox rules, and permissions for future sessions, so no posture and no widened root may write them unattended: check managed.Match(target) before root confinement and always consult the approver. The fail-closed message no longer points at allow_write, which no longer authorizes config edits. --- internal/boot/boot.go | 9 ++-- internal/tool/builtin/confine.go | 25 ++++++----- internal/tool/builtin/confine_test.go | 60 +++++++++++++++++++++++++ internal/tool/builtin/managed_config.go | 4 +- 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/internal/boot/boot.go b/internal/boot/boot.go index d77313f6d5..d031b977ab 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -693,10 +693,11 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) { } forbidReadRoots := RuntimeForbidReadRoots(cfg, root) // managedConfig names the Reasonix-owned config FILES (config.toml, - // compatibility TOMLs, legacy v0.x config.json) the file-writers may repair - // outside the workspace after a fresh per-write human approval. The bash - // OS-sandbox write roots deliberately stay unwidened: config repair goes - // through the approval-gated file tools, not raw shell writes. + // compatibility TOMLs, legacy v0.x config.json) the file-writers gate behind + // a fresh per-write human approval at any location, inside or outside the + // roots. The bash OS-sandbox write roots deliberately stay unwidened: + // config repair goes through the approval-gated file tools, not raw shell + // writes. managedConfig := builtin.NewManagedConfigPaths(config.ReasonixManagedConfigPaths()) bashSpec := sandbox.Spec{Mode: bashMode, WriteRoots: writeRoots, ForbidReadRoots: forbidReadRoots, Network: networkEnabled} bashSpec.Shell = shell diff --git a/internal/tool/builtin/confine.go b/internal/tool/builtin/confine.go index 5450bc01d1..f3cfebaea0 100644 --- a/internal/tool/builtin/confine.go +++ b/internal/tool/builtin/confine.go @@ -214,22 +214,23 @@ func confine(roots []string, target string) error { // confineWrite is the write-tool boundary check: workspace confinement first, // then the session-data guard, so a write can be inside the roots (e.g. a // home-directory workspace covering the state root) and still be refused when -// it targets Reasonix's own session stores. A target outside every root that -// matches a Reasonix-managed config file (see ManagedConfigPaths) may proceed -// after a fresh per-write human approval carried on ctx; without an approver it -// fails closed with the original confinement error semantics. +// it targets Reasonix's own session stores. A target that matches a +// Reasonix-managed config file (see ManagedConfigPaths) is always gated by a +// fresh per-write human approval carried on ctx, even when the roots already +// cover it: those files configure providers, sandbox rules, and permissions +// for future sessions, so no posture (YOLO included) and no widened root may +// write them silently. Without an approver the write fails closed. func confineWrite(ctx context.Context, roots []string, guard SessionDataGuard, managed ManagedConfigPaths, target string) error { - confineErr := confine(roots, target) - if confineErr == nil { - return guard.Check(target) - } - if !managed.Match(target) { - return confineErr + if managed.Match(target) { + if err := guard.Check(target); err != nil { + return err + } + return managed.approve(ctx, target) } - if err := guard.Check(target); err != nil { + if err := confine(roots, target); err != nil { return err } - return managed.approve(ctx, target) + return guard.Check(target) } // confinePreview is stricter than confineWrite because Preview runs before the diff --git a/internal/tool/builtin/confine_test.go b/internal/tool/builtin/confine_test.go index 2810e8a2c0..04db8c3bc8 100644 --- a/internal/tool/builtin/confine_test.go +++ b/internal/tool/builtin/confine_test.go @@ -315,6 +315,66 @@ func TestManagedConfigWriteGatedOnApprover(t *testing.T) { } } +// TestManagedConfigWriteAsksEvenInsideRoots pins that a Reasonix-managed config +// file stays gated behind the fresh per-write approval even when the write +// roots already cover it: YOLO and a widened allow_write must never let an +// agent rewrite config.toml silently. +func TestManagedConfigWriteAsksEvenInsideRoots(t *testing.T) { + home := isolateBuiltinTestUserState(t) + if err := os.MkdirAll(filepath.Join(home, ".reasonix"), 0o755); err != nil { + t.Fatal(err) + } + cfg := config.Default() + userConfig := config.UserConfigPath() + // The whole isolated home is a write root, config.toml included. + cfg.Sandbox.AllowWrite = []string{home} + managed := NewManagedConfigPaths(config.ReasonixManagedConfigPaths()) + w := writeFile{roots: realRoots(cfg.WriteRootsForRoot(home)), managed: managed} + args, _ := json.Marshal(map[string]string{"path": userConfig, "content": "{}\n"}) + + // No approver: fails closed even though the path is inside the roots. + if _, err := w.Execute(context.Background(), args); err == nil || !strings.Contains(err.Error(), "interactive user approval") { + t.Fatalf("in-root managed config write without an approver should be denied, got: %v", err) + } + if _, err := os.Stat(userConfig); !os.IsNotExist(err) { + t.Fatalf("user config must not be created without approval, stat err=%v", err) + } + + // Approved: the approver is consulted and the write lands. + approve := &stubConfigWriteApprover{allow: true} + ctx := tool.WithConfigWriteApprover(context.Background(), approve) + if _, err := w.Execute(ctx, args); err != nil { + t.Fatalf("approved in-root managed config write: %v", err) + } + if len(approve.asked) != 1 || approve.asked[0] != userConfig { + t.Fatalf("approver should be asked for the in-root config path, asked=%v", approve.asked) + } + + // Declined: reason surfaces, nothing lands. + decline := &stubConfigWriteApprover{allow: false, reason: "the user declined this Reasonix config write"} + dctx := tool.WithConfigWriteApprover(context.Background(), decline) + if err := os.Remove(userConfig); err != nil { + t.Fatalf("remove approved config before declined write: %v", err) + } + if _, err := w.Execute(dctx, args); err == nil || !strings.Contains(err.Error(), "declined") { + t.Fatalf("declined in-root managed config write should surface the reason, got: %v", err) + } + if _, err := os.Stat(userConfig); !os.IsNotExist(err) { + t.Fatalf("declined config must not be created, stat err=%v", err) + } + + // A plain file inside the roots still writes silently: the managed gate is + // file-level, not directory-level. + plain := filepath.Join(home, "notes.txt") + pargs, _ := json.Marshal(map[string]string{"path": plain, "content": "hi\n"}) + if _, err := w.Execute(context.Background(), pargs); err != nil { + t.Fatalf("plain in-root write must not consult the approver: %v", err) + } + if len(approve.asked) != 1 { + t.Fatalf("plain in-root write must not reach the approver, asked=%v", approve.asked) + } +} + func TestBashSandboxConfinement(t *testing.T) { if !sandbox.Available() { t.Skip("OS sandbox not available") diff --git a/internal/tool/builtin/managed_config.go b/internal/tool/builtin/managed_config.go index c1f049d1db..89ade827a8 100644 --- a/internal/tool/builtin/managed_config.go +++ b/internal/tool/builtin/managed_config.go @@ -59,8 +59,8 @@ func (m ManagedConfigPaths) Match(target string) bool { func (m ManagedConfigPaths) approve(ctx context.Context, target string) error { approver, ok := tool.ConfigWriteApproverFrom(ctx) if !ok { - 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. "+ - "Ask the user to retry in an interactive session, or to add the directory to [sandbox] allow_write in reasonix.toml", target) + return fmt.Errorf("path %q is a Reasonix-managed config file; writing it requires interactive user approval, which this session cannot provide. "+ + "Ask the user to retry in an interactive session; widening [sandbox] allow_write does not authorize Reasonix config edits", target) } req := tool.ConfigWriteRequest{Path: target} if checker, ok := approver.(tool.ConfigWriteSessionChecker); ok && checker.ManagedConfigWriteSessionAllowed(ctx, req) { From 2b3da8ab533a780c3f327d9a12dbb1a3ddb2c892 Mon Sep 17 00:00:00 2001 From: "Stephen R. van den Berg" Date: Wed, 9 Sep 2026 18:33:44 +0200 Subject: [PATCH 2/3] chore(repolint): regenerate baseline for rebase onto synced base --- tools/repolint/baseline.json | 123 +++++++++++++++++------------------ 1 file changed, 60 insertions(+), 63 deletions(-) diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json index 083a072a8b..5af5fd7874 100644 --- a/tools/repolint/baseline.json +++ b/tools/repolint/baseline.json @@ -2,15 +2,15 @@ "limits": { "banner": 13, "commented-code": 0, - "complexity": 1894, - "essay": 1891, - "file-size": 101568, - "function-size": 8257, + "complexity": 1852, + "essay": 1885, + "file-size": 100305, + "function-size": 8048, "layering": 1, "marker": 0, "narrative": 64, - "struct-state": 109, - "test-file-size": 68583 + "struct-state": 108, + "test-file-size": 68452 }, "files": { "cmd/e2ebench/main.go": { @@ -23,15 +23,15 @@ "essay": 19 }, "desktop/app.go": { - "complexity": 57, + "complexity": 55, "essay": 60, - "file-size": 10908, - "function-size": 354, + "file-size": 10825, + "function-size": 353, "struct-state": 10 }, "desktop/app_test.go": { "essay": 2, - "test-file-size": 10391 + "test-file-size": 10340 }, "desktop/bot_bridge.go": { "essay": 3 @@ -93,7 +93,7 @@ "file-size": 2643 }, "desktop/frontend/src/components/Composer.tsx": { - "file-size": 4002 + "file-size": 3858 }, "desktop/frontend/src/components/ContextPanel.tsx": { "file-size": 36 @@ -114,7 +114,7 @@ "file-size": 141 }, "desktop/frontend/src/components/SettingsPanel.tsx": { - "file-size": 7284 + "file-size": 6524 }, "desktop/frontend/src/components/StatusBar.tsx": { "file-size": 25 @@ -135,19 +135,19 @@ "file-size": 177 }, "desktop/frontend/src/lib/bridge.ts": { - "file-size": 4999 + "file-size": 4790 }, "desktop/frontend/src/lib/crash.ts": { "file-size": 212 }, "desktop/frontend/src/lib/transcriptStore.ts": { - "file-size": 264 + "file-size": 229 }, "desktop/frontend/src/lib/types.ts": { - "file-size": 1634 + "file-size": 1593 }, "desktop/frontend/src/lib/useController.ts": { - "file-size": 4267 + "file-size": 4252 }, "desktop/heartbeat.go": { "essay": 7 @@ -186,10 +186,10 @@ "essay": 4 }, "desktop/provider_access_removal.go": { - "essay": 4 + "essay": 3 }, "desktop/remote_app.go": { - "file-size": 745 + "file-size": 732 }, "desktop/remote_markdown_image.go": { "complexity": 12 @@ -199,7 +199,7 @@ }, "desktop/remote_tab.go": { "essay": 8, - "file-size": 182 + "file-size": 100 }, "desktop/remote_tab_commands.go": { "essay": 1 @@ -231,12 +231,12 @@ "desktop/session_takeover.go": { "complexity": 9, "essay": 20, - "file-size": 514, - "function-size": 22 + "file-size": 511, + "function-size": 19 }, "desktop/sessions.go": { "essay": 4, - "file-size": 403 + "file-size": 368 }, "desktop/sessions_test.go": { "test-file-size": 751 @@ -244,10 +244,10 @@ "desktop/settings_app.go": { "complexity": 29, "essay": 6, - "file-size": 3051 + "file-size": 2697 }, "desktop/settings_app_test.go": { - "test-file-size": 1814 + "test-file-size": 1808 }, "desktop/shared_host.go": { "essay": 1 @@ -262,10 +262,10 @@ "test-file-size": 151 }, "desktop/tabs.go": { - "complexity": 42, - "essay": 68, - "file-size": 7605, - "function-size": 364, + "complexity": 41, + "essay": 67, + "file-size": 7287, + "function-size": 363, "struct-state": 23 }, "desktop/tabs_order_test.go": { @@ -312,7 +312,7 @@ }, "desktop/updater.go": { "essay": 2, - "file-size": 597 + "file-size": 556 }, "desktop/updater_app.go": { "essay": 4 @@ -325,7 +325,7 @@ "test-file-size": 553 }, "desktop/updater_test.go": { - "test-file-size": 769 + "test-file-size": 752 }, "desktop/updater_windows.go": { "essay": 1 @@ -372,7 +372,7 @@ "complexity": 27, "essay": 25, "file-size": 2040, - "function-size": 104, + "function-size": 88, "struct-state": 2 }, "internal/agent/cache_shape_test.go": { @@ -409,8 +409,8 @@ "test-file-size": 487 }, "internal/agent/execute_batch.go": { - "complexity": 27, - "function-size": 167 + "complexity": 23, + "function-size": 152 }, "internal/agent/execute_one.go": { "complexity": 1, @@ -479,7 +479,7 @@ "internal/agent/save.go": { "complexity": 30, "essay": 79, - "file-size": 1419, + "file-size": 1393, "function-size": 103 }, "internal/agent/save_test.go": { @@ -527,7 +527,7 @@ "internal/agent/task.go": { "complexity": 3, "essay": 12, - "file-size": 1207, + "file-size": 1172, "function-size": 18 }, "internal/agent/task_test.go": { @@ -546,10 +546,10 @@ "test-file-size": 946 }, "internal/boot/boot.go": { - "complexity": 264, - "essay": 77, - "file-size": 2072, - "function-size": 1726, + "complexity": 261, + "essay": 78, + "file-size": 2016, + "function-size": 1714, "narrative": 3 }, "internal/boot/boot_test.go": { @@ -604,7 +604,7 @@ "internal/bot/gateway.go": { "complexity": 60, "essay": 19, - "file-size": 2276, + "file-size": 2274, "function-size": 265 }, "internal/bot/gateway_lock_test.go": { @@ -641,7 +641,7 @@ }, "internal/checkpoint/checkpoint.go": { "essay": 5, - "file-size": 174 + "file-size": 148 }, "internal/checkpoint/transaction.go": { "complexity": 32, @@ -663,10 +663,10 @@ "essay": 6 }, "internal/cli/chat_tui.go": { - "complexity": 318, - "essay": 82, - "file-size": 4583, - "function-size": 1277 + "complexity": 280, + "essay": 80, + "file-size": 4286, + "function-size": 1097 }, "internal/cli/chat_tui_paste.go": { "essay": 6 @@ -676,10 +676,10 @@ "test-file-size": 3609 }, "internal/cli/cli.go": { - "complexity": 90, + "complexity": 88, "essay": 22, - "file-size": 2000, - "function-size": 562 + "file-size": 1961, + "function-size": 549 }, "internal/cli/cli_test.go": { "essay": 1, @@ -805,7 +805,7 @@ }, "internal/config/config.go": { "essay": 22, - "file-size": 1552, + "file-size": 1512, "narrative": 2 }, "internal/config/credentials.go": { @@ -816,7 +816,7 @@ }, "internal/config/edit.go": { "essay": 5, - "file-size": 1680 + "file-size": 1615 }, "internal/config/edit_test.go": { "test-file-size": 2409 @@ -826,7 +826,7 @@ }, "internal/config/load.go": { "essay": 3, - "file-size": 1759, + "file-size": 1755, "function-size": 45 }, "internal/config/main_test.go": { @@ -866,10 +866,10 @@ "internal/control/controller.go": { "complexity": 12, "essay": 87, - "file-size": 5565, - "function-size": 83, + "file-size": 5103, + "function-size": 76, "narrative": 4, - "struct-state": 17 + "struct-state": 16 }, "internal/control/controller_test.go": { "essay": 1, @@ -955,9 +955,7 @@ "narrative": 1 }, "internal/eventwire/wire.go": { - "complexity": 4, - "file-size": 31, - "function-size": 9 + "complexity": 3 }, "internal/evidence/evidence.go": { "complexity": 6, @@ -1111,9 +1109,8 @@ "test-file-size": 238 }, "internal/jobs/jobs.go": { - "essay": 21, - "file-size": 1271, - "function-size": 12, + "essay": 18, + "file-size": 1156, "struct-state": 6 }, "internal/jobs/jobs_extra_test.go": { @@ -1206,7 +1203,7 @@ "internal/provider/openai/openai.go": { "complexity": 53, "essay": 25, - "file-size": 543, + "file-size": 481, "function-size": 181, "struct-state": 9 }, @@ -1223,7 +1220,7 @@ "internal/provider/responses/responses.go": { "complexity": 49, "essay": 2, - "file-size": 99, + "file-size": 15, "function-size": 161, "struct-state": 5 }, @@ -1352,7 +1349,7 @@ "file-size": 899 }, "internal/serve/serve_test.go": { - "test-file-size": 422 + "test-file-size": 365 }, "internal/serve/session_ownership.go": { "essay": 17, From e706be555128529fd7e6ca8ccefab6f65fad86f0 Mon Sep 17 00:00:00 2001 From: "Stephen R. van den Berg" Date: Wed, 9 Sep 2026 23:25:04 +0200 Subject: [PATCH 3/3] chore(repolint): bump baseline after managed-config rebase --- tools/repolint/baseline.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json index 5af5fd7874..3d3f88e87a 100644 --- a/tools/repolint/baseline.json +++ b/tools/repolint/baseline.json @@ -4,8 +4,8 @@ "commented-code": 0, "complexity": 1852, "essay": 1885, - "file-size": 100305, - "function-size": 8048, + "file-size": 100247, + "function-size": 8050, "layering": 1, "marker": 0, "narrative": 64, @@ -25,7 +25,7 @@ "desktop/app.go": { "complexity": 55, "essay": 60, - "file-size": 10825, + "file-size": 10826, "function-size": 353, "struct-state": 10 }, @@ -114,7 +114,7 @@ "file-size": 141 }, "desktop/frontend/src/components/SettingsPanel.tsx": { - "file-size": 6524 + "file-size": 6530 }, "desktop/frontend/src/components/StatusBar.tsx": { "file-size": 25 @@ -144,10 +144,10 @@ "file-size": 229 }, "desktop/frontend/src/lib/types.ts": { - "file-size": 1593 + "file-size": 1595 }, "desktop/frontend/src/lib/useController.ts": { - "file-size": 4252 + "file-size": 4258 }, "desktop/heartbeat.go": { "essay": 7 @@ -410,7 +410,7 @@ }, "internal/agent/execute_batch.go": { "complexity": 23, - "function-size": 152 + "function-size": 154 }, "internal/agent/execute_one.go": { "complexity": 1, @@ -951,7 +951,7 @@ }, "internal/event/event.go": { "essay": 6, - "file-size": 102, + "file-size": 56, "narrative": 1 }, "internal/eventwire/wire.go": { @@ -1215,7 +1215,7 @@ }, "internal/provider/provider.go": { "essay": 7, - "file-size": 314 + "file-size": 287 }, "internal/provider/responses/responses.go": { "complexity": 49,