Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions internal/boot/boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -681,10 +681,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
Expand Down
25 changes: 13 additions & 12 deletions internal/tool/builtin/confine.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,22 +213,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
Expand Down
60 changes: 60 additions & 0 deletions internal/tool/builtin/confine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions internal/tool/builtin/managed_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down