mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
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:
1 parent
f0b853b1f0
commit
6a6d9bf298
3 files changed
+47
-18
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
)))
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user