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
42 changes: 42 additions & 0 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2595,6 +2595,13 @@ pub struct Config {
#[serde(skip)]
pub loaded_config_path: Option<PathBuf>,

/// Runtime-only receipt that a higher-precedence layer supplied `[network]`
/// — a managed overlay. The resolved policy is then not the user
/// document's to replace: a mid-session re-read of that document must not
/// widen what the higher layer set.
#[serde(skip)]
pub(crate) network_layer_is_managed: bool,

/// A resolved startup snapshot never reads remembered route choices again.
/// False means an explicit config/profile owns the route instead.
#[serde(skip)]
Expand Down Expand Up @@ -3059,6 +3066,33 @@ impl NetworkPolicyToml {
}
}

/// Re-read the `[network]` table from a configuration document.
///
/// Deliberately narrower than [`Config::load`]: this runs on the tool-context
/// build path, where re-applying the environment, managed, and credential
/// layers would be both wasteful and wrong. Only the document's own table comes
/// back — the same table `/network allow <host>` edits.
///
/// `None` means there is nothing usable to adopt: the document is missing,
/// unreadable, unparseable, or carries no `[network]` table. Callers keep the
/// policy they already hold, which is the conservative direction for an
/// allow/deny gate.
///
/// Known limitation: `[profiles.<name>.network]` is not consulted. `/network`
/// does not write it either, so a live session and the command agree on this
/// base table.
#[must_use]
pub fn network_policy_from_document(path: &Path) -> Option<crate::network_policy::NetworkPolicy> {
#[derive(Deserialize)]
struct NetworkTable {
network: Option<NetworkPolicyToml>,
}

let contents = fs::read_to_string(path).ok()?;
let parsed: NetworkTable = toml::from_str(&contents).ok()?;
parsed.network.map(NetworkPolicyToml::into_runtime)
}

/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in
/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`
/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).
Expand Down Expand Up @@ -10447,6 +10481,8 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
notifications: override_cfg.notifications.or(base.notifications),
approval: override_cfg.approval.or(base.approval),
network: override_cfg.network.or(base.network),
network_layer_is_managed: override_cfg.network_layer_is_managed
|| base.network_layer_is_managed,
verifier: override_cfg.verifier.or(base.verifier),
advisor: override_cfg.advisor.or(base.advisor),
skills: merge_skills_config(base.skills, override_cfg.skills),
Expand Down Expand Up @@ -10972,6 +11008,12 @@ fn apply_managed_overrides(config: &mut Config) -> Result<()> {
}
merged.base_url_env_receipt = BaseUrlEnvReceipt::NoOwner;
}
// A managed `[network]` table outranks the user document. Record that the
// resolved policy is not the document's to replace, so a mid-session
// re-read of that document folds onto it instead of widening it.
if managed.network.is_some() {
merged.network_layer_is_managed = true;
}
*config = merged;
Ok(())
}
Expand Down
61 changes: 57 additions & 4 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7104,6 +7104,59 @@ impl Engine {
)
}

/// The network decider this turn runs under: the live policy, re-read from
/// the document the session was launched with.
///
/// `self.config.network_policy` is the snapshot `Engine::new` took when the
/// session was spawned. `/network allow <host>` edits `config.toml` and
/// promises "Retry the command now.", but it cannot reach that snapshot —
/// so without this re-read the host stayed refused until the next engine
/// spawn. Same shape as the workspace trust list loaded below, which also
/// re-reads per tool-context build so `/trust add` lands mid-session; a
/// hand edit of `[network]` lands through it too.
///
/// Both directions have to land. `/network deny <host>` writes the same
/// `[network]` table `/network allow` does, and a session that started
/// without one adopts it the moment it appears — otherwise tightening a
/// policy mid-session would need the restart this exists to remove.
///
/// A session that started gated keeps the policy it holds when the document
/// stops carrying a `[network]` table, so removing the table cannot leave a
/// session ungated by accident.
///
/// The session cache rides along on a refresh, so a host approved through
/// the approval prompt survives it. An adopted table has no cache to
/// inherit: the session had no decider, so nothing was ever approved under
/// one.
fn current_network_decider(&self) -> Option<crate::network_policy::NetworkPolicyDecider> {
let Some(path) = self.api_config.loaded_config_path.as_deref() else {
return self.config.network_policy.clone();
};
let Some(document) = crate::config::network_policy_from_document(path) else {
// The document carries no `[network]` table. Keep what the session
// resolved: removing a table must not ungate a gated run.
return self.config.network_policy.clone();
};
match self.config.network_policy.as_ref() {
// A managed overlay or a Fleet denial produced this policy, so the
// document is a lower layer: it may add hosts, but it may not widen
// the fallback or lift a denial.
Some(decider)
if decider.is_authoritative() || self.api_config.network_layer_is_managed =>
{
Some(
decider
.with_policy_refreshed(decider.policy().folded_with_lower_layer(document)),
)
}
Some(decider) => Some(decider.with_policy_refreshed(document)),
// Nothing resolved a policy for this session, so there is no higher
// layer to protect. Adopt the table the moment it exists — that is
// how `/network deny <host>` lands where there was no policy yet.
None => Some(crate::network_policy::NetworkPolicyDecider::with_default_audit(document)),
}
}

/// Build one tool context from the already-resolved turn authority and
/// route. A preview owns values that are deliberately not installed on the
/// session; rebuilding either from `self.session` would give it the prior
Expand Down Expand Up @@ -7210,8 +7263,8 @@ impl Engine {
ctx.memory_path = Some(self.config.memory_path.clone());
}

if let Some(decider) = self.config.network_policy.as_ref() {
ctx = ctx.with_network_policy(decider.clone());
if let Some(decider) = self.current_network_decider() {
ctx = ctx.with_network_policy(decider);
}

// Adaptive evidence routing is engine-native and opt-in
Expand Down Expand Up @@ -7420,8 +7473,8 @@ impl Engine {
}
pool = pool.with_backend(crate::mcp::McpBackend::from_config(&self.api_config));
pool = pool.with_disallowed_tools(self.config.disallowed_tools.clone().unwrap_or_default());
if let Some(decider) = self.config.network_policy.as_ref() {
pool = pool.with_network_policy(decider.clone());
if let Some(decider) = self.current_network_decider() {
pool = pool.with_network_policy(decider);
}
// The self-serve login tool honors the same pre-registered redirect
// overrides `/mcp login` uses, or providers with pinned callback
Expand Down
152 changes: 152 additions & 0 deletions crates/tui/src/core/engine/tests/test_cases_12.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1581,4 +1581,156 @@ fn turn_tool_context_uses_planned_authority_and_route_not_installed_session() {
.model,
"planned-next-model"
);
}

/// `/network allow <host>` edits `config.toml` and tells the operator to retry
/// the command. The engine holds its policy by value, so that retry only works
/// when the tool context re-reads the document — this is the regression guard
/// for a session that kept refusing a host the file already allowed.
#[test]
fn tool_context_network_policy_follows_the_config_document_mid_session() {
use crate::network_policy::{Decision, DecisionToml, NetworkPolicy, NetworkPolicyDecider};

let dir = tempdir().expect("temp dir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
"[network]\ndefault = \"prompt\"\nallow = [\"api.github.com\"]\n",
)
.expect("write config");

let api_config = Config {
loaded_config_path: Some(config_path),
..Config::default()
};

// The snapshot the engine was spawned with does not allow the host yet.
let engine_config = EngineConfig {
network_policy: Some(NetworkPolicyDecider::new(
NetworkPolicy {
default: DecisionToml::Prompt,
..NetworkPolicy::default()
},
None,
)),
..EngineConfig::default()
};
let (engine, _handle) = Engine::new(engine_config, &api_config);

let authority = crate::core::authority::TurnAuthority::from_effective_fields(
AppMode::Agent,
true,
true,
true,
ApprovalMode::Bypass,
);
let route = TurnRouteContext {
provider: ProviderKind::Deepseek,
model: "network-refresh-model".to_string(),
capabilities: codewhale_config::route::RouteCapabilities::default(),
limits: None,
client: None,
api_config: Box::new(Config::default()),
locale_tag: engine.config.locale_tag.clone(),
role_models: engine.subagent_role_models(),
auto_model: false,
reasoning_effort: None,
reasoning_effort_auto: false,
};

let context = engine.build_tool_context_for_turn(&authority, &route);
let decider = context
.network_policy
.as_ref()
.expect("the configured policy is injected");
assert_eq!(
decider.evaluate("api.github.com", "Bash"),
Decision::Allow,
"the document already allows the host; the retry must see it"
);
assert_eq!(
decider.evaluate("unlisted.example.com", "Bash"),
Decision::Prompt,
"a host the document does not name still prompts"
);
}

/// `/network deny <host>` writes the same `[network]` table `/network allow`
/// does. A session that started without one has to adopt it — otherwise
/// tightening the policy mid-session needs the restart this fix removes — and a
/// session that started gated must not lose its policy because the table went
/// away.
#[test]
fn tool_context_adopts_a_policy_written_mid_session_and_never_ungates_one() {
use crate::network_policy::{Decision, DecisionToml, NetworkPolicy, NetworkPolicyDecider};

let dir = tempdir().expect("temp dir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
"[network]\ndefault = \"prompt\"\ndeny = [\"evil.example.com\"]\n",
)
.expect("write config");

let api_config = Config {
loaded_config_path: Some(config_path.clone()),
..Config::default()
};

// A session that started with no `[network]` table at all adopts the deny.
let (engine, _handle) = Engine::new(EngineConfig::default(), &api_config);
let authority = crate::core::authority::TurnAuthority::from_effective_fields(
AppMode::Agent,
true,
true,
true,
ApprovalMode::Bypass,
);
let route = TurnRouteContext {
provider: ProviderKind::Deepseek,
model: "network-adopt-model".to_string(),
capabilities: codewhale_config::route::RouteCapabilities::default(),
limits: None,
client: None,
api_config: Box::new(Config::default()),
locale_tag: engine.config.locale_tag.clone(),
role_models: engine.subagent_role_models(),
auto_model: false,
reasoning_effort: None,
reasoning_effort_auto: false,
};
let context = engine.build_tool_context_for_turn(&authority, &route);
let decider = context
.network_policy
.as_ref()
.expect("a table written mid-session is adopted");
assert_eq!(
decider.evaluate("evil.example.com", "Bash"),
Decision::Deny,
"the deny direction must land mid-session too"
);

// Removing the table must not ungate a session that started gated.
fs::write(&config_path, "# `[network]` removed\n").expect("rewrite config");
let engine_config = EngineConfig {
network_policy: Some(NetworkPolicyDecider::new(
NetworkPolicy {
default: DecisionToml::Deny,
..NetworkPolicy::default()
},
None,
)),
..EngineConfig::default()
};
let (gated, _handle) = Engine::new(engine_config, &api_config);
let context = gated.build_tool_context_for_turn(&authority, &route);
let decider = context
.network_policy
.as_ref()
.expect("a gated session stays gated");
assert_eq!(
decider.evaluate("unlisted.example.com", "Bash"),
Decision::Deny,
"removing the table must not hand a gated session an open policy"
);
}
20 changes: 13 additions & 7 deletions crates/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14734,13 +14734,19 @@ fn exec_network_policy(
// Fleet caps are an outer authority boundary: user configuration may
// narrow them further, but it may never widen an explicit network denial.
if outer_network_access == Some(false) {
return Some(crate::network_policy::NetworkPolicyDecider::new(
crate::network_policy::NetworkPolicy {
default: crate::network_policy::DecisionToml::Deny,
..crate::network_policy::NetworkPolicy::default()
},
None,
));
// A Fleet denial is an outer authority the user's document may never
// widen, so mark the decider authoritative: a mid-session re-read of
// that document folds onto it instead of replacing it.
return Some(
crate::network_policy::NetworkPolicyDecider::new(
crate::network_policy::NetworkPolicy {
default: crate::network_policy::DecisionToml::Deny,
..crate::network_policy::NetworkPolicy::default()
},
None,
)
.with_authoritative(),
);
}
config.network.clone().map(|toml_cfg| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
Expand Down
Loading
Loading