From d1c7b8a80a245eac076ab1c432ed613007f8baf0 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Tue, 22 Sep 2026 23:22:52 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20scan-3=20regressions=20=E2=80=94=20mcp?= =?UTF-8?q?=20global=20flags,=20chat=20flag=20scope,=20hook=20env=20scrub,?= =?UTF-8?q?=20cora=5Fsearch=20flag=20guard,=20uteke=20spec=20truth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tole mcp: --plan-mode now filters the SERVED registry to read-only tools and --on-pretool/--on-posttool error out loudly instead of being silently ignored (scan-3 #9 — global args parsed but unhandled by the mcp subcommand). - chat REPL: dropped_message is scoped per message; previously one drop warned on every later message forever (scan-3 #8). - hooks: hook subprocesses scrub secret-shaped env like every other child spawn (scan-3 #23). - cora_search: leading-dash query guard, mirroring uteke_recall (scan-3 #32). - uteke_document spec no longer advertises title/tags that execute() silently drops (scan-3 #33) — the spec now matches reality. --- crates/tole-core/src/file_tools.rs | 15 +++++++++++++ crates/tole-core/src/turn.rs | 21 +++++++++++++---- evals/replay/replay.rs | 36 ++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/crates/tole-core/src/file_tools.rs b/crates/tole-core/src/file_tools.rs index dd7ec9a..0dcadb3 100644 --- a/crates/tole-core/src/file_tools.rs +++ b/crates/tole-core/src/file_tools.rs @@ -138,6 +138,21 @@ fn atomic_write(target: &Path, new: &str) -> Result<(), String> { return Err(format!("edit_file: write temp {}: {e}", tmp.display())); } drop(file); + // Preserve the TARGET's permissions across the atomic rename (cora + // scan-3 #60): the temp file was created with default permissions, + // so a rename-over would silently drop exec bits and restrictive + // modes (e.g. 0600 secrets) from the resulting inode. + #[cfg(unix)] + if let Ok(meta) = std::fs::metadata(target) { + let perms = meta.permissions(); + if let Err(e) = std::fs::set_permissions(&tmp, perms) { + let _ = std::fs::remove_file(&tmp); + return Err(format!( + "edit_file: preserve permissions on {}: {e}", + tmp.display() + )); + } + } if let Err(e) = std::fs::rename(&tmp, target) { let _ = std::fs::remove_file(&tmp); return Err(format!( diff --git a/crates/tole-core/src/turn.rs b/crates/tole-core/src/turn.rs index ea6fb34..2512a08 100644 --- a/crates/tole-core/src/turn.rs +++ b/crates/tole-core/src/turn.rs @@ -420,14 +420,27 @@ fn drive( // next request and can retry with well-formed JSON. let seq = s.state().seq; s.commit(Commit::new().transition(StateTransition::from(seq, Pc::ToolCall)))?; - // Nothing executes for this intent (it is settled as an - // error immediately below), so Idempotent is the honest - // contract: a replay can only ever settle it again. + // Replay safety derives from TOOL RISK, not from the fact + // that nothing executed now (cora scan-3 #50): a crash + // between this intent and its settlement must re-consult + // the approval gate for a Write/Destructive tool on + // resume, exactly like the normal ToolCall path. The + // intent's input is the RAW malformed arguments, so a + // guarded replay re-settles it as an error — never an + // execution. + let risky = registry + .get(&tool) + .map(|t| t.risk() != Risk::ReadOnly) + .unwrap_or(false); let handle = begin( s, &tool, serde_json::Value::String(raw), - ReplaySafety::Idempotent, + if risky { + ReplaySafety::Guarded + } else { + ReplaySafety::Idempotent + }, None, )?; let msg = format!("tool arguments are not valid JSON: {reason}"); diff --git a/evals/replay/replay.rs b/evals/replay/replay.rs index 0544a71..042d9d5 100644 --- a/evals/replay/replay.rs +++ b/evals/replay/replay.rs @@ -301,6 +301,42 @@ fn incumbent_default_prompt() -> Result { Some(b'\\') => out.push('\\'), Some(b'\'') => out.push('\''), Some(b'0') => bail!("unexpected \\0 in prompt literal"), + // \xHH and \uNNNN are legal Rust escapes (cora + // scan-3 #74): decode them — a silent skip would + // corrupt the incumbent and poison every replay + // score computed from it. + Some(b'x') => { + let hex: String = rest[i + 2..].chars().take(2).collect(); + if hex.len() < 2 { + bail!("truncated \\x escape in prompt literal"); + } + let byte = u8::from_str_radix(&hex, 16) + .map_err(|_| anyhow::anyhow!("bad \\x escape: \\x{hex}"))?; + out.push(byte as char); + i += 4; + continue; + } + Some(b'u') => { + if bytes.get(i + 2) != Some(&b'{') { + bail!("\\u escape without {{ in prompt literal"); + } + let close = rest[i + 3..] + .find('}') + .ok_or_else(|| anyhow::anyhow!("unterminated \\u{{...}} escape"))?; + let hex = &rest[i + 3..i + 3 + close]; + if hex.is_empty() || hex.len() > 6 { + bail!("bad \\u{{...}} escape: \\u{{{hex}}}"); + } + let ch = u32::from_str_radix(hex, 16) + .ok() + .and_then(char::from_u32) + .ok_or_else(|| { + anyhow::anyhow!("bad \\u{{...}} escape: \\u{{{hex}}}") + })?; + out.push(ch); + i += 3 + close + 1; + continue; + } Some(_) => { // Line continuation: `\[whitespace]` — // Rust strips the newline AND leading whitespace