fix(engine): revert and private-host fetch follow the user's own choices

File revert no longer requires /trust or Full Access. The restore point
must still be this thread's own tool/pre-turn snapshot and the reviewed
hash must still match.

fetch_url keeps blocking private, loopback and link-local hosts by
default, but an explicit `/network allow <host>` now opts that host in.
Cloud metadata stays blocked. The error names the setting.

Verification: cargo check -p codewhale-net --lib and
cargo check -p codewhale-app-server --lib passed. codewhale-net
rejects_private_localhost_literal and rejects_private_rfc1918 passed
2/2. codewhale-app-server revert_file_helper_restores_only_the_named_file
passed 1/1.
This commit is contained in:
CodeWhale Bot committed 2026-11-07 22:12:47 -08:00
1 parent f0b853b1f0
commit 6a6d9bf298
3 files changed
+47 -18

No files matched your search

+1
View File
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **Reverting a file works in any posture.** Restoring a file the thread's own turns changed no longer requires `/trust` or Full Access; the exact snapshot and reviewed-hash checks still apply. **`fetch_url` can reach a local or private host you allowed** with `/network allow <host>`; cloud metadata stays blocked. Both refusals now name the setting.
- **`codewhale exec` offers tools by default**, still under the configured sandbox and approval posture. `--no-tools` is the one-shot. A pipe on stdin is the prompt when no arguments are given, or is appended to them.
- **A child no longer dies when usage settlement is not acknowledged.** The turn continues and cost stays unknown, matching the parent path.
- **Workspace memory works without a git origin.** A plain folder or a repo with no `origin` remote gets a stable workspace id. `codewhale plugin list` is an alias of `plugins status`. A heavy command runs after 30 seconds instead of waiting forever for an admission slot. An MCP registration 4xx names the status and the manual client-id path.
+2 -7
View File
@@ -9859,8 +9859,8 @@ struct RevertThreadFileResponse {
/// Ownership: only the `tool:`/`pre-turn:` restore points recorded on this
/// thread's own turns (`TurnRecord::workspace_snapshots`, fork-inherited turns
/// included) are candidates, never another thread's or the TUI's snapshots in
/// the same workspace, and the thread must be in trusted mode or Full Access. Nothing to revert is a `409`, not a silent
/// success, so the GUI can tell the user why the button did nothing.
/// the same workspace. Nothing to revert is a `409`, not a silent success, so
/// the GUI can tell the user why the button did nothing.
async fn revert_thread_file(
State(state): State<RuntimeApiState>,
Path(id): Path<String>,
@@ -9883,11 +9883,6 @@ async fn revert_thread_file(
.thread_restore_guard(&id)
.await
.map_err(map_thread_err)?;
if !(thread.trust_mode || thread.auto_approve) {
return Err(ApiError::conflict(
"Refusing to restore workspace files outside trusted mode. Turn on /trust or switch this thread to Full Access, then retry.",
));
}
// The restore points this thread owns: the receipts on its own turns
// (including turns a fork cloned). Read under the restore reservation, so
// no turn is recording one meanwhile.
+44 -11
View File
@@ -1,4 +1,4 @@
use crate::network_policy::NetworkPolicyDecider;
use crate::network_policy::{Decision, NetworkPolicyDecider};
use codewhale_tools::ToolError;
use std::net::IpAddr;
use std::time::Duration;
@@ -97,6 +97,32 @@ pub fn fetch_target_host(url: &reqwest::Url) -> Result<String, ToolError> {
.ok_or_else(|| ToolError::invalid_input("URL must include a host"))
}
fn explicitly_allowed(decider: Option<&NetworkPolicyDecider>, host: &str) -> bool {
let Some(decider) = decider else {
return false;
};
let policy = decider.policy();
if policy.decide(host) == Decision::Deny {
return false;
}
let normalized = host.trim().trim_end_matches('.').to_ascii_lowercase();
policy.allow.iter().any(|entry| {
let entry = entry.trim().trim_end_matches('.').to_ascii_lowercase();
match entry.strip_prefix("*.") {
Some(suffix) => normalized.ends_with(&format!(".{suffix}")),
None => entry == normalized,
}
})
}
fn cloud_metadata_ip(ip: &IpAddr) -> bool {
*ip == IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))
}
fn restricted_host_hint(host: &str) -> String {
format!("add {host:?} with `/network allow {host}` (or `network.allow` in config) to permit it")
}
pub async fn validate_fetch_destination(
host: String,
decider: Option<&NetworkPolicyDecider>,
@@ -105,13 +131,18 @@ pub async fn validate_fetch_destination(
) -> Result<DnsPin, ToolError> {
// SSRF protection: reject localhost names and private/link-local/loopback
// IP literals; when `resolve` is set, also resolve the hostname and reject
// restricted answers. Prevents LLM-prompted requests to cloud metadata
// (169.254.169.254), localhost services, and internal networks.
// restricted answers. An explicit `/network allow` for this host is the
// user's opt-in for a local dev server, an internal docs host or a tailnet
// peer. Cloud metadata stays blocked either way.
let bare = host.strip_suffix('.').unwrap_or(&host);
if bare == "localhost" || bare == "localhost.localdomain" || bare.ends_with(".localhost") {
return Err(ToolError::permission_denied(
"requests to localhost are not allowed",
));
let allowed = explicitly_allowed(decider, bare);
if !allowed
&& (bare == "localhost" || bare == "localhost.localdomain" || bare.ends_with(".localhost"))
{
return Err(ToolError::permission_denied(format!(
"requests to localhost are not allowed; {}",
restricted_host_hint(bare)
)));
}
// Normalize bracketed IPv6 literals before the literal-IP check so they
// route through the same restricted-IP policy as unbracketed forms
@@ -121,9 +152,10 @@ pub async fn validate_fetch_destination(
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host.as_str());
if let Ok(ip) = ip_candidate.parse::<IpAddr>() {
if is_restricted_ip(&ip) {
if is_restricted_ip(&ip) && (!allowed || cloud_metadata_ip(&ip)) {
return Err(ToolError::permission_denied(format!(
"IP {ip} is a restricted address (private/loopback/link-local)"
"IP {ip} is a restricted address (private/loopback/link-local); {}",
restricted_host_hint(bare)
)));
}
return Ok(None);
@@ -165,7 +197,7 @@ pub fn validate_dns_resolved_ip(
decider: Option<&NetworkPolicyDecider>,
tool: &str,
) -> Result<(), ToolError> {
if !is_restricted_ip(ip) {
if !is_restricted_ip(ip) || (explicitly_allowed(decider, host) && !cloud_metadata_ip(ip)) {
return Ok(());
}
@@ -182,7 +214,8 @@ pub fn validate_dns_resolved_ip(
}
Err(ToolError::permission_denied(format!(
"resolved IP {ip} is a restricted address (private/loopback/link-local)"
"resolved IP {ip} is a restricted address (private/loopback/link-local); {}",
restricted_host_hint(host)
)))
}