chore(release): prepare v0.8.22

Validate redirected fetch targets before following them and prepare v0.8.22.
This commit is contained in:
Hunter Bown
2026-05-08 13:34:26 -05:00
committed by GitHub
parent 1fc892e604
commit 8b60275981
14 changed files with 190 additions and 128 deletions
Generated
+14 -14
View File
@@ -1152,7 +1152,7 @@ dependencies = [
[[package]]
name = "deepseek-agent"
version = "0.8.21"
version = "0.8.22"
dependencies = [
"deepseek-config",
"serde",
@@ -1160,7 +1160,7 @@ dependencies = [
[[package]]
name = "deepseek-app-server"
version = "0.8.21"
version = "0.8.22"
dependencies = [
"anyhow",
"axum",
@@ -1182,7 +1182,7 @@ dependencies = [
[[package]]
name = "deepseek-config"
version = "0.8.21"
version = "0.8.22"
dependencies = [
"anyhow",
"deepseek-secrets",
@@ -1194,7 +1194,7 @@ dependencies = [
[[package]]
name = "deepseek-core"
version = "0.8.21"
version = "0.8.22"
dependencies = [
"anyhow",
"chrono",
@@ -1212,7 +1212,7 @@ dependencies = [
[[package]]
name = "deepseek-execpolicy"
version = "0.8.21"
version = "0.8.22"
dependencies = [
"anyhow",
"deepseek-protocol",
@@ -1221,7 +1221,7 @@ dependencies = [
[[package]]
name = "deepseek-hooks"
version = "0.8.21"
version = "0.8.22"
dependencies = [
"anyhow",
"async-trait",
@@ -1235,7 +1235,7 @@ dependencies = [
[[package]]
name = "deepseek-mcp"
version = "0.8.21"
version = "0.8.22"
dependencies = [
"anyhow",
"serde",
@@ -1244,7 +1244,7 @@ dependencies = [
[[package]]
name = "deepseek-protocol"
version = "0.8.21"
version = "0.8.22"
dependencies = [
"serde",
"serde_json",
@@ -1252,7 +1252,7 @@ dependencies = [
[[package]]
name = "deepseek-secrets"
version = "0.8.21"
version = "0.8.22"
dependencies = [
"dirs",
"keyring",
@@ -1265,7 +1265,7 @@ dependencies = [
[[package]]
name = "deepseek-state"
version = "0.8.21"
version = "0.8.22"
dependencies = [
"anyhow",
"chrono",
@@ -1277,7 +1277,7 @@ dependencies = [
[[package]]
name = "deepseek-tools"
version = "0.8.21"
version = "0.8.22"
dependencies = [
"anyhow",
"async-trait",
@@ -1290,7 +1290,7 @@ dependencies = [
[[package]]
name = "deepseek-tui"
version = "0.8.21"
version = "0.8.22"
dependencies = [
"anyhow",
"arboard",
@@ -1351,7 +1351,7 @@ dependencies = [
[[package]]
name = "deepseek-tui-cli"
version = "0.8.21"
version = "0.8.22"
dependencies = [
"anyhow",
"chrono",
@@ -1375,7 +1375,7 @@ dependencies = [
[[package]]
name = "deepseek-tui-core"
version = "0.8.21"
version = "0.8.22"
[[package]]
name = "deranged"
+1 -1
View File
@@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"]
resolver = "2"
[workspace.package]
version = "0.8.21"
version = "0.8.22"
edition = "2024"
# Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the
# codebase relies on extensively. Cargo enforces this so users on older
+1 -1
View File
@@ -7,5 +7,5 @@ repository.workspace = true
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
[dependencies]
deepseek-config = { path = "../config", version = "0.8.21" }
deepseek-config = { path = "../config", version = "0.8.22" }
serde.workspace = true
+9 -9
View File
@@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect
anyhow.workspace = true
axum.workspace = true
clap.workspace = true
deepseek-agent = { path = "../agent", version = "0.8.21" }
deepseek-config = { path = "../config", version = "0.8.21" }
deepseek-core = { path = "../core", version = "0.8.21" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.21" }
deepseek-hooks = { path = "../hooks", version = "0.8.21" }
deepseek-mcp = { path = "../mcp", version = "0.8.21" }
deepseek-protocol = { path = "../protocol", version = "0.8.21" }
deepseek-state = { path = "../state", version = "0.8.21" }
deepseek-tools = { path = "../tools", version = "0.8.21" }
deepseek-agent = { path = "../agent", version = "0.8.22" }
deepseek-config = { path = "../config", version = "0.8.22" }
deepseek-core = { path = "../core", version = "0.8.22" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.22" }
deepseek-hooks = { path = "../hooks", version = "0.8.22" }
deepseek-mcp = { path = "../mcp", version = "0.8.22" }
deepseek-protocol = { path = "../protocol", version = "0.8.22" }
deepseek-state = { path = "../state", version = "0.8.22" }
deepseek-tools = { path = "../tools", version = "0.8.22" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
+7 -7
View File
@@ -14,13 +14,13 @@ path = "src/main.rs"
anyhow.workspace = true
clap.workspace = true
clap_complete.workspace = true
deepseek-agent = { path = "../agent", version = "0.8.21" }
deepseek-app-server = { path = "../app-server", version = "0.8.21" }
deepseek-config = { path = "../config", version = "0.8.21" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.21" }
deepseek-mcp = { path = "../mcp", version = "0.8.21" }
deepseek-secrets = { path = "../secrets", version = "0.8.21" }
deepseek-state = { path = "../state", version = "0.8.21" }
deepseek-agent = { path = "../agent", version = "0.8.22" }
deepseek-app-server = { path = "../app-server", version = "0.8.22" }
deepseek-config = { path = "../config", version = "0.8.22" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.22" }
deepseek-mcp = { path = "../mcp", version = "0.8.22" }
deepseek-secrets = { path = "../secrets", version = "0.8.22" }
deepseek-state = { path = "../state", version = "0.8.22" }
chrono.workspace = true
dirs.workspace = true
serde.workspace = true
+1 -1
View File
@@ -8,7 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite
[dependencies]
anyhow.workspace = true
deepseek-secrets = { path = "../secrets", version = "0.8.21" }
deepseek-secrets = { path = "../secrets", version = "0.8.22" }
dirs.workspace = true
serde.workspace = true
toml.workspace = true
+8 -8
View File
@@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
deepseek-agent = { path = "../agent", version = "0.8.21" }
deepseek-config = { path = "../config", version = "0.8.21" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.21" }
deepseek-hooks = { path = "../hooks", version = "0.8.21" }
deepseek-mcp = { path = "../mcp", version = "0.8.21" }
deepseek-protocol = { path = "../protocol", version = "0.8.21" }
deepseek-state = { path = "../state", version = "0.8.21" }
deepseek-tools = { path = "../tools", version = "0.8.21" }
deepseek-agent = { path = "../agent", version = "0.8.22" }
deepseek-config = { path = "../config", version = "0.8.22" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.22" }
deepseek-hooks = { path = "../hooks", version = "0.8.22" }
deepseek-mcp = { path = "../mcp", version = "0.8.22" }
deepseek-protocol = { path = "../protocol", version = "0.8.22" }
deepseek-state = { path = "../state", version = "0.8.22" }
deepseek-tools = { path = "../tools", version = "0.8.22" }
serde_json.workspace = true
uuid.workspace = true
+1 -1
View File
@@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace
[dependencies]
anyhow.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.8.21" }
deepseek-protocol = { path = "../protocol", version = "0.8.22" }
serde.workspace = true
+1 -1
View File
@@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc
anyhow.workspace = true
async-trait.workspace = true
chrono.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.8.21" }
deepseek-protocol = { path = "../protocol", version = "0.8.22" }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
+1 -1
View File
@@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.8.21" }
deepseek-protocol = { path = "../protocol", version = "0.8.22" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
+2 -2
View File
@@ -21,8 +21,8 @@ path = "src/main.rs"
[dependencies]
anyhow = "1.0.100"
arboard = "3.4"
deepseek-secrets = { path = "../secrets", version = "0.8.21" }
deepseek-tools = { path = "../tools", version = "0.8.21" }
deepseek-secrets = { path = "../secrets", version = "0.8.22" }
deepseek-tools = { path = "../tools", version = "0.8.22" }
schemaui = { version = "0.12.0", default-features = false, optional = true }
async-stream = "0.3.6"
async-trait = "0.1"
+2 -2
View File
@@ -618,8 +618,8 @@ mod tests {
fn package_version_is_current_hotfix_release() {
assert_eq!(
env!("CARGO_PKG_VERSION"),
"0.8.21",
"0.8.21 release branch must report the release version before publishing"
"0.8.22",
"0.8.22 release branch must report the release version before publishing"
);
}
+140 -78
View File
@@ -10,7 +10,7 @@
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,
};
use crate::network_policy::{Decision, NetworkPolicyDecider, host_from_url};
use crate::network_policy::{Decision, NetworkPolicyDecider};
use async_trait::async_trait;
use regex::Regex;
use serde::Serialize;
@@ -142,91 +142,57 @@ impl ToolSpec for FetchUrlTool {
));
}
// Extract host once for reuse across network policy + SSRF checks.
let url_host = host_from_url(&url);
// Per-domain network policy gate (#135). If no policy is attached
// (e.g. ad-hoc tests), behavior is permissive — match pre-v0.7.0.
if let Some(decider) = context.network_policy.as_ref()
&& let Some(ref host) = url_host
{
match decider.evaluate(host, "fetch_url") {
Decision::Allow => {}
Decision::Deny => {
return Err(ToolError::permission_denied(format!(
"network call to '{host}' blocked by network policy"
)));
}
Decision::Prompt => {
return Err(ToolError::permission_denied(format!(
"network call to '{host}' requires approval; \
re-run after `/network allow {host}` or set network.default = \"allow\" in config"
)));
}
}
}
// SSRF protection: resolve hostname and reject private/link-local/loopback IPs.
// Prevents LLM-prompted requests to cloud metadata (169.254.169.254),
// localhost services, and internal networks.
// Pin the validated IP via ClientBuilder::resolve() to close the DNS rebinding
// TOCTOU window — reqwest will use the pinned IP instead of re-resolving.
let mut dns_pinning = None; // (hostname, validated_ip)
if let Some(host) = &url_host {
if host == "localhost" || host == "localhost.localdomain" {
return Err(ToolError::permission_denied(
"requests to localhost are not allowed",
));
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
if is_restricted_ip(&ip) {
return Err(ToolError::permission_denied(format!(
"IP {ip} is a restricted address (private/loopback/link-local)"
)));
}
} else if let Ok(addrs) = tokio::net::lookup_host((&**host, 0u16)).await {
let mut first_valid: Option<std::net::IpAddr> = None;
for addr in addrs {
validate_dns_resolved_ip(host, &addr.ip(), context.network_policy.as_ref())?;
if first_valid.is_none() {
first_valid = Some(addr.ip());
}
}
if let Some(validated_ip) = first_valid {
dns_pinning = Some((host.clone(), validated_ip));
}
}
// If DNS resolution fails, let the HTTP request proceed and fail naturally.
}
let format = Format::parse(input.get("format").and_then(Value::as_str))?;
let max_bytes = optional_u64(&input, "max_bytes", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);
let timeout_ms =
optional_u64(&input, "timeout_ms", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);
let mut current_url = reqwest::Url::parse(&url)
.map_err(|e| ToolError::invalid_input(format!("invalid URL: {e}")))?;
let mut redirects_followed = 0usize;
let mut client_builder = reqwest::Client::builder()
.timeout(Duration::from_millis(timeout_ms))
.user_agent(USER_AGENT)
.redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS));
let resp = loop {
let dns_pinning = validate_fetch_target(&current_url, context).await?;
let mut client_builder = reqwest::Client::builder()
.timeout(Duration::from_millis(timeout_ms))
.user_agent(USER_AGENT)
.redirect(reqwest::redirect::Policy::none());
// Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will
// connect to the validated IP directly instead of re-resolving.
if let Some((hostname, validated_ip)) = dns_pinning {
client_builder =
client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0));
}
// Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will
// connect to the validated IP directly instead of re-resolving.
if let Some((hostname, validated_ip)) = dns_pinning {
client_builder =
client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0));
}
let client = client_builder.build().map_err(|e| {
ToolError::execution_failed(format!("failed to build HTTP client: {e}"))
})?;
let client = client_builder.build().map_err(|e| {
ToolError::execution_failed(format!("failed to build HTTP client: {e}"))
})?;
let resp = client
.get(&url)
.header("Accept", "text/html,text/plain,application/json,*/*;q=0.5")
.header("Accept-Language", "en-US,en;q=0.5")
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("request failed: {e}")))?;
let resp = client
.get(current_url.clone())
.header("Accept", "text/html,text/plain,application/json,*/*;q=0.5")
.header("Accept-Language", "en-US,en;q=0.5")
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("request failed: {e}")))?;
if !resp.status().is_redirection() || redirects_followed >= MAX_REDIRECTS {
break resp;
}
let Some(location) = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
else {
break resp;
};
current_url = resp.url().join(location).map_err(|e| {
ToolError::execution_failed(format!("invalid redirect location: {e}"))
})?;
redirects_followed += 1;
};
let final_url = resp.url().to_string();
let status = resp.status();
@@ -327,6 +293,71 @@ fn is_restricted_ip(ip: &std::net::IpAddr) -> bool {
}
}
async fn validate_fetch_target(
url: &reqwest::Url,
context: &ToolContext,
) -> Result<Option<(String, std::net::IpAddr)>, ToolError> {
if url.scheme() != "http" && url.scheme() != "https" {
return Err(ToolError::invalid_input(
"only http:// and https:// URLs are supported",
));
}
let host = url
.host_str()
.map(str::to_ascii_lowercase)
.ok_or_else(|| ToolError::invalid_input("URL must include a host"))?;
validate_network_policy(&host, context)?;
// SSRF protection: resolve hostname and reject private/link-local/loopback IPs.
// Prevents LLM-prompted requests to cloud metadata (169.254.169.254),
// localhost services, and internal networks.
if host == "localhost" || host == "localhost.localdomain" {
return Err(ToolError::permission_denied(
"requests to localhost are not allowed",
));
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
if is_restricted_ip(&ip) {
return Err(ToolError::permission_denied(format!(
"IP {ip} is a restricted address (private/loopback/link-local)"
)));
}
return Ok(None);
}
let mut first_valid: Option<std::net::IpAddr> = None;
if let Ok(addrs) = tokio::net::lookup_host((host.as_str(), 0u16)).await {
for addr in addrs {
validate_dns_resolved_ip(&host, &addr.ip(), context.network_policy.as_ref())?;
if first_valid.is_none() {
first_valid = Some(addr.ip());
}
}
}
// If DNS resolution fails, let the HTTP request proceed and fail naturally.
Ok(first_valid.map(|validated_ip| (host, validated_ip)))
}
fn validate_network_policy(host: &str, context: &ToolContext) -> Result<(), ToolError> {
let Some(decider) = context.network_policy.as_ref() else {
return Ok(());
};
match decider.evaluate(host, "fetch_url") {
Decision::Allow => Ok(()),
Decision::Deny => Err(ToolError::permission_denied(format!(
"network call to '{host}' blocked by network policy"
))),
Decision::Prompt => Err(ToolError::permission_denied(format!(
"network call to '{host}' requires approval; \
re-run after `/network allow {host}` or set network.default = \"allow\" in config"
))),
}
}
fn validate_dns_resolved_ip(
host: &str,
ip: &std::net::IpAddr,
@@ -524,6 +555,37 @@ mod tests {
assert!(format!("{err}").contains("blocked"));
}
#[tokio::test]
async fn redirected_localhost_hostname_is_rejected() {
let url = reqwest::Url::parse("http://localhost:8080/admin").unwrap();
let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();
assert!(format!("{err}").contains("localhost"));
}
#[tokio::test]
async fn redirected_private_ip_literal_is_rejected() {
let url = reqwest::Url::parse("http://169.254.169.254/latest/meta-data").unwrap();
let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();
assert!(format!("{err}").contains("restricted address"));
}
#[tokio::test]
async fn redirected_host_respects_network_policy() {
use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
let policy = NetworkPolicy {
default: Decision::Deny.into(),
allow: vec!["api.deepseek.com".to_string()],
deny: vec![],
proxy: Vec::new(),
audit: false,
};
let decider = NetworkPolicyDecider::new(policy, None);
let ctx = ToolContext::new(PathBuf::from(".")).with_network_policy(decider);
let url = reqwest::Url::parse("https://example.com/redirect-target").unwrap();
let err = validate_fetch_target(&url, &ctx).await.unwrap_err();
assert!(format!("{err}").contains("blocked"));
}
#[test]
fn restricted_dns_result_is_denied_without_proxy_opt_in() {
let ip = "198.18.0.1".parse().unwrap();
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "deepseek-tui",
"version": "0.8.21",
"deepseekBinaryVersion": "0.8.21",
"version": "0.8.22",
"deepseekBinaryVersion": "0.8.22",
"description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.",
"author": "Hmbown",
"license": "MIT",