Skip to content

Commit ee7136e

Browse files
fix(check): stop check agents from getting lost outside their sandbox
A traced `multi check` run showed every "agent did not report" timeout sharing one signature: the agent was never told the *path* of its sandbox working directory, went hunting for the repository across the host filesystem (navigating by the workspace map in the loaded user-level CLAUDE.md), and died inside an unbounded recursive Glob over a huge host tree. Retries at effort=low (temperature 0.0) replayed the same fatal trajectory verbatim, three out of three attempts. Two other agents "passed" by grading the live repository instead of the sandbox copy. Three fixes, one per leg of that failure: - Instructions now state the sandbox path explicitly, direct the agent to stay inside it, and note that omitting `path` defaults to it. Every traced agent that learned the path — by any means — reported within 40s. - New `Jailed` tool decorator confines FileRead/Grep/Glob to the agent's working directory: paths are judged by their symlink-resolved location (via deepest-existing-ancestor, so /var <-> /private/var aliasing and not-yet-existing paths both work), and glob patterns get their own rule since an absolute pattern replaces the walk base and `..` climbs out of it. Escapes return a corrective tool error instead of running away. - `AgentRunRequest.attempt` (1-based) threads the retry count to the executor: retries raise the sampling temperature by 0.5 (capped at 1.0) and append a note that a previous attempt went unreported, so attempt 2 has a reason to sample a different trajectory than attempt 1. Also formats three files (trace.rs, checks/mod.rs, trace_archive.rs) that were committed unformatted — `cargo make check-format` was failing at HEAD. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VxKv1hhPZ4GocfmwHUk1G8
1 parent bbdedc8 commit ee7136e

9 files changed

Lines changed: 619 additions & 58 deletions

File tree

src/checks/execution.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,15 @@ impl ExecutionActor {
115115
// Permit acquired ⇒ the agent is about to run: mark the check Running.
116116
let _ = presenter.tell(UiEvent::CheckStarted { id }).await;
117117

118-
let mut result =
119-
run_one(executor, sandbox, job.id, job.check.clone(), &working_dir).await;
118+
let mut result = run_one(
119+
executor,
120+
sandbox,
121+
job.id,
122+
job.check.clone(),
123+
&working_dir,
124+
attempt,
125+
)
126+
.await;
120127

121128
// Harvest this attempt's trace *before* signalling completion, so it
122129
// is collected even for retried attempts (whose outcome never reaches
@@ -249,13 +256,15 @@ async fn run_one(
249256
id: CheckId,
250257
check: Check,
251258
working_dir: &Path,
259+
attempt: usize,
252260
) -> Result<AgentOutcome> {
253261
let handle = sandbox.create(working_dir).await?;
254262

255263
let request = crate::checks::executor::AgentRunRequest {
256264
check_id: id,
257265
check,
258266
working_dir: handle.path().to_path_buf(),
267+
attempt: u32::try_from(attempt).unwrap_or(u32::MAX),
259268
};
260269

261270
let outcome = executor.run_check(request).await;
@@ -422,7 +431,9 @@ mod tests {
422431
.await
423432
.unwrap();
424433
assert!(out[0].satisfied);
425-
// Ran twice: one silent attempt, then one reporting attempt.
426-
assert_eq!(executor.seen().len(), 2);
434+
// Ran twice: one silent attempt, then one reporting attempt — and the
435+
// executor was told which attempt each was (retries must be able to
436+
// vary temperature/instructions rather than replaying attempt 1).
437+
assert_eq!(executor.seen_attempts(), vec![(0, 1), (0, 2)]);
427438
}
428439
}

src/checks/executor/cersei.rs

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use cersei_types::CerseiError;
1515
use miette::{Result, miette};
1616
use tokio_util::sync::CancellationToken;
1717

18+
use super::jail::Jailed;
1819
use super::judge::{JudgeTool, VerdictSink};
1920
use super::trace::{TraceHeader, TraceRecorder, serialize_trace};
2021
use super::{
@@ -70,11 +71,27 @@ impl CerseiExecutor {
7071
/// The read-only tool set a verification agent gets by default: observe, do not
7172
/// mutate. Execution-requiring checks (which would need Bash/Write) are gated
7273
/// separately and are future work — the default is least privilege.
74+
///
75+
/// Each tool is [`Jailed`] to the agent's working directory: "read-only" alone
76+
/// still allowed reading anywhere the user can, which let lost agents launch
77+
/// unbounded globs over the host filesystem (timeouts) and grade the live
78+
/// repository instead of the sandbox (postmortem C5). The jail turns an
79+
/// out-of-sandbox path into an immediate tool error that steers the agent back.
7380
fn read_only_tools() -> Vec<Box<dyn Tool>> {
7481
vec![
75-
Box::new(cersei_tools::file_read::FileReadTool),
76-
Box::new(cersei_tools::grep_tool::GrepTool),
77-
Box::new(cersei_tools::glob_tool::GlobTool),
82+
Box::new(Jailed::path_keys(
83+
cersei_tools::file_read::FileReadTool,
84+
&["file_path"],
85+
)),
86+
Box::new(Jailed::path_keys(
87+
cersei_tools::grep_tool::GrepTool,
88+
&["path"],
89+
)),
90+
Box::new(Jailed::glob(
91+
cersei_tools::glob_tool::GlobTool,
92+
&["path"],
93+
"pattern",
94+
)),
7895
]
7996
}
8097

@@ -111,6 +128,16 @@ fn effort_temperature(effort: Effort) -> f32 {
111128
}
112129
}
113130

131+
/// The sampling temperature for a given attempt: the effort base, raised by
132+
/// 0.5 per retry and capped at 1.0. At effort=low the base is 0.0, and a
133+
/// temperature-0 retry is a replay: the 2026-07-01 postmortem caught one check
134+
/// reproducing its fatal trajectory near-verbatim on all three attempts. A
135+
/// retry has to sample differently to be worth its wall-clock.
136+
fn attempt_temperature(effort: Effort, attempt: u32) -> f32 {
137+
let base = effort_temperature(effort);
138+
(base + 0.5 * attempt.saturating_sub(1) as f32).min(1.0)
139+
}
140+
114141
#[async_trait]
115142
impl CheckExecutor for CerseiExecutor {
116143
async fn run_check(&self, req: AgentRunRequest) -> Result<AgentOutcome> {
@@ -136,7 +163,12 @@ impl CheckExecutor for CerseiExecutor {
136163
let cancel = CancellationToken::new();
137164
let judge = JudgeTool::new(sink.clone(), cancel.clone());
138165

139-
let instructions = assemble_instructions(&req.check, &judge_tool_directive());
166+
let instructions = assemble_instructions(
167+
&req.check,
168+
&judge_tool_directive(),
169+
&req.working_dir,
170+
req.attempt,
171+
);
140172

141173
let mut agent_builder = Agent::builder()
142174
.provider_boxed(provider)
@@ -149,7 +181,7 @@ impl CheckExecutor for CerseiExecutor {
149181
.tools(read_only_tools())
150182
.tool(judge)
151183
// Thinking is intentionally left disabled (see `effort_temperature`).
152-
.temperature(effort_temperature(self.effort))
184+
.temperature(attempt_temperature(self.effort, req.attempt))
153185
.max_turns(MAX_TURNS)
154186
.cancel_token(cancel.clone());
155187

@@ -237,3 +269,18 @@ impl CheckExecutor for CerseiExecutor {
237269
Ok(outcome)
238270
}
239271
}
272+
273+
#[cfg(test)]
274+
mod tests {
275+
use super::*;
276+
277+
#[test]
278+
fn retries_raise_the_temperature_up_to_the_cap() {
279+
assert_eq!(attempt_temperature(Effort::Low, 1), 0.0);
280+
assert_eq!(attempt_temperature(Effort::Low, 2), 0.5);
281+
assert_eq!(attempt_temperature(Effort::Low, 3), 1.0);
282+
assert_eq!(attempt_temperature(Effort::Medium, 2), 1.0);
283+
// Already at the cap: retries must not push past valid API range.
284+
assert_eq!(attempt_temperature(Effort::High, 3), 1.0);
285+
}
286+
}

src/checks/executor/claude.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,12 @@ impl CheckExecutor for ClaudeExecutor {
9797
"dispatching claude -p check (fallback)",
9898
);
9999

100-
let instructions = assemble_instructions(&req.check, &file_report_directive());
100+
let instructions = assemble_instructions(
101+
&req.check,
102+
&file_report_directive(),
103+
&req.working_dir,
104+
req.attempt,
105+
);
101106

102107
let mut cmd = Command::new(&self.program);
103108
cmd.arg("-p")

src/checks/executor/fake.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ pub struct FakeExecutor {
2222
/// id to `(report_on_attempt, report)`. Exercises the retry path: the same
2323
/// `CheckId` is re-run, so the fake counts attempts per id.
2424
silent_until: HashMap<CheckId, (usize, CheckReport)>,
25-
seen: Mutex<Vec<CheckId>>,
25+
/// Every `(check_id, attempt)` the fake was asked to run, in call order.
26+
seen: Mutex<Vec<(CheckId, u32)>>,
2627
}
2728

2829
impl FakeExecutor {
@@ -72,6 +73,18 @@ impl FakeExecutor {
7273

7374
/// The check ids the fake was asked to run, in call order.
7475
pub fn seen(&self) -> Vec<CheckId> {
76+
self.seen
77+
.lock()
78+
.unwrap()
79+
.iter()
80+
.map(|(id, _)| *id)
81+
.collect()
82+
}
83+
84+
/// The `(check_id, attempt)` pairs the fake was asked to run, in call
85+
/// order — lets tests assert the retry plumbing threads attempt numbers
86+
/// through to the executor.
87+
pub fn seen_attempts(&self) -> Vec<(CheckId, u32)> {
7588
self.seen.lock().unwrap().clone()
7689
}
7790
}
@@ -81,8 +94,8 @@ impl CheckExecutor for FakeExecutor {
8194
async fn run_check(&self, req: AgentRunRequest) -> Result<AgentOutcome> {
8295
let attempt = {
8396
let mut seen = self.seen.lock().unwrap();
84-
seen.push(req.check_id);
85-
seen.iter().filter(|id| **id == req.check_id).count()
97+
seen.push((req.check_id, req.attempt));
98+
seen.iter().filter(|(id, _)| *id == req.check_id).count()
8699
};
87100

88101
if self.silent.contains(&req.check_id) {

0 commit comments

Comments
 (0)