Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions crates/tole-core/src/file_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
21 changes: 17 additions & 4 deletions crates/tole-core/src/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down
36 changes: 36 additions & 0 deletions evals/replay/replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,42 @@ fn incumbent_default_prompt() -> Result<String> {
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: `\<newline>[whitespace]` —
// Rust strips the newline AND leading whitespace
Expand Down
Loading