Skip to content

Commit 767b1b8

Browse files
committed
Tighten tools, MCP isolation tail, and CLI polish across the audit punch list
1 parent 041bc5e commit 767b1b8

18 files changed

Lines changed: 358 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,22 @@ All notable changes to Sofos are documented in this file.
66

77
### Added
88

9+
- **`glob_files` aborts at fifty thousand matches and breaks symlink cycles.** A `**` pattern over a million-file monorepo used to buffer about 80 MB of strings before output truncation; the walk now stops at 50 000 hits with a "narrow the pattern" sentinel. With `follow_symlinks: true`, the walker tracks canonical directories so a `ln -s . loop` no longer spins forever.
10+
- **`edit_file` refuses to overwrite a file that changed during the read.** When VSCode auto-save or `cargo watch` rewrites a file between the tool's read and write, sofos now detects the mtime / length change and surfaces a clear "concurrent edit" error so the model re-reads the file instead of clobbering the fresher version.
11+
912
- **The cache numbers now appear in the status line.** When either cache-read or cache-creation tokens are non-zero, the status row shows `cache: 1234r 56w` alongside the input/output totals so users can see their prompt-cache hit rate without quitting to view the session summary.
1013
- **The input box title line shows a `… +N more` hint when the draft overflows the visible area.** A long paste or multi-line draft that exceeds the input box height previously had no on-screen indication that more content existed below the visible window; the cap is now surfaced as part of the title.
1114
- **`Ctrl+W` and `Ctrl+K` are now bound in the input box.** `Ctrl+W` deletes the word behind the cursor (the readline / bash / zsh / fish binding) and `Ctrl+K` deletes from the cursor to the end of the line. `Ctrl+U` (delete to start of line) is unchanged.
1215

1316
### Fixed
1417

18+
- **`list_directory` no longer over-counts items when output is truncated.** The `[TRUNCATED: …]` footer added when a directory listing exceeds the cap used to be counted as extra items; sofos now strips the footer before counting so the reported count matches the actual entries.
19+
- **`view_image` rejects `data:` URLs with a clear hint.** The previous flow routed them into the file branch and surfaced a misleading "Image not found" error; the new error explains the accepted shapes (workspace-relative, absolute, `~/`, http(s)://).
20+
- **MCP safe-mode refusals are now treated as security blocks, not failures.** Tools filtered out by safe mode used to render with the red error styling; they now flow through the same yellow "blocked" channel as native safe-mode refusals.
21+
- **A corrupt `index.json` no longer fails session saves.** Sofos now treats a malformed index as if it were missing and rebuilds it from the current save instead of failing the whole write.
22+
- **Mid-turn user messages folded into text-only assistant turns.** Typing while the assistant is producing a text-only reply (no tool calls) now folds the steer message into a follow-up user turn instead of dropping it as a fresh prompt.
23+
- **Morph stub-response detection covers the 200-500 byte range.** Files in this bracket that collapsed below 30 % of the original used to slip past the absolute 50-byte floor; sofos now flags those merges as likely truncated.
24+
1525
- **Long assistant turns render fluidly again.** The streaming markdown renderer used to re-render the whole accumulated buffer on every new line — quadratic in the length of the reply, which on a 50 KB stream was noticeably laggy. Replies under 16 KB stream per line as before; longer replies batch the work into 1 KB chunks so the total cost stays linear.
1626
- **Editing past a slash-popup dismissal stays dismissed.** Pressing Esc on `/clear` and then backspacing one character (or adding a typo fix) used to immediately re-open the popup; the dismissal now sticks while the textarea is still in the same `/command` edit family and only clears when the user switches to an unrelated command.
1727
- **A live draft is preserved while scrolling through input history.** Alt+Up used to discard whatever you had typed when stepping into older prompts; sofos now snapshots the draft on the first Alt+Up and restores it when Alt+Down walks past the newest entry.
@@ -36,6 +46,22 @@ All notable changes to Sofos are documented in this file.
3646

3747
### Security
3848

49+
- **`web_fetch` follows at most three redirects, and only to http(s) targets.** The default `reqwest` redirect policy would let an LLM-supplied URL chain through up to ten hops including non-http(s) schemes (`file://`, `data:`, custom). Sofos now caps the hop count and rejects any redirect with a non-http(s) scheme so a content URL can't slip into a different protocol mid-chain.
50+
- **MCP server names are restricted to `[A-Za-z0-9_-]+`.** Two visually-identical Unicode names (`github` vs `gith\u{1d62}ub`) used to produce distinct map entries and route differently while looking the same in config; sofos now refuses non-ASCII server names at load time.
51+
- **MCP `initialize` uses a 15-second timeout.** Tool calls keep the existing two-minute ceiling, but the handshake no longer holds session startup hostage for two minutes per misconfigured server.
52+
- **The clipboard image binary check is the effective cap.** Base64 expansion (~33 %) used to make the post-encode check the actual gate, while the binary check at the same limit was dead. Sofos now caps the binary side at three-quarters of the limit so a marginally oversized image fails fast with a clearer reason.
53+
54+
### Changed
55+
56+
- **Deprecated `--thinking-budget` and `--check-connection` + `--prompt` warnings now use the yellow `UI::print_warning` channel.** Users without `RUST_LOG=warn` set previously got no on-screen feedback for either; both now appear consistently.
57+
- **Passing an API key on the command line now prints a warning when the environment variable is unset.** `--api-key`, `--openai-api-key`, and `--morph-api-key` arguments land in `ps` output and shell history; sofos now recommends exporting the env-var form when it sees a flag-only setup.
58+
- **`--check-connection` reports "host reachable" instead of "API is reachable".** The probe is a HEAD against the base URL — both providers return 404 there without authenticating, so the previous wording misled users into expecting their key would also work.
59+
- **Cross-platform `move_file` now reports "concurrent edit" when the destination changed mid-operation.** Combined with the file-modification fixes above, sofos no longer silently overwrites a file that was rewritten between read and write.
60+
- **Slash-popup dismissal persists across edits within the same `/command` attempt** (covered by the slash-popup fix earlier in this section).
61+
- **Animated GIFs are documented as first-frame only.** The `view_image` schema text now matches the implementation; ask the user for a still frame if an animation matters.
62+
63+
### Security
64+
3965
- **API-key-shaped strings in provider error bodies are redacted before display.** Provider 401 responses sometimes echo a truncated key (`sk-ant-api03-…` or `Bearer …`); sofos now replaces every matching run with `sk-[redacted]` / `Bearer [redacted]` and caps the body at 4 KB, so a noisy proxy or moderation block can no longer flood the status line or leak key prefixes to transcripts and crash reports.
4066
- **The SSE re-assembly buffer is capped at 16 MB on both providers.** A server (or middlebox) that streams gigabytes without a newline used to grow the buffer until the 30-minute request timeout fired; sofos now aborts the stream cleanly with a clear error long before memory exhaustion becomes a real risk.
4167

src/api/anthropic/wire.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ pub fn legacy_thinking_budget(effort: ReasoningEffort) -> u32 {
9797
/// request's signed thinking blocks. Echoing back a stored set of
9898
/// thinking blocks that the server cross-checks against the request,
9999
/// and dropping `output_config` would 400 the next turn.
100+
/// `Off` collapses to `"low"` on the adaptive-thinking path: dropping
101+
/// `output_config.thinking` entirely would fail the server's
102+
/// cross-check against any signed thinking blocks already in the
103+
/// conversation, so adaptive models always send *some* effort label.
104+
/// Effectively, `effort: off` on Opus 4.7 / Sonnet 4.6 still sends
105+
/// the minimum adaptive budget; for "no thinking at all" pick a
106+
/// model whose `requires_adaptive_thinking` returns false and run
107+
/// it with `--reasoning-effort off`.
100108
pub fn effort_label(effort: ReasoningEffort) -> &'static str {
101109
match effort {
102110
ReasoningEffort::Off | ReasoningEffort::Low => "low",
@@ -125,6 +133,12 @@ pub(super) fn prepare_request(mut request: CreateMessageRequest) -> CreateMessag
125133

126134
// OpenAI-only; drop before serializing for Anthropic.
127135
request.prompt_cache_key = None;
136+
// `reasoning` is the OpenAI Responses-style sibling of Anthropic's
137+
// `thinking` field. The request builder never sets it on the
138+
// Anthropic path today, but clear it here defensively so a future
139+
// caller that constructs a request directly doesn't accidentally
140+
// send it and trigger a 400.
141+
request.reasoning = None;
128142

129143
if let Some(tools) = request.tools.take() {
130144
let filtered: Vec<Tool> = tools

src/api/utils.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,12 @@ pub fn parse_tool_arguments(name: &str, args: &str) -> serde_json::Value {
194194
}
195195

196196
let preview_end = truncate_at_char_boundary(args, UNPARSEABLE_ARGS_PREVIEW_BYTES);
197+
// Redact `sk-…` / `Bearer …` shapes before tracing — the preview
198+
// can land in transcripts and crash reports.
199+
let preview = redact_api_secrets(&args[..preview_end]);
197200
tracing::warn!(
198201
tool = %name,
199-
preview = %&args[..preview_end],
202+
preview = %preview,
200203
"failed to parse tool arguments as JSON; passing raw_arguments through"
201204
);
202205
serde_json::json!({"raw_arguments": args})

src/clipboard.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,16 @@ pub fn get_clipboard_image() -> Option<PastedImage> {
7171
writer.write_image_data(&image.bytes).ok()?;
7272
}
7373

74-
if buf.len() > MAX_CLIPBOARD_IMAGE_BYTES {
74+
// Base64 expands binary by ~33%, so a binary blob just under
75+
// MAX_CLIPBOARD_IMAGE_BYTES becomes ~1.33x that after encoding and
76+
// then trips the second check anyway. Cap the binary side at three
77+
// quarters of the limit so the binary check is the effective gate
78+
// and a marginally-oversize blob fails fast with a clearer reason.
79+
let binary_cap = MAX_CLIPBOARD_IMAGE_BYTES * 3 / 4;
80+
if buf.len() > binary_cap {
7581
tracing::warn!(
7682
bytes = buf.len(),
77-
limit = MAX_CLIPBOARD_IMAGE_BYTES,
83+
limit = binary_cap,
7884
"dropping oversized clipboard image"
7985
);
8086
return None;

src/error.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const BLOCKED_KEYWORDS: &[&str] = &[
1919
"tilde paths",
2020
"output redirection",
2121
"here-doc",
22+
"filtered out",
23+
"safe mode",
2224
];
2325

2426
#[derive(Error, Debug)]
@@ -77,7 +79,11 @@ impl SofosError {
7779
match self {
7880
Self::PathViolation(_) => true,
7981
Self::ToolExecution(msg) => BLOCKED_KEYWORDS.iter().any(|kw| msg.contains(kw)),
80-
Self::McpError(_) => false,
82+
// Safe-mode MCP refusals come back as `McpError` from the
83+
// executor; the message still carries a "filtered out" /
84+
// "safe mode" keyword, so let the same keyword check apply
85+
// here instead of treating every MCP error as a failure.
86+
Self::McpError(msg) => BLOCKED_KEYWORDS.iter().any(|kw| msg.contains(kw)),
8187
Self::Join(_) => false,
8288
Self::Context { source, .. } => source.is_blocked(),
8389
_ => false,

src/main.rs

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,40 @@ fn main() -> Result<()> {
3131

3232
let cli = Cli::parse();
3333

34+
// API keys on the command line land in `ps` output and shell
35+
// history; the env-var form is the safe alternative. clap's
36+
// `#[arg(env = ...)]` populates the field from either source, so
37+
// "field set but env unset" means the user passed the flag.
38+
for (field, env_var, flag) in [
39+
(cli.api_key.as_deref(), "ANTHROPIC_API_KEY", "--api-key"),
40+
(
41+
cli.openai_api_key.as_deref(),
42+
"OPENAI_API_KEY",
43+
"--openai-api-key",
44+
),
45+
(
46+
cli.morph_api_key.as_deref(),
47+
"MORPH_API_KEY",
48+
"--morph-api-key",
49+
),
50+
] {
51+
if field.is_some() && std::env::var_os(env_var).is_none() {
52+
UI::print_warning(&format!(
53+
"{} on the command line exposes the key in `ps` output and shell history. \
54+
Export {} in your shell instead.",
55+
flag, env_var
56+
));
57+
}
58+
}
59+
3460
if cli.thinking_budget != cli::THINKING_BUDGET_DEFAULT {
35-
tracing::warn!(
61+
// Surface the deprecation through the same yellow `UI` channel
62+
// the rest of the program uses for user-facing warnings.
63+
// `tracing::warn!` only reaches users with `RUST_LOG` set,
64+
// which most don't.
65+
UI::print_warning(
3666
"--thinking-budget is deprecated and has no effect on any provider path. \
37-
Use --reasoning-effort to control thinking depth. The flag will be removed in a future release."
67+
Use --reasoning-effort to control thinking depth. The flag will be removed in a future release.",
3868
);
3969
}
4070

@@ -100,9 +130,9 @@ fn main() -> Result<()> {
100130
// so scripts that combine the two don't quietly drop their
101131
// input.
102132
if cli.prompt.is_some() {
103-
tracing::warn!(
133+
UI::print_warning(
104134
"--prompt is ignored when --check-connection is set; \
105-
only the connectivity check will run."
135+
only the connectivity check will run.",
106136
);
107137
}
108138
return check_api_connectivity(&client);
@@ -246,10 +276,16 @@ fn check_api_connectivity(client: &LlmClient) -> Result<()> {
246276

247277
match runtime.block_on(client.check_connectivity()) {
248278
Ok(()) => {
279+
// Use "host reachable" rather than "API is reachable" — the
280+
// probe is a HEAD against the base URL, which returns 404
281+
// on either provider without authenticating. Saying "API is
282+
// reachable" misleads users into expecting a successful
283+
// first request, but a missing or wrong key still 401s.
249284
println!(
250-
"{} {} API is reachable",
285+
"{} {} host reachable (HEAD {}); key not validated",
251286
"✓".bright_green().bold(),
252-
provider
287+
provider,
288+
"/".dimmed()
253289
);
254290
Ok(())
255291
}

src/mcp/client.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ use std::time::Duration;
1313
/// still bounding a frozen server's blast radius to a single turn.
1414
pub(crate) const MCP_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
1515

16+
/// Shorter ceiling for the MCP `initialize` handshake. A frozen server
17+
/// holds startup hostage for the full request timeout otherwise (two
18+
/// minutes per server). 15 s is long enough for a slow stdio child to
19+
/// finish booting and short enough that a misconfigured server gives
20+
/// up quickly so the user can fix the config.
21+
pub(crate) const MCP_INIT_TIMEOUT: Duration = Duration::from_secs(15);
22+
1623
pub(crate) fn create_init_request() -> InitializeRequest {
1724
InitializeRequest {
1825
protocol_version: "2024-11-05".to_string(),

src/mcp/manager.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,12 @@ pub struct McpManager {
6363
}
6464

6565
/// Reject server and tool names that contain the prefix separator or
66-
/// that would produce an empty identifier. Names are otherwise accepted
67-
/// as-is; provider-level character validation happens on the prefixed
68-
/// name when the tool list is sent.
66+
/// that would produce an empty identifier. Server names are restricted
67+
/// further to ASCII `[A-Za-z0-9_-]+` so visually-identical Unicode
68+
/// confusables (e.g. `github` vs `gith\u{1d62}ub`) cannot produce two
69+
/// map entries that look the same in config but route differently.
70+
/// Tool names stay permissive because the server controls them and
71+
/// provider-level validation runs on the prefixed name.
6972
fn validate_mcp_name(kind: &str, name: &str) -> Result<()> {
7073
if name.is_empty() {
7174
return Err(SofosError::McpError(format!("MCP {} name is empty", kind)));
@@ -76,6 +79,16 @@ fn validate_mcp_name(kind: &str, name: &str) -> Result<()> {
7679
kind, name, MCP_NAME_SEPARATOR
7780
)));
7881
}
82+
if kind == "server"
83+
&& !name
84+
.chars()
85+
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
86+
{
87+
return Err(SofosError::McpError(format!(
88+
"MCP server name '{}' must contain only ASCII letters, digits, '_' or '-'",
89+
name
90+
)));
91+
}
7992
Ok(())
8093
}
8194

src/mcp/transport/stdio.rs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::error::{Result, SofosError};
22
use crate::mcp::client::{
3-
MCP_REQUEST_TIMEOUT, create_call_tool_request, create_init_request, parse_call_tool_response,
4-
parse_list_tools_response,
3+
MCP_INIT_TIMEOUT, MCP_REQUEST_TIMEOUT, create_call_tool_request, create_init_request,
4+
parse_call_tool_response, parse_list_tools_response,
55
};
66
use crate::mcp::config::McpServerConfig;
77
use crate::mcp::protocol::*;
@@ -396,13 +396,13 @@ impl StdioClient {
396396
/// doesn't pause the executor waiting for the OS to reap it. Used
397397
/// by both `send_request` and `send_notification` so they share
398398
/// the same lock/panic/timeout error vocabulary.
399-
async fn run_with_timeout<T, F>(&self, label: &str, blocking: F) -> Result<T>
399+
async fn run_with_timeout<T, F>(&self, label: &str, timeout: Duration, blocking: F) -> Result<T>
400400
where
401401
F: FnOnce() -> Result<T> + Send + 'static,
402402
T: Send + 'static,
403403
{
404404
let task = tokio::task::spawn_blocking(blocking);
405-
match tokio::time::timeout(MCP_REQUEST_TIMEOUT, task).await {
405+
match tokio::time::timeout(timeout, task).await {
406406
Ok(Ok(Ok(value))) => Ok(value),
407407
Ok(Ok(Err(e))) => Err(e),
408408
Ok(Err(join_err)) => Err(SofosError::McpError(format!(
@@ -415,7 +415,7 @@ impl StdioClient {
415415
"MCP server '{}' {} timed out after {}s",
416416
self.server_name,
417417
label,
418-
MCP_REQUEST_TIMEOUT.as_secs()
418+
timeout.as_secs()
419419
)))
420420
}
421421
}
@@ -451,10 +451,14 @@ impl StdioClient {
451451
}
452452

453453
async fn initialize(&self) -> Result<()> {
454+
// The handshake uses a tighter ceiling than tool calls so a
455+
// frozen server can't hold session startup hostage for two
456+
// minutes per misconfigured config entry.
454457
let response = self
455-
.send_request(
458+
.send_request_with_timeout(
456459
"initialize",
457460
Some(serde_json::to_value(create_init_request())?),
461+
MCP_INIT_TIMEOUT,
458462
)
459463
.await?;
460464

@@ -467,6 +471,16 @@ impl StdioClient {
467471
}
468472

469473
async fn send_request(&self, method: &str, params: Option<Value>) -> Result<Value> {
474+
self.send_request_with_timeout(method, params, MCP_REQUEST_TIMEOUT)
475+
.await
476+
}
477+
478+
async fn send_request_with_timeout(
479+
&self,
480+
method: &str,
481+
params: Option<Value>,
482+
timeout: Duration,
483+
) -> Result<Value> {
470484
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
471485
let request = JsonRpcRequest::new(id, method.to_string(), params);
472486
let request_json = serde_json::to_string(&request)?;
@@ -476,7 +490,7 @@ impl StdioClient {
476490
let stdin = Arc::clone(&self.stdin);
477491
let stdout = Arc::clone(&self.stdout);
478492
let response = self
479-
.run_with_timeout("request", move || {
493+
.run_with_timeout("request", timeout, move || {
480494
stdio_request_blocking(
481495
&server_name,
482496
&request_lock,
@@ -515,7 +529,7 @@ impl StdioClient {
515529
// wedged its read side. Same timeout path as `send_request`.
516530
let server_name = self.server_name.clone();
517531
let stdin = Arc::clone(&self.stdin);
518-
self.run_with_timeout("notification", move || {
532+
self.run_with_timeout("notification", MCP_REQUEST_TIMEOUT, move || {
519533
stdio_write_blocking(&server_name, &stdin, &notification_json)
520534
})
521535
.await

0 commit comments

Comments
 (0)