| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "path/filepath" |
| 7 | "runtime" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/event" |
| 12 | "reasonix/internal/permission" |
| 13 | "reasonix/internal/permissionpreset" |
| 14 | "reasonix/internal/sandbox" |
| 15 | ) |
| 16 | |
| 17 | func TestPermissionPresetChangeWaitsForApprovalCommit(t *testing.T) { |
| 18 | previousProcs := runtime.GOMAXPROCS(1) |
| 19 | t.Cleanup(func() { runtime.GOMAXPROCS(previousProcs) }) |
| 20 | |
| 21 | workspace := t.TempDir() |
| 22 | extra, err := filepath.EvalSymlinks(t.TempDir()) |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | roots := sandbox.NewWritableRootSet([]string{workspace}) |
| 27 | c := newOwnedTestController(t, Options{WriteRoots: roots, WorkspaceRoot: workspace}) |
| 28 | c.SetToolApprovalMode(ToolApprovalWorkspaceWrite) |
| 29 | |
| 30 | entered := make(chan struct{}) |
| 31 | release := make(chan struct{}) |
| 32 | c.sink = event.FuncSink(func(e event.Event) { |
| 33 | if e.Kind == event.PromptAnswered { |
| 34 | close(entered) |
| 35 | <-release |
| 36 | } |
| 37 | }) |
| 38 | id, reply := c.approval.registerWriteAccess("bash", "outside", "test", json.RawMessage(`{}`), &event.WriteAccessApproval{ |
| 39 | Directories: []string{extra}, |
| 40 | }) |
| 41 | before := c.PermissionSnapshot() |
| 42 | |
| 43 | resolved := make(chan error, 1) |
| 44 | go func() { |
| 45 | resolved <- c.ResolveApprovalAt(id, true, sandbox.ApprovalScopeSession, before.Generation, before.Revision) |
| 46 | }() |
| 47 | select { |
| 48 | case <-entered: |
| 49 | case <-time.After(5 * time.Second): |
| 50 | t.Fatal("approval did not reach its commit barrier") |
| 51 | } |
| 52 | |
| 53 | switched := make(chan struct{}) |
| 54 | go func() { |
| 55 | c.SetToolApprovalMode(ToolApprovalReadOnly) |
| 56 | close(switched) |
| 57 | }() |
| 58 | // With one scheduler P, Gosched lets the preset goroutine run until it is |
| 59 | // blocked behind the approval transaction. Publishing a revision before |
| 60 | // that lock is acquired exposes a mixed permission snapshot. |
| 61 | runtime.Gosched() |
| 62 | if got := c.permissionRevision.Load(); got != before.Revision { |
| 63 | close(release) |
| 64 | t.Fatalf("permission revision became visible during approval commit: got %d, want %d", got, before.Revision) |
| 65 | } |
| 66 | during := c.PermissionSnapshot() |
| 67 | if during.Revision != before.Revision || during.Preset != before.Preset { |
| 68 | close(release) |
| 69 | t.Fatalf("permission snapshot changed during approval commit: before=%+v during=%+v", before, during) |
| 70 | } |
| 71 | |
| 72 | close(release) |
| 73 | if err := <-resolved; err != nil { |
| 74 | t.Fatalf("approval that committed first was rejected: %v", err) |
| 75 | } |
| 76 | if got := <-reply; !got.allow || !got.session { |
| 77 | t.Fatalf("approval reply = %+v, want session allow", got) |
| 78 | } |
| 79 | <-switched |
| 80 | after := c.PermissionSnapshot() |
| 81 | if after.Preset != ToolApprovalReadOnly || after.Revision <= before.Revision { |
| 82 | t.Fatalf("permission snapshot after switch = %+v", after) |
| 83 | } |
| 84 | if !roots.Covers(extra) { |
| 85 | t.Fatal("session grant committed before the preset switch was lost") |
| 86 | } |
| 87 | found := false |
| 88 | for _, grant := range after.Grants { |
| 89 | if grant.Scope == "directory" && grant.Target == extra { |
| 90 | found = true |
| 91 | } |
| 92 | } |
| 93 | if !found { |
| 94 | t.Fatalf("committed session grant missing from permission snapshot: %+v", after.Grants) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestResolveApprovalAtRejectsStalePermissionRevision(t *testing.T) { |
| 99 | c := newOwnedTestController(t, Options{Policy: permission.New("ask", nil, nil, nil)}) |
| 100 | id, reply := c.approval.registerWriteAccess("bash", "outside", "test", json.RawMessage(`{}`), &event.WriteAccessApproval{}) |
| 101 | revision := c.permissionRevision.Load() |
| 102 | if err := c.ResolveApprovalAt(id, true, sandbox.ApprovalScopeOnce, c.runtimeGeneration, revision+1); !errors.Is(err, ErrPromptStaleRuntime) { |
| 103 | t.Fatalf("ResolveApprovalAt error = %v, want ErrPromptStaleRuntime", err) |
| 104 | } |
| 105 | select { |
| 106 | case got := <-reply: |
| 107 | t.Fatalf("stale response resolved approval: %+v", got) |
| 108 | default: |
| 109 | } |
| 110 | if err := c.ResolveApprovalAt(id, false, sandbox.ApprovalScopeOnce, c.runtimeGeneration, revision); err != nil { |
| 111 | t.Fatalf("current response failed: %v", err) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestPermissionPresetChangePublishesRevisionBeforeOldApprovalCanResolve(t *testing.T) { |
| 116 | c := newOwnedTestController(t, Options{Policy: permission.New("ask", nil, nil, nil)}) |
| 117 | id, reply := c.approval.registerWriteAccess("bash", "outside", "test", json.RawMessage(`{}`), &event.WriteAccessApproval{}) |
| 118 | before := c.PermissionSnapshot() |
| 119 | after, _, err := c.SetPermissionPreset(ToolApprovalDangerFullAccess, before.Revision) |
| 120 | if err != nil { |
| 121 | t.Fatalf("SetPermissionPreset: %v", err) |
| 122 | } |
| 123 | if after.Revision <= before.Revision { |
| 124 | t.Fatalf("revision did not advance: before=%d after=%d", before.Revision, after.Revision) |
| 125 | } |
| 126 | if err := c.ResolveApprovalAt(id, true, sandbox.ApprovalScopeOnce, before.Generation, before.Revision); !errors.Is(err, ErrPromptStaleRuntime) { |
| 127 | t.Fatalf("old approval reply error = %v, want ErrPromptStaleRuntime", err) |
| 128 | } |
| 129 | select { |
| 130 | case got := <-reply: |
| 131 | t.Fatalf("stale response resolved approval: %+v", got) |
| 132 | default: |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func TestSettingSamePermissionPresetKeepsRevisionStable(t *testing.T) { |
| 137 | c := newOwnedTestController(t, Options{Policy: permission.New("allow", nil, nil, nil)}) |
| 138 | c.SetToolApprovalMode(ToolApprovalDangerFullAccess) |
| 139 | before := c.PermissionSnapshot() |
| 140 | after, _, err := c.SetPermissionPreset(before.Preset, before.Revision) |
| 141 | if err != nil { |
| 142 | t.Fatalf("SetPermissionPreset: %v", err) |
| 143 | } |
| 144 | if after.Revision != before.Revision { |
| 145 | t.Fatalf("same preset changed revision: before=%d after=%d", before.Revision, after.Revision) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func TestPermissionSnapshotAndExactGrantRevocation(t *testing.T) { |
| 150 | workspace := t.TempDir() |
| 151 | extra := t.TempDir() |
| 152 | roots := sandbox.NewWritableRootSet([]string{workspace}) |
| 153 | roots.GrantSession([]string{extra}) |
| 154 | c := newOwnedTestController(t, Options{Policy: permission.New("allow", nil, nil, nil), WriteRoots: roots, WorkspaceRoot: workspace}) |
| 155 | snapshot := c.PermissionSnapshot() |
| 156 | if snapshot.Preset == "" || snapshot.WorkspaceRoot != workspace { |
| 157 | t.Fatalf("permission snapshot = %+v", snapshot) |
| 158 | } |
| 159 | found := false |
| 160 | for _, grant := range snapshot.Grants { |
| 161 | if grant.Scope == "directory" && grant.Target != "" { |
| 162 | found = true |
| 163 | var err error |
| 164 | snapshot, err = c.RevokeSessionGrant(grant.Scope, grant.Target, snapshot.Revision) |
| 165 | if err != nil { |
| 166 | t.Fatalf("RevokeSessionGrant: %v", err) |
| 167 | } |
| 168 | break |
| 169 | } |
| 170 | } |
| 171 | if !found { |
| 172 | t.Fatalf("directory grant missing from snapshot: %+v", snapshot.Grants) |
| 173 | } |
| 174 | if roots.Covers(extra) { |
| 175 | t.Fatal("revoked directory remains writable") |
| 176 | } |
| 177 | if _, err := c.RevokeSessionGrant("directory", extra, snapshot.Revision-1); err == nil { |
| 178 | t.Fatal("stale grant revocation should fail") |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func TestWindowsPermissionCapabilitiesKeepPresetsWithoutBackend(t *testing.T) { |
| 183 | // The Windows backend is retired: regardless of what the host reports, |
| 184 | // no isolation is advertised, yet every preset remains selectable because |
| 185 | // the presets are enforced by Reasonix's own tools there. |
| 186 | for _, available := range []bool{true, false} { |
| 187 | got := permissionCapabilitiesForPlatform("windows", available, "no OS sandbox") |
| 188 | if got.Backend != "none" || got.Enforcement != "unavailable" || got.UnavailableReason != "no OS sandbox" { |
| 189 | t.Fatalf("available=%v: Windows capability summary = %+v", available, got) |
| 190 | } |
| 191 | if got.WriteIsolation != "" || got.ReadIsolation != "" || got.NetworkIsolation != "" { |
| 192 | t.Fatalf("available=%v: Windows advertised isolation: %+v", available, got) |
| 193 | } |
| 194 | if len(got.SupportedPresets) != 3 { |
| 195 | t.Fatalf("available=%v: supported presets = %v", available, got.SupportedPresets) |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | func TestUnavailablePermissionBackendOnlyOffersFullAccess(t *testing.T) { |
| 201 | got := permissionCapabilitiesForPlatform("linux", false, "native API unavailable") |
| 202 | if got.Enforcement != "unavailable" || got.UnavailableReason != "native API unavailable" { |
| 203 | t.Fatalf("unavailable capability summary = %+v", got) |
| 204 | } |
| 205 | if len(got.SupportedPresets) != 1 || got.SupportedPresets[0] != string(permissionpreset.DangerFullAccess) { |
| 206 | t.Fatalf("supported presets = %v", got.SupportedPresets) |
| 207 | } |
| 208 | if got.WriteIsolation != "" || got.ReadIsolation != "" || got.NetworkIsolation != "" { |
| 209 | t.Fatalf("unavailable backend advertised active isolation: %+v", got) |
| 210 | } |
| 211 | } |
| 212 |