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
18 changes: 11 additions & 7 deletions crates/tui/assets/skills/fleet-manager/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <worker-id>` or the matching
Runtime API worker endpoint.
2. Inspect the worker with `codewhale fleet inspect <worker-id>` or the
matching Runtime API worker endpoint (`GET /v1/fleet/workers/{worker_id}`).
3. Review bounded evidence with `codewhale fleet logs <worker-id>` and
`codewhale fleet artifacts <worker-id>`. Summarize artifact refs, not full
payloads.
`codewhale fleet artifacts <worker-id>`, 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.
Expand All @@ -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 <run-id>` (idempotent reconcile).
- transient and retry budget remains: `codewhale fleet restart <worker-id>`.
- transient but unsafe to retry: draft escalation and mark needs-human.
- task failure: preserve artifacts, summarize the failure, and avoid restart
Expand Down Expand Up @@ -99,7 +103,7 @@ Fleet receipt
Run: <run-id>
Workers checked: <count/list>
Classification: <state>
Action: <restart/interrupt/stop/escalation draft/no-op>
Action: <restart/interrupt/stop/resume/escalation draft/no-op>
Ledger expectation: <typed action should be recorded | draft only, no send>
Artifacts reviewed: <refs>
Follow-up owner: <manager | task owner | human>
Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/tools/canonical_action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
135 changes: 133 additions & 2 deletions crates/tui/src/tools/finance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -240,7 +241,7 @@ impl ToolSpec for FinanceTool {
true
}

async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let raw_ticker = match optional_str(&input, "ticker")? {
Some(ticker) => Some(ticker),
None => optional_str(&input, "symbol")?,
Expand All @@ -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 {
Expand All @@ -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" {
Expand Down Expand Up @@ -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
);
}
}
106 changes: 98 additions & 8 deletions crates/tui/src/tools/notify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<W: Write>(
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 {
Expand Down Expand Up @@ -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"));
}
}
4 changes: 2 additions & 2 deletions crates/tui/src/tools/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion crates/tui/src/tools/verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
})))
}

Expand Down
Loading
Loading