feat: make sandbox read access configurable with ReadOnlyAccess (#11387)

`SandboxPolicy::ReadOnly` previously implied broad read access and could
not express a narrower read surface.
This change introduces an explicit read-access model so we can support
user-configurable read restrictions in follow-up work, while preserving
current behavior today.

It also ensures unsupported backends fail closed for restricted-read
policies instead of silently granting broader access than intended.

## What

- Added `ReadOnlyAccess` in protocol with:
  - `Restricted { include_platform_defaults, readable_roots }`
  - `FullAccess`
- Updated `SandboxPolicy` to carry read-access configuration:
  - `ReadOnly { access: ReadOnlyAccess }`
  - `WorkspaceWrite { ..., read_only_access: ReadOnlyAccess }`
- Preserved existing behavior by defaulting current construction paths
to `ReadOnlyAccess::FullAccess`.
- Threaded the new fields through sandbox policy consumers and call
sites across `core`, `tui`, `linux-sandbox`, `windows-sandbox`, and
related tests.
- Updated Seatbelt policy generation to honor restricted read roots by
emitting scoped read rules when full read access is not granted.
- Added fail-closed behavior on Linux and Windows backends when
restricted read access is requested but not yet implemented there
(`UnsupportedOperation`).
- Regenerated app-server protocol schema and TypeScript artifacts,
including `ReadOnlyAccess`.

## Compatibility / rollout

- Runtime behavior remains unchanged by default (`FullAccess`).
- API/schema changes are in place so future config wiring can enable
restricted read access without another policy-shape migration.
This commit is contained in:
Michael Bolin
2026-02-11 18:31:14 -08:00
committed by GitHub
parent 572ab66496
commit abbd74e2be
79 changed files with 1797 additions and 188 deletions

View File

@@ -628,6 +628,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
let workspace_write = |network_access| SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
@@ -841,7 +842,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_request_requires_approval",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_on_request.txt"),
content: "read-only-approval",
@@ -861,7 +862,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_request_requires_approval_gpt_5_1_no_exit",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_on_request_5_1.txt"),
content: "read-only-approval",
@@ -881,7 +882,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "trusted_command_on_request_read_only_runs_without_prompt",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::RunCommand {
command: "echo trusted-read-only",
},
@@ -896,7 +897,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "trusted_command_on_request_read_only_runs_without_prompt_gpt_5_1_no_exit",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::RunCommand {
command: "echo trusted-read-only",
},
@@ -911,7 +912,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_request_blocks_network",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::FetchUrl {
endpoint: "/ro/network-blocked",
response_body: "should-not-see",
@@ -925,7 +926,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_request_denied_blocks_execution",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_on_request_denied.txt"),
content: "should-not-write",
@@ -946,7 +947,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_failure_escalates_after_sandbox_error",
approval_policy: OnFailure,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_on_failure.txt"),
content: "read-only-on-failure",
@@ -967,7 +968,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_failure_escalates_after_sandbox_error_gpt_5_1_no_exit",
approval_policy: OnFailure,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_on_failure_5_1.txt"),
content: "read-only-on-failure",
@@ -987,7 +988,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_request_network_escalates_when_approved",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::FetchUrl {
endpoint: "/ro/network-approved",
response_body: "read-only-network-ok",
@@ -1006,7 +1007,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_request_network_escalates_when_approved_gpt_5_1_no_exit",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::FetchUrl {
endpoint: "/ro/network-approved",
response_body: "read-only-network-ok",
@@ -1178,7 +1179,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_unless_trusted_requires_approval",
approval_policy: UnlessTrusted,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_unless_trusted.txt"),
content: "read-only-unless-trusted",
@@ -1198,7 +1199,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_unless_trusted_requires_approval_gpt_5_1_no_exit",
approval_policy: UnlessTrusted,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_unless_trusted_5_1.txt"),
content: "read-only-unless-trusted",
@@ -1218,7 +1219,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_never_reports_sandbox_failure",
approval_policy: Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_never.txt"),
content: "read-only-never",
@@ -1242,7 +1243,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "trusted_command_never_runs_without_prompt",
approval_policy: Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::RunCommand {
command: "echo trusted-never",
},
@@ -1407,7 +1408,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "unified exec on request escalated requires approval",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::RunUnifiedExecCommand {
command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'",
justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION),
@@ -1574,6 +1575,7 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file()
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
@@ -1687,7 +1689,7 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file()
async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts() -> Result<()> {
let server = start_mock_server().await;
let approval_policy = AskForApproval::UnlessTrusted;
let sandbox_policy = SandboxPolicy::ReadOnly;
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.approval_policy = Constrained::allow_any(approval_policy);