fix(tui): stop granting shell network access just for writing the workspace

Ordinary workspace-write turns built their sandbox with
`network_access: true`, so agreeing to let a session edit this repository also
handed every shell command unrestricted outbound egress.

#273 introduced that grant, and justified it: the seatbelt default denies DNS,
which broke curl, yt-dlp, and package managers, and the comment argued the
application-level NetworkPolicy would remain "the only outbound boundary".
The second half was not true. NetworkPolicy governs fetch_url, web_search, and
MCP HTTP; it never saw a shell subprocess. So the layer meant to compensate for
the wide OS policy did not cover the thing the OS policy had opened, and
workspace-write sessions ran with no outbound boundary at any layer. This is
not tightening a working boundary — it is installing one that was missing.

Workspace-write is now created network-restricted. Egress comes from exactly
three explicit places:

  - `sandbox_network_access` in config (env: CODEWHALE_SANDBOX_NETWORK_ACCESS),
  - a `danger-full-access` posture, which applies no sandbox at all,
  - the existing post-denial elevation prompt, which grants network for one
    call after the user sees what was blocked.

Yolo and --yolo/Bypass are unchanged: they resolve to DangerFullAccess, so
their deliberate "no guardrails" contract still reports network. Plan stays
ReadOnly. Writable roots, tmpdir handling, and the git-worktree metadata roots
are untouched — only the network bit moved.

The decision is carried by a typed `SandboxNetworkAccess` rather than another
bool, so the default is stated once at the type instead of at each of the eight
resolver call sites, and "the user asked for network" cannot be transposed with
"some caller passed true".

Two surfaces were lying and now read the flag: `external-sandbox` hardcoded
`network_access: true` even when nothing granted it, and /status printed
"sandbox workspace-write, network on" for every workspace-write policy — true
only by accident of the old default.

Tests. The #273 regression test is retargeted rather than deleted: it now pins
that Agent mode still elevates *writes* while withholding network, which is the
property #273 actually needed. Added coverage for the full posture matrix
(Agent/Ask/Auto-Review/Never x configured overrides, plus Yolo and Plan), the
config key and its camelCase alias, and — the gap the audit surfaced — that the
generated seatbelt profile emits network rules if and only if the policy grants
them, so the OS layer and the application policy are verified to agree instead
of one being assumed to compensate for the other.

Verified: cargo test -p codewhale-tui --lib -> 10850 passed, 0 failed,
13 ignored (RUST_MIN_STACK=16777216, as scripts/dev-test.sh exports).
cargo fmt --all clean.

Platforms with no sandbox backend (default Linux without bubblewrap, and
Windows) still enforce nothing either way; /status and doctor continue to say
so, and that honesty gap is unchanged by this commit.

Implemented with agent assistance.

Signed-off-by: Hunter Bown <[email protected]>
This commit is contained in:
Hunter Bown committed 2026-08-20 07:53:15 -07:00
1 parent fe4cf11c83
commit 2e417dd974
17 files changed
+389 -35

No files matched your search

+20
View File
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- Editing the workspace no longer grants the shell outbound network access.
`workspace-write` sandboxes are created network-restricted; `curl`, package
installs, and `git fetch` inside a sandboxed shell are denied by the OS
sandbox unless network is granted explicitly. This closes a real gap rather
than tightening a working boundary: the elevation added in #273 was justified
by the application-level `NetworkPolicy` remaining "the only outbound
boundary", but that policy governs `fetch_url`, `web_search`, and MCP HTTP
and never constrained shell subprocesses, so workspace-write turns had
unrestricted egress with nothing enforcing anything. Network now comes from
one of three explicit places: the new `sandbox_network_access` config key
(also `CODEWHALE_SANDBOX_NETWORK_ACCESS`), a `danger-full-access` posture, or
the existing post-denial elevation prompt that grants network for a single
call. Yolo and `--yolo`/Bypass are unchanged — they resolve to
`danger-full-access`, which applies no sandbox at all. `external-sandbox`
reports the network it was actually granted instead of hardcoding `true`, and
`/status` reads the flag instead of printing "network on" for every
workspace-write policy. Platforms with no sandbox backend (default Linux
without bubblewrap, and Windows) still enforce nothing, and both `/status`
and `doctor` continue to say so.
- The nightly Windows ARM64 artifact build works again. Every nightly from
2026-08-16 failed while compiling `codewhale-tui`, deterministically on the
same codegen unit across all three build attempts, with
+12
View File
@@ -266,6 +266,18 @@ memory_path = "~/.codewhale/memory.md"
allow_shell = true
approval_policy = "on-request" # on-request | untrusted | never
sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox
# Whether a workspace-write sandbox also lets shell commands reach the
# network. Default false: being allowed to edit this repository is not a
# reason to be allowed to open outbound connections, so `curl`, package
# installs, and `git fetch` are denied by the OS sandbox unless you opt in.
# When a command is denied, Codewhale offers an elevation prompt that grants
# network for that call only; set this to `true` to grant it for the whole
# session instead. `danger-full-access` and `yolo` are unsandboxed and
# unaffected by this key. Note that on platforms with no OS sandbox backend
# (default Linux without bubblewrap, and Windows) nothing is enforced either
# way -- `/status` and `doctor` both say so.
# sandbox_network_access = false
# prompt_suggestion = true # opt-in: show ghost-text follow-up question in composer after each turn
# Optional tab/window title shown as `[title] …` in front of the terminal
+20
View File
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- Editing the workspace no longer grants the shell outbound network access.
`workspace-write` sandboxes are created network-restricted; `curl`, package
installs, and `git fetch` inside a sandboxed shell are denied by the OS
sandbox unless network is granted explicitly. This closes a real gap rather
than tightening a working boundary: the elevation added in #273 was justified
by the application-level `NetworkPolicy` remaining "the only outbound
boundary", but that policy governs `fetch_url`, `web_search`, and MCP HTTP
and never constrained shell subprocesses, so workspace-write turns had
unrestricted egress with nothing enforcing anything. Network now comes from
one of three explicit places: the new `sandbox_network_access` config key
(also `CODEWHALE_SANDBOX_NETWORK_ACCESS`), a `danger-full-access` posture, or
the existing post-denial elevation prompt that grants network for a single
call. Yolo and `--yolo`/Bypass are unchanged — they resolve to
`danger-full-access`, which applies no sandbox at all. `external-sandbox`
reports the network it was actually granted instead of hardcoding `true`, and
`/status` reads the flag instead of printing "network on" for every
workspace-write policy. Platforms with no sandbox backend (default Linux
without bubblewrap, and Windows) still enforce nothing, and both `/status`
and `doctor` continue to say so.
## [0.9.10] - 2026-08-19
- Show the full slash-command or `/model` completion row in a bounded, wrapping hover popover whenever narrow terminals truncate it, closing the remaining scoped gap from [#998](https://github.com/Hmbown/CodeWhale/issues/998). Thanks [@AiurArtanis](https://github.com/AiurArtanis) and [@formp3](https://github.com/formp3) for identifying the affected surfaces.
+1
View File
@@ -1868,6 +1868,7 @@ fn build_acp_tool_registry(
crate::tui::approval::ApprovalMode::Suggest,
config.sandbox_mode.as_deref(),
workspace,
crate::core::authority::SandboxNetworkAccess::from_config(config.sandbox_network_access),
);
let mut context = ToolContext::new(workspace)
.with_shell_policy(shell_policy)
@@ -171,6 +171,7 @@ fn safety_summary(app: &App) -> &'static str {
app.approval_mode,
app.configured_sandbox_mode.as_deref(),
&app.workspace,
crate::core::authority::SandboxNetworkAccess::from_config(app.configured_sandbox_network),
);
// The policy is the intent; `sandbox_backend` is what this platform can
// actually enforce with. Default Linux (bubblewrap is opt-in) and all
@@ -183,11 +184,22 @@ fn safety_summary(app: &App) -> &'static str {
"no OS sandbox on this platform (read-only requested, not enforced), network off"
}
crate::sandbox::SandboxPolicy::ReadOnly => "sandbox read-only, network off",
crate::sandbox::SandboxPolicy::WorkspaceWrite { .. } if unenforced => {
"no OS sandbox on this platform (workspace-write requested, not enforced), network on"
// Read the flag rather than assuming it. Workspace-write defaults to
// network-restricted, so a hardcoded "network on" here named a
// boundary the policy does not grant.
crate::sandbox::SandboxPolicy::WorkspaceWrite { network_access, .. } if unenforced => {
if network_access {
"no OS sandbox on this platform (workspace-write requested, not enforced), network on"
} else {
"no OS sandbox on this platform (workspace-write requested, not enforced), network requested off, not enforced"
}
}
crate::sandbox::SandboxPolicy::WorkspaceWrite { .. } => {
"sandbox workspace-write, network on"
crate::sandbox::SandboxPolicy::WorkspaceWrite { network_access, .. } => {
if network_access {
"sandbox workspace-write, network on"
} else {
"sandbox workspace-write, network off"
}
}
crate::sandbox::SandboxPolicy::DangerFullAccess => "sandbox disabled, network unrestricted",
crate::sandbox::SandboxPolicy::ExternalSandbox { .. } => {
@@ -401,7 +413,8 @@ mod tests {
if unenforced {
assert!(agent.contains("workspace-write requested, not enforced"));
} else {
assert!(agent.contains("sandbox workspace-write, network on"));
// workspace-write no longer implies egress; /status must say so.
assert!(agent.contains("sandbox workspace-write, network off"));
}
app.approval_mode = crate::tui::approval::ApprovalMode::Bypass;
@@ -413,9 +426,21 @@ mod tests {
if unenforced {
assert!(clamped.contains("workspace-write requested, not enforced"));
} else {
assert!(clamped.contains("sandbox workspace-write, network on"));
// Clamping full access down to workspace-write lands on the same
// restricted posture an ordinary Agent turn gets.
assert!(clamped.contains("sandbox workspace-write, network off"));
}
// The explicit opt-in is the only thing that flips the reported label.
app.configured_sandbox_network = Some(true);
let networked = format_status(&app);
if unenforced {
assert!(networked.contains("workspace-write requested, not enforced"));
} else {
assert!(networked.contains("sandbox workspace-write, network on"));
}
app.configured_sandbox_network = None;
app.mode = AppMode::Plan;
let plan = format_status(&app);
if unenforced {
+16
View File
@@ -2860,6 +2860,14 @@ pub struct Config {
pub approval_policy: Option<String>,
#[serde(alias = "sandboxMode")]
pub sandbox_mode: Option<String>,
/// Whether a workspace-write sandbox also grants the shell outbound
/// network access. Defaults to `false`: editing the workspace is not a
/// reason to be able to reach the internet. Network comes from an explicit
/// opt-in here, from a `danger-full-access` posture, or from the
/// post-denial elevation prompt. `yolo`/`Bypass` is unaffected — it
/// resolves to `danger-full-access`, which is unsandboxed by definition.
#[serde(alias = "sandboxNetworkAccess")]
pub sandbox_network_access: Option<bool>,
/// `telemetry` as written to the config file, before environment and
/// default resolution. Kept so doctor and config displays can state the
/// *resolved* consent with its source (default | env | config) instead of
@@ -8904,6 +8912,11 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo
{
config.sandbox_mode = Some(value);
}
if let Ok(value) = std::env::var("CODEWHALE_SANDBOX_NETWORK_ACCESS")
.or_else(|_| std::env::var("DEEPSEEK_SANDBOX_NETWORK_ACCESS"))
{
config.sandbox_network_access = Some(value == "1" || value.eq_ignore_ascii_case("true"));
}
if let Ok(value) = std::env::var("CODEWHALE_YOLO").or_else(|_| std::env::var("DEEPSEEK_YOLO")) {
config.yolo = Some(value == "1" || value.eq_ignore_ascii_case("true"));
}
@@ -10038,6 +10051,9 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
verbosity: override_cfg.verbosity.or(base.verbosity),
approval_policy: override_cfg.approval_policy.or(base.approval_policy),
sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),
sandbox_network_access: override_cfg
.sandbox_network_access
.or(base.sandbox_network_access),
fallback_providers: if override_cfg.fallback_providers.is_empty() {
base.fallback_providers
} else {
+20
View File
@@ -648,6 +648,26 @@ fn warns_when_allow_shell_nested_under_general_section() {
assert!(parsed.base.allow_shell());
}
#[test]
fn sandbox_network_access_parses_and_defaults_to_restricted() {
// Absent key: restricted. Editing the workspace does not imply egress.
let parsed: ConfigFile =
toml::from_str("sandbox_mode = \"workspace-write\"\n").expect("parse without the key");
assert_eq!(parsed.base.sandbox_network_access, None);
let parsed: ConfigFile =
toml::from_str("sandbox_network_access = true\n").expect("parse snake_case");
assert_eq!(parsed.base.sandbox_network_access, Some(true));
let parsed: ConfigFile =
toml::from_str("sandboxNetworkAccess = true\n").expect("parse camelCase alias");
assert_eq!(parsed.base.sandbox_network_access, Some(true));
let parsed: ConfigFile =
toml::from_str("sandbox_network_access = false\n").expect("parse explicit false");
assert_eq!(parsed.base.sandbox_network_access, Some(false));
}
#[test]
fn load_honors_codewhale_home_for_primary_config_path() -> Result<()> {
let _lock = lock_test_env();
+145 -13
View File
@@ -220,12 +220,14 @@ impl TurnAuthority {
&self,
workspace: &Path,
configured_mode: Option<&str>,
network_access: SandboxNetworkAccess,
) -> SandboxPolicy {
sandbox_policy_for_turn(
self.mode,
self.approval_mode_for_session(),
configured_mode,
workspace,
network_access,
)
}
}
@@ -339,13 +341,14 @@ pub(crate) fn sandbox_policy_for_turn(
approval_mode: ApprovalMode,
configured_mode: Option<&str>,
workspace: &Path,
network_access: SandboxNetworkAccess,
) -> SandboxPolicy {
let default = if mode == AppMode::Plan {
SandboxPolicy::ReadOnly
} else if mode == AppMode::Yolo || approval_mode == ApprovalMode::Bypass {
SandboxPolicy::DangerFullAccess
} else {
workspace_write_policy(workspace)
workspace_write_policy(workspace, network_access)
};
// The effective Config has already applied managed/project precedence.
@@ -354,21 +357,54 @@ pub(crate) fn sandbox_policy_for_turn(
match (default, configured_mode) {
(SandboxPolicy::ReadOnly, _) | (_, Some("read-only")) => SandboxPolicy::ReadOnly,
(SandboxPolicy::DangerFullAccess, Some("workspace-write")) => {
workspace_write_policy(workspace)
workspace_write_policy(workspace, network_access)
}
(SandboxPolicy::DangerFullAccess, Some("external-sandbox")) => {
SandboxPolicy::ExternalSandbox {
network_access: true,
network_access: network_access.is_allowed(),
}
}
(policy, _) => policy,
}
}
fn workspace_write_policy(workspace: &Path) -> SandboxPolicy {
/// Whether a sandboxed turn may open outbound connections.
///
/// Typed rather than a bare `bool` so the two call-site meanings — "the user
/// asked for network" and "some caller passed true" — cannot be transposed
/// silently, and so the default is spelled at the type instead of at each of
/// the seven resolver call sites.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum SandboxNetworkAccess {
/// No outbound network inside the sandbox. Editing the workspace does not
/// imply reaching the internet.
#[default]
Restricted,
/// Outbound network explicitly granted by config, policy, or an approved
/// elevation.
Allowed,
}
impl SandboxNetworkAccess {
#[must_use]
pub(crate) fn from_config(configured: Option<bool>) -> Self {
if configured.unwrap_or(false) {
Self::Allowed
} else {
Self::Restricted
}
}
#[must_use]
pub(crate) fn is_allowed(self) -> bool {
matches!(self, Self::Allowed)
}
}
fn workspace_write_policy(workspace: &Path, network_access: SandboxNetworkAccess) -> SandboxPolicy {
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![workspace.to_path_buf()],
network_access: true,
network_access: network_access.is_allowed(),
exclude_tmpdir: false,
exclude_slash_tmp: false,
}
@@ -809,40 +845,136 @@ mod tests {
let full_access = authority(AppMode::Agent, true, ApprovalMode::Bypass);
assert_eq!(
full_access.sandbox_policy(workspace, None),
full_access.sandbox_policy(workspace, None, SandboxNetworkAccess::Restricted),
SandboxPolicy::DangerFullAccess
);
// Clamping full-access down to workspace-write must land on the same
// restricted posture an ordinary Agent turn gets, not on a wider one.
assert!(matches!(
full_access.sandbox_policy(workspace, Some("workspace-write")),
SandboxPolicy::WorkspaceWrite { writable_roots, .. }
if writable_roots == vec![workspace.to_path_buf()]
full_access.sandbox_policy(
workspace,
Some("workspace-write"),
SandboxNetworkAccess::Restricted
),
SandboxPolicy::WorkspaceWrite { writable_roots, network_access, .. }
if writable_roots == vec![workspace.to_path_buf()] && !network_access
));
assert_eq!(
full_access.sandbox_policy(workspace, Some("read-only")),
full_access.sandbox_policy(
workspace,
Some("read-only"),
SandboxNetworkAccess::Restricted
),
SandboxPolicy::ReadOnly
);
// The external sandbox no longer claims network unconditionally; it
// reports what was actually granted.
assert!(matches!(
full_access.sandbox_policy(workspace, Some("external-sandbox")),
full_access.sandbox_policy(
workspace,
Some("external-sandbox"),
SandboxNetworkAccess::Restricted
),
SandboxPolicy::ExternalSandbox {
network_access: false
}
));
assert!(matches!(
full_access.sandbox_policy(
workspace,
Some("external-sandbox"),
SandboxNetworkAccess::Allowed
),
SandboxPolicy::ExternalSandbox {
network_access: true
}
));
}
#[test]
fn workspace_write_never_grants_network_without_an_explicit_opt_in() {
let workspace = Path::new("/work");
// Every non-Yolo posture, with and without a configured sandbox mode.
for approval_mode in [
ApprovalMode::Suggest,
ApprovalMode::Auto,
ApprovalMode::Never,
] {
for configured in [None, Some("workspace-write"), Some("danger-full-access")] {
let auth = authority(AppMode::Agent, false, approval_mode);
let policy =
auth.sandbox_policy(workspace, configured, SandboxNetworkAccess::Restricted);
assert!(
!policy.has_network_access(),
"{approval_mode:?}/{configured:?} leaked network: {policy:?}"
);
}
}
// Yolo/Bypass is deliberately unsandboxed and keeps its semantics:
// DangerFullAccess reports network regardless of this key, because it
// applies no sandbox at all.
let yolo = authority(AppMode::Yolo, true, ApprovalMode::Bypass);
let policy = yolo.sandbox_policy(workspace, None, SandboxNetworkAccess::Restricted);
assert_eq!(policy, SandboxPolicy::DangerFullAccess);
assert!(policy.has_network_access());
// Plan is read-only and denies network under either setting.
let plan = authority(AppMode::Plan, false, ApprovalMode::Suggest);
for access in [
SandboxNetworkAccess::Restricted,
SandboxNetworkAccess::Allowed,
] {
assert!(
!plan
.sandbox_policy(workspace, None, access)
.has_network_access()
);
}
}
#[test]
fn sandbox_network_access_defaults_to_restricted() {
assert_eq!(
SandboxNetworkAccess::default(),
SandboxNetworkAccess::Restricted
);
assert_eq!(
SandboxNetworkAccess::from_config(None),
SandboxNetworkAccess::Restricted
);
assert_eq!(
SandboxNetworkAccess::from_config(Some(false)),
SandboxNetworkAccess::Restricted
);
assert_eq!(
SandboxNetworkAccess::from_config(Some(true)),
SandboxNetworkAccess::Allowed
);
}
#[test]
fn plan_ask_and_auto_review_cannot_be_loosened_by_sandbox_config() {
let workspace = Path::new("/work");
for approval_mode in [ApprovalMode::Suggest, ApprovalMode::Auto] {
let authority = authority(AppMode::Agent, false, approval_mode);
assert!(matches!(
authority.sandbox_policy(workspace, Some("danger-full-access")),
authority.sandbox_policy(
workspace,
Some("danger-full-access"),
SandboxNetworkAccess::Restricted
),
SandboxPolicy::WorkspaceWrite { .. }
));
}
let plan = authority(AppMode::Plan, true, ApprovalMode::Bypass);
assert_eq!(
plan.sandbox_policy(workspace, Some("danger-full-access")),
plan.sandbox_policy(
workspace,
Some("danger-full-access"),
SandboxNetworkAccess::Restricted
),
SandboxPolicy::ReadOnly
);
}
+9
View File
@@ -3237,6 +3237,9 @@ impl Engine {
approval_mode,
self.api_config.sandbox_mode.as_deref(),
&self.config.workspace,
crate::core::authority::SandboxNetworkAccess::from_config(
self.api_config.sandbox_network_access,
),
);
let mut lines = vec![
format!("Current local date: {today}"),
@@ -5482,6 +5485,9 @@ impl Engine {
context.elevated_sandbox_policy = Some(authority.sandbox_policy(
&self.session.workspace,
self.api_config.sandbox_mode.as_deref(),
crate::core::authority::SandboxNetworkAccess::from_config(
self.api_config.sandbox_network_access,
),
));
context.shell_network_denied_hint = matches!(authority.mode, AppMode::Plan)
.then(|| PLAN_SHELL_NETWORK_DENIED_HINT.to_string());
@@ -5590,6 +5596,9 @@ impl Engine {
let policy = authority.sandbox_policy(
&self.session.workspace,
self.api_config.sandbox_mode.as_deref(),
crate::core::authority::SandboxNetworkAccess::from_config(
self.api_config.sandbox_network_access,
),
);
let mut ctx = ctx.with_elevated_sandbox_policy(policy);
if matches!(authority.mode, AppMode::Plan) {
+67 -16
View File
@@ -12075,7 +12075,9 @@ fn mode_invariant_matrix_covers_context_catalog_subagents_and_prompt_metadata()
"{}",
case.name
);
assert!(*network_access, "{}", case.name);
// Workspace-write grants writes, not egress. Network is a
// separate, explicit grant in every mode that reaches here.
assert!(!*network_access, "{}", case.name);
}
(ExpectedSandbox::DangerFullAccess, SandboxPolicy::DangerFullAccess) => {}
_ => panic!("{}: unexpected sandbox policy {sandbox:?}", case.name),
@@ -12429,12 +12431,17 @@ fn turn_tool_context_uses_planned_authority_and_route_not_installed_session() {
}
#[test]
fn agent_and_yolo_modes_elevate_shell_sandbox_to_allow_network() {
// Regression for #273: the seatbelt-default policy denies all outbound
// network (including DNS), which broke `curl`, `yt-dlp`, package managers,
// and similar shell commands in Agent mode. Elevation must include
// network access so the application-level NetworkPolicy stays the only
// outbound boundary.
fn agent_mode_elevates_writes_without_granting_network() {
// #273 elevated Agent mode's sandbox so `curl`, package managers, and
// similar shell commands worked, and justified it by saying the
// application-level NetworkPolicy would remain "the only outbound
// boundary". That premise did not hold: NetworkPolicy governs
// fetch_url/web_search/MCP HTTP and never constrained shell subprocesses,
// so workspace-write turns had unrestricted egress with no boundary at
// all. Writing to the workspace is now decoupled from reaching the
// network: the write elevation stays, the network grant does not.
// Network comes from `sandbox_network_access`, from a danger-full-access
// posture, or from the post-denial elevation prompt.
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
let agent_ctx = engine.build_tool_context(AppMode::Agent, false);
@@ -12443,8 +12450,14 @@ fn agent_and_yolo_modes_elevate_shell_sandbox_to_allow_network() {
.as_ref()
.expect("Agent mode should elevate the sandbox policy");
assert!(
agent_policy.has_network_access(),
"Agent mode must allow shell network access; got {agent_policy:?}",
!agent_policy.has_network_access(),
"Agent mode must not grant shell network access by default; got {agent_policy:?}",
);
assert!(
!agent_policy
.get_writable_roots(&std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
.is_empty(),
"Agent mode must still elevate workspace writes; got {agent_policy:?}",
);
let yolo_ctx = engine.build_tool_context(AppMode::Yolo, false);
@@ -12498,7 +12511,7 @@ fn agent_and_yolo_modes_elevate_shell_sandbox_to_allow_network() {
#[test]
fn sandbox_policy_for_turn_returns_correct_default_policy_per_mode() {
use crate::core::authority::sandbox_policy_for_turn;
use crate::core::authority::{SandboxNetworkAccess, sandbox_policy_for_turn};
use crate::sandbox::SandboxPolicy;
use crate::tui::approval::ApprovalMode;
@@ -12506,26 +12519,64 @@ fn sandbox_policy_for_turn_returns_correct_default_policy_per_mode() {
// Plan: ReadOnly. The whole point of #1077.
assert!(matches!(
sandbox_policy_for_turn(AppMode::Plan, ApprovalMode::Suggest, None, &workspace,),
sandbox_policy_for_turn(
AppMode::Plan,
ApprovalMode::Suggest,
None,
&workspace,
SandboxNetworkAccess::Restricted,
),
SandboxPolicy::ReadOnly
));
// Agent: WorkspaceWrite with workspace as writable root, network on.
match sandbox_policy_for_turn(AppMode::Agent, ApprovalMode::Suggest, None, &workspace) {
// Agent: WorkspaceWrite with workspace as writable root, network OFF.
match sandbox_policy_for_turn(
AppMode::Agent,
ApprovalMode::Suggest,
None,
&workspace,
SandboxNetworkAccess::Restricted,
) {
SandboxPolicy::WorkspaceWrite {
writable_roots,
network_access,
..
} => {
assert_eq!(writable_roots, vec![workspace.clone()]);
assert!(network_access, "Agent mode must allow shell network access");
assert!(
!network_access,
"workspace-write must not imply shell network access"
);
}
other => panic!("Agent mode should be WorkspaceWrite; got {other:?}"),
}
// Agent with the explicit opt-in: same posture, network on.
match sandbox_policy_for_turn(
AppMode::Agent,
ApprovalMode::Suggest,
None,
&workspace,
SandboxNetworkAccess::Allowed,
) {
SandboxPolicy::WorkspaceWrite { network_access, .. } => {
assert!(
network_access,
"sandbox_network_access = true must grant shell network access"
);
}
other => panic!("Agent mode should be WorkspaceWrite; got {other:?}"),
}
// YOLO: DangerFullAccess.
assert!(matches!(
sandbox_policy_for_turn(AppMode::Yolo, ApprovalMode::Suggest, None, &workspace,),
sandbox_policy_for_turn(
AppMode::Yolo,
ApprovalMode::Suggest,
None,
&workspace,
SandboxNetworkAccess::Restricted,
),
SandboxPolicy::DangerFullAccess
));
}
@@ -12712,7 +12763,7 @@ async fn live_runtime_authority_applies_latest_posture_and_sandbox_before_tools(
None,
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![tmp.path().to_path_buf()],
network_access: true,
network_access: false,
exclude_tmpdir: false,
exclude_slash_tmp: false,
},
+3
View File
@@ -2056,6 +2056,9 @@ impl Engine {
batch_approval_mode,
self.api_config.sandbox_mode.as_deref(),
&self.session.workspace,
crate::core::authority::SandboxNetworkAccess::from_config(
self.api_config.sandbox_network_access,
),
);
let batch_sandbox_read_only = matches!(
&batch_sandbox_policy,
+1
View File
@@ -10899,6 +10899,7 @@ async fn build_direct_workflow_tool(
},
config.sandbox_mode.as_deref(),
workspace,
crate::core::authority::SandboxNetworkAccess::from_config(config.sandbox_network_access),
));
let network_policy = config.network.clone().map(|network| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
+38
View File
@@ -718,6 +718,44 @@ mod existing_tests {
);
}
#[test]
fn seatbelt_profile_grants_network_only_when_the_policy_does() {
// The OS layer and the application-level policy must agree. The
// seatbelt base profile is `(deny default)` with no network rules, so
// absence of SEATBELT_NETWORK_POLICY is a real denial, not a gap.
let cwd = Path::new("/tmp/test");
let restricted = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![cwd.to_path_buf()],
network_access: false,
exclude_tmpdir: false,
exclude_slash_tmp: false,
};
let text = generate_policy(&restricted, cwd);
assert!(
!text.contains("network-outbound"),
"a network-restricted policy must not emit outbound rules:\n{text}"
);
assert!(!text.contains("network-inbound"));
assert!(!text.contains("network-bind"));
let allowed = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![cwd.to_path_buf()],
network_access: true,
exclude_tmpdir: false,
exclude_slash_tmp: false,
};
let text = generate_policy(&allowed, cwd);
assert!(
text.contains("network-outbound"),
"an explicitly granted policy must emit outbound rules:\n{text}"
);
// Default construction is restricted, so the shipped default profile
// carries no network rules.
assert!(!generate_policy(&SandboxPolicy::default(), cwd).contains("network-outbound"));
}
#[test]
fn test_create_seatbelt_args() {
let policy = SandboxPolicy::default();
+3
View File
@@ -1281,6 +1281,9 @@ pub struct App {
/// Effective explicit/managed filesystem scope captured at startup. The
/// named permission posture supplies the default when this is `None`.
pub configured_sandbox_mode: Option<String>,
/// Configured `sandbox_network_access`. `None`/`Some(false)` keep the
/// workspace-write sandbox network-restricted.
pub configured_sandbox_network: Option<bool>,
/// The sandbox backend this platform+config can actually enforce with,
/// resolved once at startup. `None` means there is NO enforcement
/// available (default Linux without `prefer_bwrap`, and all Windows), so
+1
View File
@@ -763,6 +763,7 @@ impl App {
goal_max_continuations: config.goal_max_continuations(),
goal_continuation_waiting: false,
configured_sandbox_mode: config.sandbox_mode.clone(),
configured_sandbox_network: config.sandbox_network_access,
sandbox_backend: crate::sandbox::get_platform_sandbox_with_bwrap_preference(
config.prefer_bwrap.unwrap_or(false),
),
+1
View File
@@ -2367,6 +2367,7 @@ pub(crate) fn apply_setup_runtime_preset(
config.allow_shell = Some(preset.allow_shell());
config.sandbox_mode = Some(preset.sandbox_mode().to_string());
app.configured_sandbox_mode = config.sandbox_mode.clone();
app.configured_sandbox_network = config.sandbox_network_access;
let approval_mode = ApprovalMode::from_config_value(
preset
+1
View File
@@ -674,6 +674,7 @@ fn filesystem_scope_label(app: &App) -> Cow<'static, str> {
app.approval_mode,
app.configured_sandbox_mode.as_deref(),
&app.workspace,
crate::core::authority::SandboxNetworkAccess::from_config(app.configured_sandbox_network),
);
// A policy is an intent; enforcement needs a backend. On default Linux
// (bubblewrap is opt-in) and on all Windows there is none, and this chip