fix(security): redact tool-call arguments, gate redirects, widen the SSRF guard

Six hardening fixes from a read-only audit of this tree. Each is small; the
first is the one that mattered.

1. Tool-call arguments were never redacted. `redact_tool_result_blocks`
   matched only `ContentBlock::ToolResult` and `continue`d on everything else,
   so a model-authored command carrying a credential (`curl -H 'Authorization:
   Bearer ...'`, `API_KEY=... ./run`, a `.env` write) was persisted verbatim
   into `SavedSession.messages` and the journal, and replayed on resume. The
   doc comment claimed "a token a tool printed is never written to disk live",
   which was true for printed and false for given. Now `redact_tool_blocks`
   covers `ToolUse` and `ServerToolUse` input as well.

   `session_secret_scrub` had the same hole from the other side, so existing
   files on disk could not be remediated: it rewrote `tool_result` and
   `tool_call` summaries but never arguments. It scrubs `tool_use` and
   `server_tool_use` input now.

2. `[redaction] model_bound = false` disabled the *at-rest* boundary, not only
   the request one. The opt-out exists so the model can quote file bytes back
   for exact edits — never to write a live credential to disk — which the
   sibling method's own doc already said. The transcript path now always
   redacts; the request path still honors the opt-out.

3. Provider clients followed up to 10 redirects across hosts. reqwest strips
   `Authorization` and `Cookie` on a cross-host hop but not `x-api-key`, which
   is how Anthropic-dialect and gateway providers carry their key, so an open
   redirect on a configured `base_url` collected it. Same-host redirects are
   now followed; a cross-host hop or an https->http downgrade stops.

4. `is_restricted_ip` left IPv6 gaps: NAT64 (`64:ff9b::/96`) reaches its
   embedded IPv4 target, and 6to4, Teredo, site-local and IPv4-compatible
   addresses were unrestricted. NAT64 is unwrapped and checked as IPv4.

5. The MCP resource-template cache was keyed by server-supplied strings with no
   bound, so one hostile server could pin memory for the process lifetime.

6. `prefix_allow_matches` granted on a raw string prefix, so `git status; rm
   -rf /` matched a `git status` allow rule. Its only caller happens to reject
   compound commands first, so this was latent rather than live; the fallback
   now fails closed on any shell metacharacter instead of depending on that
   caller.

NOT VERIFIED: no compile or test run. The workspace build lock was held by
another session throughout. Verified by inspection: the `ContentBlock`
or-pattern binds `input: serde_json::Value` in both arms; the redirect
comparison matches reqwest 0.13.5's own `remove_sensitive_headers` semantics
(`previous.last()` vs next host); and the `spacer`-style match in
`spacer_rows_for_boundary` is untouched here.

Deliberately NOT changed: `workspace-write` still runs unsandboxed when the
host has no OS sandbox, reporting `sandboxed: false` rather than refusing. The
read-only lane refuses in that situation. Making workspace-write refuse would
break every container and CI run without bubblewrap, so it is a product
decision, not a patch.
This commit is contained in:
CodeWhale Bot committed 2026-10-22 05:16:34 -07:00
1 parent 39621ffb5f
commit d1fd6f1a31
7 files changed
+142 -49

No files matched your search

+11
View File
@@ -382,12 +382,23 @@ pub fn prefix_allow_matches(pattern: &str, command: &str) -> bool {
// Fallback: normalised exact match for patterns not in the arity table
// (e.g. exact-match rules like `"ls -la"` that lack a dictionary entry).
// A prefix grant never covers a compound or expansion-bearing command: the
// allow is for one inert invocation, so a shell operator fails closed here
// rather than depending on the caller having classified it first.
let command_norm: String = command
.trim()
.to_ascii_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if command_norm.chars().any(|c| {
matches!(
c,
';' | '&' | '|' | '`' | '$' | '(' | ')' | '<' | '>' | '\n' | '\r'
)
}) {
return false;
}
command_norm == pattern_norm || command_norm.starts_with(&format!("{pattern_norm} "))
}
+30 -10
View File
@@ -903,6 +903,30 @@ fn request_query_secret_values(url: &reqwest::Url) -> Vec<String> {
values
}
/// Redirects are followed only within one host, over the same or a stronger
/// scheme.
///
/// Provider keys ride in headers this stack may send under names reqwest does
/// not strip on a cross-host hop (`x-api-key`, operator `http_headers`), so a
/// compromised or open-redirecting endpoint could otherwise collect a key by
/// answering `302`. A same-host hop to plaintext would expose the same key to
/// anything on the path, so a downgrade stops too. Upgrades and same-scheme
/// hops still work for the endpoints that use them.
fn same_host_redirects() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
let from = attempt.previous().last();
let next = attempt.url();
let same_host = from.is_some_and(|previous| previous.host_str() == next.host_str());
let downgrade =
from.is_some_and(|previous| previous.scheme() == "https" && next.scheme() == "http");
if same_host && !downgrade && attempt.previous().len() < 10 {
attempt.follow()
} else {
attempt.stop()
}
})
}
pub(crate) fn redact_model_bound_text(text: &str, exact_secret_values: &[String]) -> String {
let mut redacted = text.to_string();
for secret in exact_secret_values {
@@ -1872,7 +1896,7 @@ impl CodewhaleClient {
let http_client = if plugin_provider.is_some() || api_key_source == "Claude sign-in" {
http_client.redirect(reqwest::redirect::Policy::none())
} else {
http_client
http_client.redirect(same_host_redirects())
}
.build()?;
let models_http_client = Self::http_client_builder_with_auth_mode(
@@ -2179,16 +2203,12 @@ impl CodewhaleClient {
redact_model_bound_text(text, &self.model_bound_secret_values)
}
/// Redact tool output as it enters the transcript. Same masking as the
/// request boundary, including the confirmed `[redaction] model_bound`
/// opt-out: a user who chose to let the model see file bytes verbatim
/// keeps that, and everyone else never stores a live credential.
/// Redact tool output as it enters the transcript. Unlike the request
/// boundary, this path always redacts: the `[redaction] model_bound`
/// opt-out exists so the model can quote file bytes back for exact edits,
/// never to write a live credential to disk.
pub(crate) fn redact_tool_output_for_transcript(&self, text: &str) -> String {
if self.model_bound_masking {
redact_model_bound_text(text, &self.model_bound_secret_values)
} else {
text.to_string()
}
redact_model_bound_text(text, &self.model_bound_secret_values)
}
/// Alternate models share this client's frozen endpoint and declarations.
+46 -23
View File
@@ -918,26 +918,47 @@ enum EngineHostSetup {
Rlm(rlm_host::RlmHostSetup),
}
/// Rewrite the text of every tool result in `message`, including text
/// content blocks, with `redact`.
pub(crate) fn redact_tool_result_blocks(message: &mut Message, redact: impl Fn(&str) -> String) {
/// Rewrite the text of every tool result and tool-call input in `message`,
/// including text content blocks, with `redact`.
pub(crate) fn redact_tool_blocks(message: &mut Message, redact: impl Fn(&str) -> String) {
for block in &mut message.content {
let ContentBlock::ToolResult {
content,
content_blocks,
..
} = block
else {
continue;
};
*content = redact(content);
for value in content_blocks.iter_mut().flatten() {
if value.get("type").and_then(serde_json::Value::as_str) == Some("text")
&& let Some(serde_json::Value::String(text)) = value.get_mut("text")
{
*text = redact(text);
match block {
ContentBlock::ToolResult {
content,
content_blocks,
..
} => {
*content = redact(content);
for value in content_blocks.iter_mut().flatten() {
if value.get("type").and_then(serde_json::Value::as_str) == Some("text")
&& let Some(serde_json::Value::String(text)) = value.get_mut("text")
{
*text = redact(text);
}
}
}
ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
redact_json_strings(input, &redact);
}
_ => {}
}
}
}
fn redact_json_strings(value: &mut serde_json::Value, redact: &impl Fn(&str) -> String) {
match value {
serde_json::Value::String(text) => *text = redact(text),
serde_json::Value::Array(values) => {
for value in values {
redact_json_strings(value, redact);
}
}
serde_json::Value::Object(values) => {
for value in values.values_mut() {
redact_json_strings(value, redact);
}
}
_ => {}
}
}
@@ -4701,13 +4722,15 @@ impl Engine {
self.emit_session_updated().await;
}
/// Scrub credentials from tool output once, as it enters the transcript
/// (B1). The transcript is what session JSON, the journal and every
/// later request are built from, so a token a tool printed is never
/// written to disk live. Honors the confirmed `[redaction] model_bound`
/// opt-out the same way the request boundary does.
/// Scrub credentials from tool output and tool-call input once, as it
/// enters the transcript (B1). The transcript is what session JSON, the
/// journal and every later request are built from, so a token a tool
/// printed — or one a tool was *given* in its arguments — is never written
/// to disk live. Unlike the request boundary this does not honor the
/// `[redaction] model_bound` opt-out: that opt-out exists so the model can
/// quote file bytes back for exact edits, never to relax storage.
fn redact_tool_results_for_transcript(&self, message: &mut Message) {
redact_tool_result_blocks(message, |text| self.redact_tool_output_for_transcript(text));
redact_tool_blocks(message, |text| self.redact_tool_output_for_transcript(text));
}
fn redact_tool_output_for_transcript(&self, text: &str) -> String {
+14 -9
View File
@@ -1439,8 +1439,11 @@ fn resource_template_pattern(template: &str) -> Option<String> {
///
/// This runs per URI per advertised template, while the template itself is
/// fixed by the server's listing, so compiling it on every call was pure
/// repetition. `None` still means "matches nothing" (#6213 T7).
/// repetition. `None` still means "matches nothing" (#6213 T7). The cache is
/// bounded because the template list is server-supplied: an unbounded key set
/// would let one server pin memory for the life of the process.
fn compiled_resource_template(template: &str) -> Option<Arc<regex::Regex>> {
const MAX_CACHED_TEMPLATES: usize = 512;
static CACHE: std::sync::OnceLock<
std::sync::Mutex<HashMap<String, Option<Arc<regex::Regex>>>>,
> = std::sync::OnceLock::new();
@@ -1448,14 +1451,16 @@ fn compiled_resource_template(template: &str) -> Option<Arc<regex::Regex>> {
let mut cache = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
cache
.entry(template.to_string())
.or_insert_with(|| {
resource_template_pattern(template)
.and_then(|pattern| regex::Regex::new(&pattern).ok())
.map(Arc::new)
})
.clone()
if let Some(cached) = cache.get(template) {
return cached.clone();
}
let compiled = resource_template_pattern(template)
.and_then(|pattern| regex::Regex::new(&pattern).ok())
.map(Arc::new);
if cache.len() < MAX_CACHED_TEMPLATES {
cache.insert(template.to_string(), compiled.clone());
}
compiled
}
fn resource_uri_matches_template(uri: &str, template: &str) -> bool {
+1 -1
View File
@@ -19469,7 +19469,7 @@ impl RuntimeThreadManager {
{
let mut messages = engine.get_session_snapshot().await?.messages;
for message in &mut messages {
crate::core::engine::redact_tool_result_blocks(message, |text| {
crate::core::engine::redact_tool_blocks(message, |text| {
crate::client::redact_model_bound_text(text, &receipt_secrets)
});
}
+17 -6
View File
@@ -8,9 +8,10 @@
//! `codewhale doctor` reports them and `codewhale sessions scrub-secrets`
//! rewrites them on request.
//!
//! Only transcript tool results and Runtime tool receipts (including event
//! copies) are touched. Cleanup masks credential shapes and currently configured
//! secret values; bare secrets absent from current configuration cannot be recovered.
//! Only transcript tool results, transcript tool-call arguments and Runtime
//! tool receipts (including event copies) are touched. Cleanup masks credential
//! shapes and currently configured secret values; bare secrets absent from
//! current configuration cannot be recovered.
//! Runtime rewrites require the exclusive process lease, and session rewrites
//! the session's live lease; busy stores and sessions open in an interactive
//! surface are reported and left untouched while the remaining files are
@@ -334,13 +335,14 @@ fn scrub_file(path: &Path, apply: bool, secrets: &[String]) -> io::Result<FileSc
Ok(FileScan::Dirty(redacted))
}
/// Mask credentials in every `tool_result` text field below `value`.
/// Returns how many fields changed.
/// Mask credentials in every `tool_result` and `tool_call` argument below
/// `value`. Returns how many fields changed.
fn scrub_value(value: &mut Value, secrets: &[String]) -> usize {
match value {
Value::Object(map) => {
let mut changed = 0;
if map.get("type").and_then(Value::as_str) == Some("tool_result") {
let block_type = map.get("type").and_then(Value::as_str);
if block_type == Some("tool_result") {
if let Some(Value::String(content)) = map.get_mut("content") {
changed += usize::from(scrub_text(content, secrets));
}
@@ -354,6 +356,15 @@ fn scrub_value(value: &mut Value, secrets: &[String]) -> usize {
}
}
}
if matches!(block_type, Some("tool_use" | "server_tool_use"))
&& let Some(input) = map.get_mut("input")
{
let redacted = crate::client::redact_json_model_bound_text(input, secrets);
if *input != redacted {
*input = redacted;
changed += 1;
}
}
if map.get("kind").and_then(Value::as_str) == Some("tool_call") {
for key in ["summary", "detail", "metadata"] {
if let Some(value) = map.get_mut(key) {
+23
View File
@@ -62,14 +62,37 @@ pub(crate) fn is_restricted_ip(ip: &IpAddr) -> bool {
if let Some(v4) = v6.to_ipv4_mapped() {
return is_restricted_ip(&IpAddr::V4(v4));
}
// A NAT64 gateway reaches the embedded IPv4 target, so the prefix
// must not hide a restricted address.
if let Some(v4) = nat64_ipv4(*v6)
&& is_restricted_ip(&IpAddr::V4(v4))
{
return true;
}
v6.is_loopback()
|| v6.is_multicast()
|| matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7
|| matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10
|| matches!(v6.segments(), [0xfec0..=0xfeff, ..]) // Site-local fec0::/10
|| matches!(v6.segments(), [0x2002, ..]) // 6to4 2002::/16
|| matches!(v6.segments(), [0x2001, 0x0000, ..]) // Teredo 2001::/32
|| matches!(v6.segments(), [0, 0, 0, 0, 0, 0, ..]) // IPv4-compatible ::a.b.c.d
}
}
}
/// The IPv4 target of a well-known-prefix NAT64 address (`64:ff9b::/96`).
fn nat64_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
let segments = v6.segments();
if segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2..6].iter().all(|s| *s == 0) {
let octets = v6.octets();
return Some(std::net::Ipv4Addr::new(
octets[12], octets[13], octets[14], octets[15],
));
}
None
}
/// Validate that `url` is a safe fetch target under SSRF and network policy.
///
/// On success returns an optional DNS pin `(hostname, ip)` for hostnames that