From be4e26854f723206f4cb592582f8bfdc907fdb55 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 27 Aug 2026 20:43:13 +0800 Subject: [PATCH 1/5] fix(skills): complete fleet-manager typed-action guidance The fleet-manager skill listed the real CLI evidence verbs but omitted 'codewhale fleet resume' (the orphaned-lease reconcile action) and did not say that 'stop' requires '--all'. Add both, name the concrete Runtime API evidence endpoints (worker inspection, receipt evidence, event replay) alongside the CLI commands, and list resume in the post-run receipt action set. Signed-off-by: asto --- .../tui/assets/skills/fleet-manager/SKILL.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/tui/assets/skills/fleet-manager/SKILL.md b/crates/tui/assets/skills/fleet-manager/SKILL.md index 8947d3ba2a..067c58873b 100644 --- a/crates/tui/assets/skills/fleet-manager/SKILL.md +++ b/crates/tui/assets/skills/fleet-manager/SKILL.md @@ -14,8 +14,8 @@ and leave a ledgered receipt or a safe escalation draft. ## Authority Boundary - Prefer typed fleet surfaces over shell spelunking: `codewhale fleet status`, - `inspect`, `logs`, `artifacts`, `interrupt`, `restart`, `stop`, and the - Runtime API fleet endpoints. + `inspect`, `logs`, `artifacts`, `interrupt`, `restart`, `resume`, `stop` + (stop requires `--all`), and the Runtime API fleet endpoints. - Do not read `.codewhale/fleet.jsonl`, host logs, or remote files directly unless the typed command or API is missing required evidence. - Do not send Slack, webhook, PagerDuty, email, or chat messages unless the @@ -27,11 +27,13 @@ and leave a ledgered receipt or a safe escalation draft. 1. Identify the run and worker from the user request, run receipt, or fleet status output. If no worker is named, start with `codewhale fleet status`. -2. Inspect the worker with `codewhale fleet inspect ` or the matching - Runtime API worker endpoint. +2. Inspect the worker with `codewhale fleet inspect ` or the + matching Runtime API worker endpoint (`GET /v1/fleet/workers/{worker_id}`). 3. Review bounded evidence with `codewhale fleet logs ` and - `codewhale fleet artifacts `. Summarize artifact refs, not full - payloads. + `codewhale fleet artifacts `, or the Runtime API equivalents + (`GET /v1/fleet/runs/{run_id}/receipts/{task_id}/evidence` and + `GET /v1/fleet/runs/{run_id}/events/replay`). Summarize artifact refs, + not full payloads. 4. Classify the state before acting: - `transient failure`: transport error, timeout, stale heartbeat, host unavailable, or retryable provider/network failure. @@ -43,6 +45,8 @@ and leave a ledgered receipt or a safe escalation draft. action, repeated restart exhaustion, ambiguous product decision, or conflict between artifacts and verifier. 5. Choose one typed action: + - run has orphaned leases after a manager restart: + `codewhale fleet resume ` (idempotent reconcile). - transient and retry budget remains: `codewhale fleet restart `. - transient but unsafe to retry: draft escalation and mark needs-human. - task failure: preserve artifacts, summarize the failure, and avoid restart @@ -99,7 +103,7 @@ Fleet receipt Run: Workers checked: Classification: -Action: +Action: Ledger expectation: Artifacts reviewed: Follow-up owner: From d08b8d3a581e978aba248571a4b743ac4cfbda1b Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 27 Aug 2026 20:43:17 +0800 Subject: [PATCH 2/5] fix(tui): stop teaching retired tool names The verify tool description told models to 'use run_verifiers', a name retired in the v0.9.3 consolidation that cannot dispatch; point at the canonical 'Run with action=verifiers' form instead. Drop the retired 'exec_shell_wait' from the verifier background metadata poll_with list, keeping the live 'task_shell_wait'. Extend the no_advertised_tool_teaches_a_retired_name guard to scan the verify tool by adding with_verify_tool to its builder. Signed-off-by: asto --- crates/tui/src/tools/canonical_action.rs | 1 + crates/tui/src/tools/verifier.rs | 2 +- crates/tui/src/tools/verify.rs | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tools/canonical_action.rs b/crates/tui/src/tools/canonical_action.rs index 25086b3340..c3051413b9 100644 --- a/crates/tui/src/tools/canonical_action.rs +++ b/crates/tui/src/tools/canonical_action.rs @@ -235,6 +235,7 @@ mod tests { .with_test_runner_tool() .with_web_tools() .with_patch_tools() + .with_verify_tool(None, "guard-scan".to_string()) .build(ToolContext::new(tmp.path().to_path_buf())); for tool in registry.to_api_tools() { diff --git a/crates/tui/src/tools/verifier.rs b/crates/tui/src/tools/verifier.rs index 0a75204a28..e5b71e0caf 100644 --- a/crates/tui/src/tools/verifier.rs +++ b/crates/tui/src/tools/verifier.rs @@ -650,7 +650,7 @@ fn start_background_gates( "completion_surface": "task_status", "background_policy": "nonblocking", "task_ids": task_ids, - "poll_with": ["exec_shell_wait", "task_shell_wait"] + "poll_with": ["task_shell_wait"] }))) } diff --git a/crates/tui/src/tools/verify.rs b/crates/tui/src/tools/verify.rs index c5da50a57b..e8a43b6881 100644 --- a/crates/tui/src/tools/verify.rs +++ b/crates/tui/src/tools/verify.rs @@ -316,8 +316,8 @@ elevated reasoning and tries to REFUTE it, returning structured findings (issue, suggested fix). Call this when it is worth spending extra thinking: before claiming a non-trivial \ change complete, after a risky or subtle edit, or when you are unsure the change fully satisfies \ the requirement and handles edge cases. Skip it for trivial or mechanical changes. This is not a \ -test runner (use run_verifiers) or a code review of an arbitrary target (use review) — it is a \ -self-check of whether what you just did is actually correct and complete." +test runner (use Run with action=verifiers) or a code review of an arbitrary target (use review) \ +— it is a self-check of whether what you just did is actually correct and complete." } fn input_schema(&self) -> Value { From 16601dcf983af27ba023a53435ca3f45d484b23d Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 27 Aug 2026 20:43:22 +0800 Subject: [PATCH 3/5] fix(tui): document Bash background timeout semantics The timeout_ms schema implied the deadline applies to action=run generally, but the background spawn path schedules no kill: only the foreground wait is bounded (deadline kill + TimedOut). State that explicitly in timeout_ms and background, including the 1000-600000 clamp on the foreground/wait paths and how to bound or stop background work (action=wait timeout, action=cancel). Documentation aligns with current behavior; whether background tasks should become killable at the timeout is left for upstream discussion. Signed-off-by: asto --- crates/tui/src/tools/shell.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index ba315cfaf8..d5500d9715 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -3427,11 +3427,11 @@ impl ToolSpec for BashTool { }, "timeout_ms": { "type": "integer", - "description": "Timeout in milliseconds. The default depends on the action: action=run 120000 (capped at 600000), action=wait 30000, action=interact 1000. For action=wait, `timeout_secs` (seconds) and `timeout` (milliseconds) are accepted aliases." + "description": "Timeout in milliseconds. Only bounds the foreground wait: for action=run without background=true, the process is killed and reported TimedOut at the deadline (values clamped to 1000-600000). A background=true task is NOT killed at the timeout — it keeps running until it finishes, is cancelled (action=cancel), or is cleaned up; bound polling of background work with action=wait's timeout, or stop it with action=cancel. Defaults: action=run 120000, action=wait 30000, action=interact 1000. For action=wait, `timeout_secs` (seconds) and `timeout` (milliseconds) are accepted aliases." }, "background": { "type": "boolean", - "description": "Run in background and return task_id (default: false). Prefer this for commands expected to take >5 seconds." + "description": "Run in background and return task_id (default: false). Prefer this for commands expected to take >5 seconds. The task is not killed at timeout_ms; plan to poll it with action=wait or stop it with action=cancel." }, "interactive": { "type": "boolean", From f8efbdab9c99929880d378fb9ad3a71902aba450 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 27 Aug 2026 20:43:27 +0800 Subject: [PATCH 4/5] fix(tui): honor notifications method in notify tool The notify tool description and its registration comment both promise that [notifications].method = "off" silences the tool, but the tool hardcoded Method::Auto, so only quiet/events gating applied and a configured 'off' method still emitted. Install the configured method process-wide from settings() (same bridge as the NotificationGate) and have the tool read it; 'off' now returns before any sink write while the tool result stays a success (silent no-op). Known limitation, shared with the gate: the install happens on the first settings() call, so a first-turn notify in a fresh process predates it. Signed-off-by: asto --- crates/tui/src/tools/notify.rs | 106 +++++++++++++++++++++++-- crates/tui/src/tui/notifications.rs | 115 ++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 8 deletions(-) diff --git a/crates/tui/src/tools/notify.rs b/crates/tui/src/tools/notify.rs index 7eea1adca1..d6ff33906a 100644 --- a/crates/tui/src/tools/notify.rs +++ b/crates/tui/src/tools/notify.rs @@ -6,18 +6,23 @@ //! the tool is intended for "long task done, come back" beats and //! sub-agent-completion pings, not chatter. //! -//! Auto-suppresses when `[notifications].method = "off"`. Output messages +//! Honors the user's `[notifications]` config: `method = "off"` silences +//! the tool entirely, and `quiet` / `events.model-notify = false` gate the +//! category through the process-wide [`NotificationGate`]. Output messages //! are length-capped so a runaway model can't paint a paragraph into the //! terminal title bar. use async_trait::async_trait; use serde_json::{Value, json}; +use std::io::Write; use super::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_str, required_str, }; -use crate::tui::notifications::{Method, NotificationPayload, notify_done}; +use crate::tui::notifications::{ + Method, NotificationPayload, configured_method, current_notification_gate, notify_done_to, +}; /// Maximum chars passed through for the title — keeps the OSC 9 escape /// reasonable on terminals that wrap long titles awkwardly. @@ -104,23 +109,50 @@ impl ToolSpec for NotifyTool { .map(|v| !v.is_empty()) .unwrap_or(false); - // Threshold = 0 so the notification always fires; the model has - // already decided this is the moment. - notify_done( - Method::Auto, + // #1322 promise: the tool respects the user's configured + // `[notifications].method` — `off` makes this a silent no-op (the + // sink-level `Method::Off` check in `notify_done_to` returns before + // any write). Threshold = 0 so the notification always fires when + // not suppressed; the model has already decided this is the moment. + emit_model_notify( + configured_method(), in_tmux, &payload, - std::time::Duration::ZERO, - std::time::Duration::from_secs(1), + &mut std::io::stdout(), ); Ok(ToolResult::success(format!("notified: {title}"))) } } +/// Deliver a model-authored payload through the configured method and the +/// installed category gate to `sink`. +/// +/// Split from [`NotifyTool::execute`] with a `Write` sink so tests can pin +/// the suppression semantics (method `off`, gated category) without owning +/// the process stdout; production calls it with `io::stdout()`, exactly what +/// `notify_done` would have used. +pub(crate) fn emit_model_notify( + method: Method, + in_tmux: bool, + payload: &NotificationPayload, + sink: &mut W, +) { + notify_done_to( + method, + in_tmux, + payload, + std::time::Duration::ZERO, + std::time::Duration::from_secs(1), + current_notification_gate(), + sink, + ); +} + #[cfg(test)] mod tests { use super::*; + use crate::tui::notifications::install_configured_method; use std::path::Path; fn ctx() -> ToolContext { @@ -190,4 +222,62 @@ mod tests { assert!(required.iter().any(|v| v.as_str() == Some("title"))); assert!(!required.iter().any(|v| v.as_str() == Some("body"))); } + + /// Restores the process-wide configured method after a test mutates it, + /// mirroring `NotificationGateRestore` in `tui::notifications`. + struct ConfiguredMethodRestore(Method); + + impl ConfiguredMethodRestore { + fn capture() -> Self { + Self(configured_method()) + } + } + + impl Drop for ConfiguredMethodRestore { + fn drop(&mut self) { + install_configured_method(self.0); + } + } + + #[test] + fn method_off_makes_emission_a_silent_no_op() { + let payload = NotificationPayload::model_notify("done", None); + let mut sink = Vec::new(); + emit_model_notify(Method::Off, false, &payload, &mut sink); + assert!( + sink.is_empty(), + "method=off must not write any notification bytes" + ); + } + + #[test] + fn configured_method_off_silences_the_tool_emission() { + let _restore = ConfiguredMethodRestore::capture(); + install_configured_method(Method::Off); + + // The exact chain `execute` uses: the installed method decides, the + // gate is loaded from the process-wide state. + let payload = NotificationPayload::model_notify("done", None); + let mut sink = Vec::new(); + emit_model_notify(configured_method(), false, &payload, &mut sink); + assert!( + sink.is_empty(), + "configured method=off must silence the notify tool path" + ); + } + + #[tokio::test] + async fn configured_method_off_still_reports_success_to_the_model() { + // The description promises a *silent* no-op: the model sees success + // (nothing to retry), the user's desktop stays quiet. + let _restore = ConfiguredMethodRestore::capture(); + install_configured_method(Method::Off); + + let result = NotifyTool + .execute(json!({"title": "done"}), &ctx()) + .await + .expect("ok"); + assert!(result.success); + assert!(result.content.contains("done")); + } } diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index 60016ad5b0..1d32d9c64e 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -321,6 +321,55 @@ pub fn current_notification_gate() -> NotificationGate { NotificationGate::from_bits(NOTIFICATION_GATE.load(Ordering::SeqCst)) } +/// Process-wide effective `[notifications].method`, installed by +/// [`settings`] alongside the category gate. The `notify` tool reads this +/// instead of hardcoding `Auto` so a configured `method = "off"` silences +/// model-callable notifications too — the promise its description and the +/// registration comment in `tool_setup` both make. Default `Auto` mirrors +/// the pre-install behavior for contexts that never call [`settings`] +/// (unit tests, headless dispatch). +static CONFIGURED_METHOD: AtomicU8 = AtomicU8::new(0); + +/// Install `method` as the process-wide notification method for paths that +/// do not resolve a method of their own (the `notify` tool). +pub fn install_configured_method(method: Method) { + CONFIGURED_METHOD.store(method_to_bits(method), Ordering::SeqCst); +} + +/// The currently installed notification method (default `Auto`). +#[must_use] +pub fn configured_method() -> Method { + method_from_bits(CONFIGURED_METHOD.load(Ordering::SeqCst)) +} + +/// Pack a [`Method`] into the [`CONFIGURED_METHOD`] word. Kept next to the +/// static (like `NotificationGate::to_bits`) so the encoding has one home. +fn method_to_bits(method: Method) -> u8 { + match method { + Method::Auto => 0, + Method::Osc9 => 1, + Method::Bel => 2, + Method::MacOS => 3, + Method::Kitty => 4, + Method::Ghostty => 5, + Method::Off => 6, + } +} + +/// Unpack a [`Method`] from the [`CONFIGURED_METHOD`] word. Unknown bits +/// decode to `Auto`, the permissive pre-install default. +fn method_from_bits(bits: u8) -> Method { + match bits { + 1 => Method::Osc9, + 2 => Method::Bel, + 3 => Method::MacOS, + 4 => Method::Kitty, + 5 => Method::Ghostty, + 6 => Method::Off, + _ => Method::Auto, + } +} + /// Emit a notification to `sink` if the elapsed time meets or exceeds /// `threshold`, `method` is not `Off`, and `gate` allows the payload's /// category. @@ -937,6 +986,10 @@ pub fn settings(config: &crate::config::Config) -> Option<(Method, Duration, boo crate::config::NotificationMethod::Ghostty => Method::Ghostty, crate::config::NotificationMethod::Off => Method::Off, }; + // Install the configured method alongside the gate so the `notify` tool + // (which has no method of its own) honors `[notifications].method`, + // including `off` (#1322 promise; previously it hardcoded `Auto`). + install_configured_method(method); if let Some(condition) = config .tui @@ -1327,6 +1380,68 @@ mod tests { assert!(gate.turn_complete); } + /// Restores the process-wide configured method after a test mutates it. + struct ConfiguredMethodRestore(Method); + + impl ConfiguredMethodRestore { + fn capture() -> Self { + Self(configured_method()) + } + } + + impl Drop for ConfiguredMethodRestore { + fn drop(&mut self) { + install_configured_method(self.0); + } + } + + /// Same single-place contract as the gate: `settings()` must install the + /// configured `[notifications].method` so the `notify` tool (which has + /// no method of its own) honors it — including `off` (#1322 promise). + #[test] + fn settings_installs_configured_method_from_config() { + let _lock = env_lock(); + let _method_restore = ConfiguredMethodRestore::capture(); + let off: crate::config::Config = toml::from_str( + r#" + [notifications] + method = "off" + "#, + ) + .expect("method=off config should parse"); + let _ = settings(&off); + assert_eq!(configured_method(), Method::Off); + + let osc9: crate::config::Config = toml::from_str( + r#" + [notifications] + method = "osc9" + "#, + ) + .expect("method=osc9 config should parse"); + let _ = settings(&osc9); + assert_eq!(configured_method(), Method::Osc9); + } + + /// The packed encoding must round-trip every method so an install/read + /// pair can never silently fall back to `Auto`. + #[test] + fn configured_method_bits_round_trip_every_variant() { + for method in [ + Method::Auto, + Method::Osc9, + Method::Bel, + Method::MacOS, + Method::Kitty, + Method::Ghostty, + Method::Off, + ] { + assert_eq!(method_from_bits(method_to_bits(method)), method); + } + // Unknown words decode permissively to the pre-install default. + assert_eq!(method_from_bits(255), Method::Auto); + } + /// #5041 copy contract: interactive banners lead with the action and /// name the subject, instead of a bare "Approval needed". #[test] From c24997d3d4810587f77293a914f1ed2eaf3f9223 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 27 Aug 2026 20:43:32 +0800 Subject: [PATCH 5/5] fix(tui): gate finance tool by network policy The finance tool declared the Network capability but never consulted the session NetworkPolicyDecider, so a tightened session (network.default = deny) still reached query1.finance.yahoo.com. Check both configured endpoint hosts (quote and chart) before any request, matching the Web/web_search/speech family: Deny and undecided Prompt both fail closed with permission errors; no attached policy falls through permissively for back-compat. Testing both hosts up front closes the chart-fallback leak. Say so in the description. Signed-off-by: asto --- crates/tui/src/tools/finance.rs | 135 +++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/finance.rs b/crates/tui/src/tools/finance.rs index 22682a4455..0a5c351b7d 100644 --- a/crates/tui/src/tools/finance.rs +++ b/crates/tui/src/tools/finance.rs @@ -14,6 +14,7 @@ use super::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_str, optional_u64, }; +use crate::network_policy::Decision; const DEFAULT_TIMEOUT_MS: u64 = 10_000; const MAX_TIMEOUT_MS: u64 = 60_000; @@ -188,7 +189,7 @@ impl ToolSpec for FinanceTool { } fn description(&self) -> &'static str { - "Fetch a live market quote for a stock, ETF, or crypto ticker using Yahoo Finance-style public endpoints." + "Fetch a live market quote for a stock, ETF, or crypto ticker using Yahoo Finance-style public endpoints. Network-policy aware: both endpoint hosts are checked against the session network policy before any request, and policy rejections fail closed." } fn input_schema(&self) -> Value { @@ -240,7 +241,7 @@ impl ToolSpec for FinanceTool { true } - async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + async fn execute(&self, input: Value, context: &ToolContext) -> Result { let raw_ticker = match optional_str(&input, "ticker")? { Some(ticker) => Some(ticker), None => optional_str(&input, "symbol")?, @@ -259,6 +260,11 @@ impl ToolSpec for FinanceTool { let request = normalize_request(raw_ticker, type_hint); let timeout = Duration::from_millis(timeout_ms); + // #135: quote and chart hosts are both vetted before any transport + // fires, so a tightened session (e.g. network.default = "deny") + // cannot leak a request through the chart fallback. + check_network_policy(context, &self.endpoints)?; + let quote_result = fetch_quote_endpoint(&self.client, timeout, &self.endpoints, &request).await; match quote_result { @@ -280,6 +286,39 @@ impl ToolSpec for FinanceTool { } } +/// Fail closed when the session network policy denies (or has not approved) +/// either endpoint host. Mirrors the Web/web_search/speech family: `Deny` +/// and an undecided `Prompt` both stop before any request is made; no +/// attached policy falls through permissively for back-compat. +fn check_network_policy( + context: &ToolContext, + endpoints: &FinanceEndpoints, +) -> Result<(), ToolError> { + let Some(decider) = context.network_policy.as_ref() else { + return Ok(()); + }; + for base in [&endpoints.quote_base, &endpoints.chart_base] { + let Some(host) = crate::network_policy::host_from_url(base) else { + continue; + }; + match decider.evaluate(&host, "finance") { + Decision::Allow => {} + Decision::Deny => { + return Err(ToolError::permission_denied(format!( + "finance lookup to '{host}' blocked by network policy" + ))); + } + Decision::Prompt => { + return Err(ToolError::permission_denied(format!( + "finance lookup to '{host}' requires approval; \ + re-run after `/network allow {host}` or set network.default = \"allow\" in config" + ))); + } + } + } + Ok(()) +} + fn normalize_request(raw_ticker: &str, type_hint: Option<&str>) -> FinanceRequest { let requested_ticker = raw_ticker.trim().to_ascii_uppercase(); let resolved_symbol = if requested_ticker == "BTC" { @@ -952,4 +991,96 @@ mod tests { assert_eq!(any_of[0]["required"], json!(["ticker"])); assert_eq!(any_of[1]["required"], json!(["symbol"])); } + + fn denied_context_for(host: &str) -> (ToolContext, tempfile::TempDir) { + use crate::network_policy::{NetworkPolicy, NetworkPolicyDecider}; + let (ctx, tmp) = context(); + let policy = NetworkPolicy { + default: Decision::Allow.into(), + allow: Vec::new(), + deny: vec![host.to_string()], + proxy: Vec::new(), + proxy_fake_ip_cidrs: Vec::new(), + audit: false, + }; + ( + ctx.with_network_policy(NetworkPolicyDecider::new(policy, None)), + tmp, + ) + } + + #[tokio::test] + async fn finance_fails_closed_when_network_policy_denies_endpoint_host() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "quoteResponse": {"result": []} + }))) + .mount(&server) + .await; + + let host = reqwest::Url::parse(&server.uri()) + .expect("mock server URL") + .host_str() + .expect("mock server host") + .to_string(); + let (blocked, _tmp) = denied_context_for(&host); + + let tool = tool_with_server(&server); + let error = tool + .execute(json!({"ticker": "AAPL"}), &blocked) + .await + .expect_err("denied host must fail closed"); + assert!( + error.to_string().contains("blocked by network policy"), + "{error}" + ); + assert_eq!( + server + .received_requests() + .await + .expect("recorded requests") + .len(), + 0, + "no request may leave before the policy check" + ); + } + + #[tokio::test] + async fn finance_fails_closed_on_prompt_when_default_is_prompt() { + let server = MockServer::start().await; + + // default = prompt with no allow list: the undecided host must fail + // closed with the approval hint, never with a silent request. + let (ctx, tmp) = context(); + use crate::network_policy::{NetworkPolicy, NetworkPolicyDecider}; + let policy = NetworkPolicy { + default: Decision::Prompt.into(), + allow: Vec::new(), + deny: Vec::new(), + proxy: Vec::new(), + proxy_fake_ip_cidrs: Vec::new(), + audit: false, + }; + let blocked = ctx.with_network_policy(NetworkPolicyDecider::new(policy, None)); + drop(tmp); + + let tool = tool_with_server(&server); + let error = tool + .execute(json!({"ticker": "AAPL"}), &blocked) + .await + .expect_err("undecided host must not reach the endpoint"); + assert!( + error.to_string().contains("requires approval"), + "unexpected error: {error}" + ); + assert_eq!( + server + .received_requests() + .await + .expect("recorded requests") + .len(), + 0 + ); + } }