Skip to content

Commit b8ac3e7

Browse files
committed
Add Opus 4.7 adaptive thinking, harden permission checks, fix TUI exit and confirmation scroll
1 parent e7fe62a commit b8ac3e7

16 files changed

Lines changed: 748 additions & 119 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,33 @@ All notable changes to Sofos are documented in this file.
44

55
## [Unreleased]
66

7-
### Changed
7+
### Added
88

9-
- **`read_file` output cap raised to ~256 KB** (64k tokens). Previously `read_file` shared the ~64 KB / 16k-token cap with `execute_bash` and `search_code`, which clipped mid-sized source files — generated code, JSON fixtures, long prompt templates — and forced the model into an extra range-reads round trip against the 200-iteration tool-loop budget. `execute_bash` stdout/stderr and `search_code` keep the 16k-token cap, since verbose test output and broad ripgrep patterns benefit from being forced to narrow rather than handing the model noise.
9+
- **Claude Opus 4.7 adaptive-thinking support.** Opus 4.7 rejects the legacy `{thinking: {type: "enabled", budget_tokens: N}}` request shape with HTTP 400; sofos now detects Opus 4.7 models and sends `{thinking: {type: "adaptive"}, output_config: {effort}}` instead. The `--thinking-budget` token count is meaningless for adaptive models (the server picks its own budget), so `/think on` maps to `effort: high` and `/think off` maps to `effort: low`. Adaptive is sent on every request — including when thinking is toggled off — so echoed thinking blocks from earlier turns still round-trip cleanly. Startup banner, TUI status line, and `/think` handlers all label the state as `Adaptive thinking effort: high|low` rather than showing a fake token count.
10+
- **Confirmation modal now fits on short terminals.** The 4-choice permission prompt (`Yes` / `Yes and remember` / `No` / `No and remember`) needs ~10 rows of chrome, which used to get clipped to just "Yes" on small inline viewports. The modal grows the viewport to fit when it can; when it can't, it drops the separators and hint row, and finally scrolls the choice list around the cursor with `` / `` cues on the nearest non-cursor row so the "I'm at the top/bottom of the list" signal doesn't fight the cursor glyph.
11+
- **Visible feedback on `/s` and `/n`** (safe-mode toggles). Previously silent on state changes and a total no-op when the mode was already active; now prints a one-line status (`Safe mode: enabled / read-only tools only; no writes or bash`, `Safe mode: disabled / all tools available`, or a dimmed `already enabled/disabled`).
12+
13+
### Security
14+
15+
- **`FOO=bar rm -rf /` no longer bypassed the forbidden-command check.** `extract_base_command` took the first whitespace-delimited token, which for a command starting with `KEY=value` env assignments was the assignment itself — meaning the real base command (`rm`) was never looked up against allow/deny lists. Leading POSIX-shaped env assignments are now skipped before the base-command lookup. Regression test added (`env_prefix_does_not_bypass_forbidden_base`).
16+
- **Forbidden-git detection now covers shell substitution boundaries.** `command_contains_op` only recognised `` ` ` ``, `;`, `&&`, `||`, `|` as command boundaries, so `` echo hi; `git push` ``, `echo $(git push)`, `(git push)`, and `{ git push; }` all slipped past the guarded-git list. Extended to cover backtick substitution, `$(…)` command substitution, `(…)` subshells, and `{…;}` groups. Regression test added (`command_contains_op_catches_shell_boundaries`).
17+
- **Clipboard paste is now bounded at 20 MB.** Matches the Anthropic Messages API ceiling on base64-encoded image bodies; an oversized screenshot is dropped (with a `tracing::warn!`) before it enters the conversation state, so a huge paste can't blow the session up with a confusing HTTP 400.
1018

1119
### Fixed
1220

21+
- **"Goodbye!" no longer prints on the same line as the status row on exit.** The TUI left the cursor parked at the end of the status line; when the session summary short-circuited on zero usage, `print_goodbye` then printed `Goodbye!` flush against `… thinking: 5120 tok`. The teardown now emits an escape-newline on the no-summary path so the message always starts fresh.
22+
- **Safe-mode tool list now matches reality.** `SAFE_MODE_MESSAGE` told the model it had `list_directory, read_file and web_search`, but `get_read_only_tools()` actually exposes `list_directory, read_file, glob_files, web_fetch, web_search` (+ `search_code` when ripgrep is present). The model was being given a false toolset description on safe-mode entry.
23+
- **Corrupted session file on save no longer wipes the in-memory conversation.** `save_session` re-read the prior file to preserve `created_at`; if the file was unparseable (hand-edited, partial prior write, schema drift) the JSON error bubbled up and the current turn's messages were dropped on the floor. The stamp now falls back to `now` on any read/parse failure — losing a date is cheaper than losing the session. Regression test added (`save_session_survives_corrupted_prior_file`).
24+
- **Empty-signature thinking blocks no longer round-trip.** If a streamed thinking block ended without ever receiving a `signature_delta`, sofos pushed a `ContentBlock::Thinking { thinking: "", signature: "" }` into history — which the server would reject on the next turn with a signature-verification 400. The block is now dropped when the signature is empty; legitimate empty-thinking adaptive blocks (Opus 4.7 `display: "omitted"`, which still emit a real signature) continue to round-trip.
25+
- **`on_thinking_delta` no longer prints a bare `Thinking:` label** when the first delta is an empty string.
1326
- Prompt glyph (> or :) now correctly reflects the normal/safe modes.
1427

28+
### Changed
29+
30+
- **"User declined" prompt phrasing** now nudges the model to pivot rather than retry. The old `Command blocked by user: 'X'` read like a hard policy block and invited the model to reissue the same command. Replaced with `User declined 'X'. Propose a different approach or ask the user to clarify rather than retrying the same command.` at all three rejection sites.
31+
- **`/think` command wording aligned.** The startup banner used `Extended thinking: enabled`; `/think on` printed `Extended thinking enabled.`; `/think off` printed `Extended thinking disabled.`. Consolidated on `Extended thinking: enabled` / `Extended thinking: disabled` everywhere.
32+
- **`read_file` output cap raised to ~256 KB** (64k tokens). Previously `read_file` shared the ~64 KB / 16k-token cap with `execute_bash` and `search_code`, which clipped mid-sized source files — generated code, JSON fixtures, long prompt templates — and forced the model into an extra range-reads round trip against the 200-iteration tool-loop budget. `execute_bash` stdout/stderr and `search_code` keep the 16k-token cap, since verbose test output and broad ripgrep patterns benefit from being forced to narrow rather than handing the model noise.
33+
1534
## [0.2.2] - 2026-04-21
1635

1736
### Security

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Use AGENTS.md as the single source of truth for all instructions.

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -176,14 +176,15 @@ Exit summary shows token usage and estimated cost (based on official API pricing
176176
Enable for complex reasoning tasks (disabled by default):
177177
178178
```bash
179-
sofos -t # Default 5120 token budget (Claude only)
180-
sofos -t --thinking-budget 10000 --max-tokens 16000 # Custom budget (Claude only)
179+
sofos -t # Default 5120 token budget (Claude 4.5 / 4.6)
180+
sofos -t --thinking-budget 10000 --max-tokens 16000 # Custom budget (Claude 4.5 / 4.6)
181181
```
182182
183-
**Note:** Extended thinking works with both Claude and OpenAI models.
184-
For Claude, it enables the thinking protocol and `--thinking-budget` controls token allocation.
185-
For OpenAI (gpt-5 models), `/think on` sets high reasoning effort and `/think off` sets low reasoning effort.
186-
The `--thinking-budget` parameter only applies to Claude models.
183+
**Note:** Extended thinking works with both Claude and OpenAI models.
184+
185+
- **Claude 4.5 / 4.6** uses a manual token budget controlled by `--thinking-budget` (default `5120`).
186+
- **Claude Opus 4.7** uses adaptive thinking — the server picks the budget based on the prompt, and sofos sends `effort: high` when thinking is on and `effort: low` when off. `--thinking-budget` is ignored for this model; the status line shows `effort: high|low` instead of a token count.
187+
- **OpenAI (gpt-5 models)** — `/think on` sets high reasoning effort and `/think off` sets low. `--thinking-budget` is ignored.
187188
188189
## Custom Instructions
189190

src/api/anthropic.rs

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@ const API_BASE: &str = "https://api.anthropic.com/v1";
1010
const API_VERSION: &str = "2023-06-01";
1111
const ANTHROPIC_BETA: &str = "token-efficient-tools-2025-02-19";
1212

13+
/// Return true for models that *only* accept `thinking.type = "adaptive"`
14+
/// (paired with `output_config.effort`) and reject the legacy
15+
/// `{type: "enabled", budget_tokens: N}` shape with HTTP 400.
16+
///
17+
/// Currently Opus 4.7 is the sole member of this set; Sonnet/Opus 4.6 and
18+
/// older continue to accept manual budgets, so we keep them on the old path
19+
/// to preserve the user's `--thinking-budget` knob.
20+
pub fn requires_adaptive_thinking(model: &str) -> bool {
21+
model.starts_with("claude-opus-4-7")
22+
}
23+
24+
/// The string form of an "effort" level derived from the user's
25+
/// thinking-on/off toggle. Used both for Anthropic's `output_config.effort`
26+
/// (adaptive models) and OpenAI's `reasoning.effort` — the two APIs
27+
/// happen to share the same `high` / `low` vocabulary, so one helper
28+
/// keeps the request builder, TUI status line, startup banner, and
29+
/// `/think` messages in sync without each site hand-mapping the bool.
30+
pub fn effort_label(enable_thinking: bool) -> &'static str {
31+
if enable_thinking { "high" } else { "low" }
32+
}
33+
1334
#[derive(Clone)]
1435
pub struct AnthropicClient {
1536
client: reqwest::Client,
@@ -263,10 +284,20 @@ impl AnthropicClient {
263284
});
264285
}
265286
Some("thinking") => {
266-
content_blocks.push(ContentBlock::Thinking {
267-
thinking: current_thinking.clone(),
268-
signature: current_signature.clone(),
269-
});
287+
// Every legitimate thinking block the server emits
288+
// is paired with a signature. An empty signature
289+
// means no `signature_delta` ever arrived for this
290+
// block — echoing it back on the next turn would
291+
// fail server-side verification and 400 the whole
292+
// request. Drop the block; an empty-thinking
293+
// adaptive block *with* a real signature (Opus 4.7
294+
// `display: "omitted"`) is still preserved.
295+
if !current_signature.is_empty() {
296+
content_blocks.push(ContentBlock::Thinking {
297+
thinking: current_thinking.clone(),
298+
signature: current_signature.clone(),
299+
});
300+
}
270301
}
271302
Some("tool_use") => {
272303
let input = utils::parse_tool_arguments(
@@ -373,13 +404,58 @@ mod tests {
373404
fn test_thinking_serialization() {
374405
let thinking = Thinking::enabled(5120);
375406
assert_eq!(thinking.thinking_type, "enabled");
376-
assert_eq!(thinking.budget_tokens, 5120);
407+
assert_eq!(thinking.budget_tokens, Some(5120));
377408

378409
let json = serde_json::to_value(&thinking).unwrap();
379410
assert_eq!(json["type"], "enabled");
380411
assert_eq!(json["budget_tokens"], 5120);
381412
}
382413

414+
#[test]
415+
fn test_adaptive_thinking_serialization() {
416+
let thinking = Thinking::adaptive();
417+
let json = serde_json::to_value(&thinking).unwrap();
418+
assert_eq!(json["type"], "adaptive");
419+
// `budget_tokens` must be omitted for adaptive — Opus 4.7 rejects it.
420+
assert!(json.get("budget_tokens").is_none());
421+
}
422+
423+
#[test]
424+
fn requires_adaptive_thinking_matches_opus_4_7_only() {
425+
assert!(requires_adaptive_thinking("claude-opus-4-7"));
426+
assert!(requires_adaptive_thinking("claude-opus-4-7-20260301"));
427+
assert!(!requires_adaptive_thinking("claude-opus-4-6"));
428+
assert!(!requires_adaptive_thinking("claude-sonnet-4-6"));
429+
assert!(!requires_adaptive_thinking("claude-opus-4-5"));
430+
assert!(!requires_adaptive_thinking(""));
431+
}
432+
433+
#[test]
434+
fn effort_label_maps_bool_to_high_low() {
435+
assert_eq!(effort_label(true), "high");
436+
assert_eq!(effort_label(false), "low");
437+
}
438+
439+
#[test]
440+
fn adaptive_request_sends_output_config_and_omits_budget() {
441+
let request = CreateMessageRequest {
442+
model: "claude-opus-4-7".to_string(),
443+
max_tokens: 8192,
444+
messages: vec![],
445+
system: None,
446+
tools: None,
447+
stream: None,
448+
thinking: Some(Thinking::adaptive()),
449+
output_config: Some(OutputConfig::with_effort("high")),
450+
reasoning: None,
451+
};
452+
453+
let json = serde_json::to_value(&request).unwrap();
454+
assert_eq!(json["thinking"]["type"], "adaptive");
455+
assert!(json["thinking"].get("budget_tokens").is_none());
456+
assert_eq!(json["output_config"]["effort"], "high");
457+
}
458+
383459
#[test]
384460
fn test_request_with_thinking() {
385461
let thinking = Some(Thinking::enabled(3000));
@@ -391,6 +467,7 @@ mod tests {
391467
tools: None,
392468
stream: None,
393469
thinking,
470+
output_config: None,
394471
reasoning: None,
395472
};
396473

src/api/types.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ pub struct CreateMessageRequest {
6565
#[serde(skip_serializing_if = "Option::is_none")]
6666
pub thinking: Option<Thinking>,
6767
#[serde(skip_serializing_if = "Option::is_none")]
68+
pub output_config: Option<OutputConfig>,
69+
#[serde(skip_serializing_if = "Option::is_none")]
6870
pub reasoning: Option<Reasoning>,
6971
}
7072

@@ -145,14 +147,40 @@ pub enum ContentBlock {
145147
pub struct Thinking {
146148
#[serde(rename = "type")]
147149
pub thinking_type: String,
148-
pub budget_tokens: u32,
150+
#[serde(skip_serializing_if = "Option::is_none")]
151+
pub budget_tokens: Option<u32>,
149152
}
150153

151154
impl Thinking {
152155
pub fn enabled(budget_tokens: u32) -> Self {
153156
Self {
154157
thinking_type: "enabled".to_string(),
155-
budget_tokens,
158+
budget_tokens: Some(budget_tokens),
159+
}
160+
}
161+
162+
/// Opus 4.7+ uses adaptive thinking: the server picks the budget based
163+
/// on the prompt, and the caller expresses intent via
164+
/// [`OutputConfig::effort`] on the request instead of a token count.
165+
pub fn adaptive() -> Self {
166+
Self {
167+
thinking_type: "adaptive".to_string(),
168+
budget_tokens: None,
169+
}
170+
}
171+
}
172+
173+
/// Top-level `output_config` block on the Messages API. Currently used to
174+
/// set the `effort` level that pairs with adaptive thinking on Opus 4.7+.
175+
#[derive(Debug, Clone, Serialize, Deserialize)]
176+
pub struct OutputConfig {
177+
pub effort: String,
178+
}
179+
180+
impl OutputConfig {
181+
pub fn with_effort(effort: impl Into<String>) -> Self {
182+
Self {
183+
effort: effort.into(),
156184
}
157185
}
158186
}

src/clipboard.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ pub fn strip_paste_markers(input: &str) -> (String, Vec<usize>) {
4343
(cleaned.trim().to_string(), indices)
4444
}
4545

46+
/// Ceiling on pasted clipboard images. Matches the 20 MB cap Anthropic
47+
/// imposes on base64-encoded image bodies in the Messages API — a
48+
/// larger screenshot would just get rejected at request time with a
49+
/// confusing 400. Checked on both the raw PNG buffer (encoder output)
50+
/// and the encoded base64 so a huge image never makes it into the
51+
/// conversation state.
52+
const MAX_CLIPBOARD_IMAGE_BYTES: usize = 20 * 1024 * 1024;
53+
4654
pub fn get_clipboard_image() -> Option<PastedImage> {
4755
let mut clipboard = arboard::Clipboard::new().ok()?;
4856
let image = clipboard.get_image().ok()?;
@@ -60,8 +68,27 @@ pub fn get_clipboard_image() -> Option<PastedImage> {
6068
writer.write_image_data(&image.bytes).ok()?;
6169
}
6270

71+
if buf.len() > MAX_CLIPBOARD_IMAGE_BYTES {
72+
tracing::warn!(
73+
bytes = buf.len(),
74+
limit = MAX_CLIPBOARD_IMAGE_BYTES,
75+
"dropping oversized clipboard image"
76+
);
77+
return None;
78+
}
79+
80+
let base64_data = STANDARD.encode(&buf);
81+
if base64_data.len() > MAX_CLIPBOARD_IMAGE_BYTES {
82+
tracing::warn!(
83+
bytes = base64_data.len(),
84+
limit = MAX_CLIPBOARD_IMAGE_BYTES,
85+
"dropping clipboard image whose base64 form exceeds the API limit"
86+
);
87+
return None;
88+
}
89+
6390
Some(PastedImage {
6491
media_type: "image/png".to_string(),
65-
base64_data: STANDARD.encode(&buf),
92+
base64_data,
6693
})
6794
}

src/config.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,13 @@ impl SofosConfig {
6262
// No need for new() since Default::default() is the idiomatic way
6363
}
6464

65-
/// Safe mode message shown to user and AI
65+
/// Safe mode message shown to user and AI. Must stay in sync with the
66+
/// tool set returned by `tools::get_read_only_tools()` (+ the optional
67+
/// `search_code` tool wired in when ripgrep is on PATH).
6668
pub const SAFE_MODE_MESSAGE: &str = "[SYSTEM: Safe (read-only) mode has been enabled. \
67-
No file modifications or bash commands are allowed.\
68-
Available tools: list_directory, read_file and web_search.]";
69+
No file modifications or bash commands are allowed. \
70+
Available tools: list_directory, read_file, glob_files, \
71+
search_code (when ripgrep is installed), web_fetch, web_search.]";
6972

7073
/// Normal mode message shown when switching from safe mode
7174
pub const NORMAL_MODE_MESSAGE: &str = "[SYSTEM: Normal (unrestricted) mode has been enabled. \

src/main.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,18 @@ fn main() -> Result<()> {
101101
startup_banner.push_str(&format!("{} {}\n", "Model:".bright_green(), cli.model));
102102

103103
if matches!(client, LlmClient::OpenAI(_)) {
104-
let effort = if cli.enable_thinking { "high" } else { "low" };
105104
startup_banner.push_str(&format!(
106105
"{} {}\n",
107106
"Reasoning effort:".bright_green(),
108-
effort
107+
crate::api::anthropic::effort_label(cli.enable_thinking)
108+
));
109+
} else if crate::api::anthropic::requires_adaptive_thinking(&cli.model) {
110+
// Opus 4.7 picks its own budget; advertising a token count would be
111+
// a lie. Surface the `output_config.effort` we actually send.
112+
startup_banner.push_str(&format!(
113+
"{} {}\n",
114+
"Adaptive thinking effort:".bright_green(),
115+
crate::api::anthropic::effort_label(cli.enable_thinking)
109116
));
110117
} else if cli.enable_thinking {
111118
startup_banner.push_str(&format!(

0 commit comments

Comments
 (0)