mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 10:01:30 -10:00
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:
1 parent
fe4cf11c83
commit
2e417dd974
17 files changed
+389
-35
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user