Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1ec7106
feat(tui): mode, status, session and file pickers navigate on the sha…
Sep 17, 2026
341151f
test(tui): provider health test records against the row's own route m…
Sep 17, 2026
751ea33
fix(plugins): a marketplace catalog is its source, not the name an ad…
Sep 17, 2026
c191943
refactor(plugins): catalog entries carry their kind; skills never ent…
Sep 17, 2026
4de9e9e
refactor(goal): the model decides goals; the host stops guessing from…
Sep 17, 2026
b04713c
refactor: delete three host-side semantic classifiers (determinism au…
Sep 17, 2026
bf1b209
refactor(reasoning): auto tier is a declared default, not a prompt cl…
Sep 17, 2026
72b028e
refactor(routing): Auto fallback is the declared default, not a conte…
Sep 17, 2026
27ae676
refactor(goal): delete the natural-language /goal prose parser (deter…
Sep 17, 2026
ff15f95
refactor(plugins): delete the dead proactive score gate (determinism …
Sep 17, 2026
ac070a0
refactor(progress): footer keys off structured routine_wait, not mess…
Sep 17, 2026
f6fb5f4
feat(read): every read response carries size, truncated, line_count (…
Sep 18, 2026
3475d03
refactor(subagent): move delivery git work off the manager write lock…
Sep 18, 2026
6286136
feat(runtime): cancel queued Agent Mail before delivery (#6176)
Sep 18, 2026
dc0b38e
refactor(tui): adopt the shared list_nav vocabulary in model/slash/fl…
Sep 18, 2026
d1175fd
perf(fleet): wake SSE streams on ledger append instead of 250ms polli…
Sep 18, 2026
bb1c3a5
fix(review): too-large diffs degrade to a named partial review instea…
Sep 18, 2026
24c97b9
perf(client): decode Anthropic SSE straight into the tagged event enu…
Sep 18, 2026
531cddb
perf(mcp): reap stdio children off-thread; authority checks leave the…
Sep 18, 2026
da17a5e
docs(unsafe): add SAFETY contracts for undocumented unsafe blocks
AdityaVG13 Sep 17, 2026
d80a871
fix(async): use tokio::fs for blocking calls in async code
AdityaVG13 Sep 17, 2026
30b7d4a
fix(resource): bound recursion in value walkers
AdityaVG13 Sep 17, 2026
4bb01cb
docs(policy): establish lock-poison posture as fail-stop
AdityaVG13 Sep 17, 2026
780e214
fix(resource): budget unbounded file and stdin reads
AdityaVG13 Sep 17, 2026
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ tag, packages, checksums and release assets exist.

### Added

- `read` responses now always report the file's byte size, line count, and
whether output was truncated, and truncation footers name the total size
alongside the continuation offset — so paging through a large file is
deliberate instead of a surprise (#6283).
- File edits are parse-gated before the write lands: Rust goes through
`syn::parse_file` for a grammar-exact `line:column`, and `.toml` / `.json`
through the parsers already vendored. An edit is refused only when the file
Expand Down
52 changes: 48 additions & 4 deletions crates/cli/src/config_bundles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,17 +287,30 @@ pub fn find_rejected_entries(bundle: &PortableBundle) -> Vec<RejectedEntry> {
rejected
}

/// Maximum nesting depth the export walkers descend. Config files are
/// shallow; anything deeper is pathological and fails closed.
const MAX_EXPORT_WALK_DEPTH: usize = 64;

/// Why a value carries nested non-portable authority or looks like a bare
/// credential, or `None` when it is safe to move between machines.
fn value_rejection_reason(path: &str, value: &toml::Value) -> Option<String> {
value_rejection_reason_at(path, value, 0)
}

fn value_rejection_reason_at(path: &str, value: &toml::Value, depth: usize) -> Option<String> {
if depth > MAX_EXPORT_WALK_DEPTH {
return Some(format!(
"nested more than {MAX_EXPORT_WALK_DEPTH} levels deep"
));
}
if let Some(reason) = nonportable_value_reason(path, value) {
return Some(reason.to_string());
}
match value {
toml::Value::String(text) => string_secret_reason(text),
toml::Value::Array(items) => items
.iter()
.find_map(|value| value_rejection_reason(path, value))
.find_map(|value| value_rejection_reason_at(path, value, depth + 1))
.map(|reason| format!("array contains an entry where {reason}")),
toml::Value::Table(map) => {
for (key, nested_value) in map {
Expand All @@ -309,7 +322,9 @@ fn value_rejection_reason(path: &str, value: &toml::Value) -> Option<String> {
if let Some(reason) = nonportable_path_reason(&child_path) {
return Some(format!("nested key {key:?} {reason}"));
}
if let Some(reason) = value_rejection_reason(&child_path, nested_value) {
if let Some(reason) =
value_rejection_reason_at(&child_path, nested_value, depth + 1)
{
return Some(format!("nested under {key:?}, {reason}"));
}
}
Expand Down Expand Up @@ -965,6 +980,13 @@ fn config_document(config: &ConfigToml) -> Result<toml::map::Map<String, toml::V
/// machine-local paths are omitted rather than replaced with a placeholder,
/// because a placeholder would become literal config on re-import.
fn sanitize_export_value(path: &str, value: &toml::Value) -> Option<toml::Value> {
sanitize_export_value_at(path, value, 0)
}

fn sanitize_export_value_at(path: &str, value: &toml::Value, depth: usize) -> Option<toml::Value> {
if depth > MAX_EXPORT_WALK_DEPTH {
return None;
}
if nonportable_path_reason(path).is_some() || nonportable_value_reason(path, value).is_some() {
return None;
}
Expand All @@ -973,14 +995,14 @@ fn sanitize_export_value(path: &str, value: &toml::Value) -> Option<toml::Value>
toml::Value::Array(values) => Some(toml::Value::Array(
values
.iter()
.filter_map(|value| sanitize_export_value(path, value))
.filter_map(|value| sanitize_export_value_at(path, value, depth + 1))
.collect(),
)),
toml::Value::Table(table) => {
let mut scrubbed = toml::map::Map::new();
for (key, value) in table {
let child_path = format!("{path}.{key}");
if let Some(value) = sanitize_export_value(&child_path, value) {
if let Some(value) = sanitize_export_value_at(&child_path, value, depth + 1) {
scrubbed.insert(key.clone(), value);
}
}
Expand Down Expand Up @@ -3621,6 +3643,28 @@ command = "/synthetic/direct-tool-override"
assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
}

#[test]
fn deep_nesting_fails_closed_for_rejection_and_sanitize() {
fn deep_toml(depth: usize) -> toml::Value {
let mut value = toml::Value::String("leaf".to_string());
for _ in 0..depth {
let mut map = toml::map::Map::new();
map.insert("t".to_string(), value);
value = toml::Value::Table(map);
}
value
}

let deep = deep_toml(70);
let reason = value_rejection_reason("t", &deep).expect("over-deep value must be rejected");
assert!(reason.contains("levels deep"), "{reason}");
assert!(
sanitize_export_value("t", &deep)
.is_some_and(|scrubbed| !scrubbed.to_string().contains("leaf")),
"over-deep branch must be omitted, not exported"
);
}

#[test]
fn lsp_executable_authority_is_rejected_while_inert_settings_remain_portable() {
let config: ConfigToml = toml::from_str(
Expand Down
124 changes: 57 additions & 67 deletions crates/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5371,6 +5371,20 @@ fn tui_argv(cli: &Cli, passthrough: Vec<String>) -> Vec<String> {
args
}

/// Set one process environment variable for the CLI-to-TUI bridge.
///
/// The dispatcher must call this only on the main thread before the TUI
/// runtime starts; all current callers are inside [`apply_tui_env`].
fn set_tui_env(key: impl AsRef<std::ffi::OsStr>, value: impl AsRef<std::ffi::OsStr>) {
// SAFETY: the dispatcher runs on the main thread and these setters execute
// before the TUI runtime starts. The only other thread that may be alive
// is the detached telemetry writer, which never reads or writes the
// process environment, so no concurrent environment access can occur.
unsafe {
std::env::set_var(key, value);
}
}

fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrough: &[String]) {
let mut verbosity = if cli.profile.is_some() {
cli.verbosity.clone()
Expand All @@ -5396,106 +5410,74 @@ fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrou
|| provider.to_string(),
|provider| provider.as_str().to_string(),
);
unsafe {
std::env::set_var("CODEWHALE_PROVIDER", &provider);
std::env::set_var("DEEPSEEK_PROVIDER", provider);
}
set_tui_env("CODEWHALE_PROVIDER", &provider);
set_tui_env("DEEPSEEK_PROVIDER", provider);
}
if !(uses_raw_tui_provider
|| (cli.profile.is_some()
&& matches!(resolved_runtime.provider_source, ProviderSource::Config)))
&& matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
&& let Some(api_key) = keyring_bridge_api_key
{
unsafe {
for var in provider_env_vars(keyring_bridge_provider) {
std::env::set_var(var, api_key);
}
std::env::set_var(
codewhale_config::CLI_API_KEY_SOURCE_ENV,
RuntimeApiKeySource::Keyring.as_env_value(),
);
for var in provider_env_vars(keyring_bridge_provider) {
set_tui_env(var, api_key);
}
set_tui_env(
codewhale_config::CLI_API_KEY_SOURCE_ENV,
RuntimeApiKeySource::Keyring.as_env_value(),
);
}
if let Some(model) = cli.model.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_MODEL", model);
std::env::set_var("DEEPSEEK_MODEL", model);
}
set_tui_env("CODEWHALE_MODEL", model);
set_tui_env("DEEPSEEK_MODEL", model);
}
if let Some(output_mode) = cli.output_mode.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_OUTPUT_MODE", output_mode);
std::env::set_var("DEEPSEEK_OUTPUT_MODE", output_mode);
}
set_tui_env("CODEWHALE_OUTPUT_MODE", output_mode);
set_tui_env("DEEPSEEK_OUTPUT_MODE", output_mode);
}
if let Some(v) = verbosity.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_VERBOSITY", v);
std::env::set_var("DEEPSEEK_VERBOSITY", v);
}
set_tui_env("CODEWHALE_VERBOSITY", v);
set_tui_env("DEEPSEEK_VERBOSITY", v);
}
if let Some(log_level) = cli.log_level.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_LOG_LEVEL", log_level);
std::env::set_var("DEEPSEEK_LOG_LEVEL", log_level);
}
set_tui_env("CODEWHALE_LOG_LEVEL", log_level);
set_tui_env("DEEPSEEK_LOG_LEVEL", log_level);
}
let telemetry = resolved_runtime.telemetry.to_string();
unsafe {
std::env::set_var("CODEWHALE_TELEMETRY", &telemetry);
std::env::set_var("DEEPSEEK_TELEMETRY", &telemetry);
}
set_tui_env("CODEWHALE_TELEMETRY", &telemetry);
set_tui_env("DEEPSEEK_TELEMETRY", &telemetry);
let floor = cli.telemetry == Some(false) || codewhale_config::telemetry_floor_in_force();
unsafe {
std::env::set_var(
codewhale_config::TELEMETRY_FLOOR_ENV,
if floor { "1" } else { "0" },
);
}
set_tui_env(
codewhale_config::TELEMETRY_FLOOR_ENV,
if floor { "1" } else { "0" },
);
if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
std::env::set_var("DEEPSEEK_TELEMETRY_ENDPOINT", endpoint);
}
set_tui_env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
set_tui_env("DEEPSEEK_TELEMETRY_ENDPOINT", endpoint);
}
if let Some(policy) = cli.approval_policy.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_APPROVAL_POLICY", policy);
std::env::set_var("DEEPSEEK_APPROVAL_POLICY", policy);
}
set_tui_env("CODEWHALE_APPROVAL_POLICY", policy);
set_tui_env("DEEPSEEK_APPROVAL_POLICY", policy);
}
if let Some(mode) = cli.sandbox_mode.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_SANDBOX_MODE", mode);
std::env::set_var("DEEPSEEK_SANDBOX_MODE", mode);
}
set_tui_env("CODEWHALE_SANDBOX_MODE", mode);
set_tui_env("DEEPSEEK_SANDBOX_MODE", mode);
}
if cli.yolo {
unsafe {
std::env::set_var("CODEWHALE_YOLO", "true");
}
set_tui_env("CODEWHALE_YOLO", "true");
}
if let Some(api_key) = cli.api_key.as_ref() {
unsafe {
std::env::set_var(codewhale_config::CLI_API_KEY_ENV, api_key);
}
set_tui_env(codewhale_config::CLI_API_KEY_ENV, api_key);
if !uses_raw_tui_provider && (cli.profile.is_none() || cli.provider.is_some()) {
unsafe {
for var in provider_env_vars(resolved_runtime.provider) {
std::env::set_var(var, api_key);
}
for var in provider_env_vars(resolved_runtime.provider) {
set_tui_env(var, api_key);
}
}
unsafe {
std::env::set_var(codewhale_config::CLI_API_KEY_SOURCE_ENV, "cli");
}
set_tui_env(codewhale_config::CLI_API_KEY_SOURCE_ENV, "cli");
}
if let Some(base_url) = cli.base_url.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_BASE_URL", base_url);
std::env::set_var("DEEPSEEK_BASE_URL", base_url);
}
set_tui_env("CODEWHALE_BASE_URL", base_url);
set_tui_env("DEEPSEEK_BASE_URL", base_url);
}
}

Expand Down Expand Up @@ -5534,11 +5516,19 @@ fn run_metrics_command(args: MetricsArgs) -> Result<()> {
})
}

/// Maximum bytes read for an API key on stdin. Keys are short; anything
/// larger is a piped file, not a key.
const MAX_STDIN_API_KEY_BYTES: u64 = 8 * 1024;

fn read_api_key_from_stdin() -> Result<String> {
let mut input = String::new();
io::stdin()
.take(MAX_STDIN_API_KEY_BYTES + 1)
.read_to_string(&mut input)
.context("failed to read api key from stdin")?;
if input.len() as u64 > MAX_STDIN_API_KEY_BYTES {
bail!("API key on stdin exceeds the 8 KiB limit");
}
let key = input.trim().to_string();
if key.is_empty() {
bail!("empty API key provided");
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ fn main() -> std::process::ExitCode {
// inherit SIGPIPE set to SIG_IGN, which makes write(2) return EPIPE;
// Rust's `println!` then treats that io::Error as fatal and panics.
// See issue #4030.
// SAFETY: process entry; no threads or handlers yet.
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
Expand Down
27 changes: 24 additions & 3 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7224,20 +7224,41 @@ fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
.with_context(|| format!("failed to read {label} at {}", path.display()))
}

/// Maximum bytes read from a config file. Configs are kilobytes; anything
/// larger is not a config file.
const MAX_CONFIG_FILE_BYTES: u64 = 1024 * 1024;

#[cfg(unix)]
fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
let mut file = fs::OpenOptions::new()
let file = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
let mut raw = String::new();
file.read_to_string(&mut raw)?;
file.take(MAX_CONFIG_FILE_BYTES + 1)
.read_to_string(&mut raw)?;
if raw.len() as u64 > MAX_CONFIG_FILE_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("config file {} exceeds the 1 MiB limit", path.display()),
));
}
Ok(raw)
}

#[cfg(not(unix))]
fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
fs::read_to_string(path)
let file = fs::File::open(path)?;
let mut raw = String::new();
file.take(MAX_CONFIG_FILE_BYTES + 1)
.read_to_string(&mut raw)?;
if raw.len() as u64 > MAX_CONFIG_FILE_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("config file {} exceeds the 1 MiB limit", path.display()),
));
}
Ok(raw)
}

fn reject_path_symlink(path: &Path) -> Result<()> {
Expand Down
23 changes: 23 additions & 0 deletions crates/config/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,29 @@ PASSWORD=hunter2hunter2"
assert!(!out.to_string().contains(&synthetic_secret), "{out}");
}

#[test]
fn redact_json_truncates_pathological_nesting() {
let mut value = serde_json::Value::String("leaf".to_string());
for _ in 0..150 {
let mut map = serde_json::Map::new();
map.insert("t".to_string(), value);
value = serde_json::Value::Object(map);
}
let out = redact_json_secrets(&value);
let mut cursor = &out;
let mut descended = 0;
while let serde_json::Value::Object(map) = cursor {
cursor = map.values().next().expect("single-key nesting");
descended += 1;
}
assert_eq!(
cursor,
&serde_json::Value::String(REDACTED.to_string()),
"over-deep value must be redacted, not traversed"
);
assert!(descended < 150, "guard must fire before the leaf");
}

#[test]
fn redact_text_masks_camel_case_and_dotted_secret_assignments() {
let synthetic_secret = synthetic_secret_fixture();
Expand Down
Loading
Loading