Four defects, found by checking the audit's claims instead of trusting them. Two of the audit's own findings turned out to be wrong, and the registry that exists to record which config keys are read was itself inaccurate — so the corrections are part of the change. upsert_task raised 42P10 on every call, for every caller `mission_tasks_external_uniq` is a PARTIAL unique index (WHERE external_id IS NOT NULL). Postgres will not match a partial index to an ON CONFLICT target unless the statement repeats the predicate, so the upsert failed on its first row. Both callers — the task-card parser that turns INT markers into tasks, and the security scanner — map the error to a string their caller logs. Two features were broken and nothing was red. Regression test in cm-db with a negative control: reverting the WHERE reproduces 42P10 exactly. the security scan never ran `security_scan::run` was reachable only from an operator button, so security_hardening.toml — a workflow whose entire first phase is a scan — ran an agent that was never told to scan and never fired the scanner either. phase_runner now sweeps finished security_scan phases, mirroring the benchmark baseline sweep that was added for the identical defect. Guarded on a new completion marker rather than on findings: a clean scan writes no findings, so a findings-guard would rescan forever. The marker also answers the question an operator actually asks, which is not "how many findings" but "was this looked at, by what, and when". two recipes could not fail security_hardening.toml and benchmark.toml carried no `task` and no `done_when` on any phase. A phase without done_when never enters evaluating, is never judged, and reports completed whatever it did — so a security mission could scan nothing and go green, and a benchmark mission could record no baseline that the next refactor would then compare against. Both now state the work and the condition, with inert keys annotated inline rather than deleted, so the gap between what a recipe asks for and what a phase receives stays visible. the config registry was wrong in both directions `harness` was listed NOT IMPLEMENTED while benchmark_runner reads it and phase_runner runs a baseline through it. `tools` was listed NOT IMPLEMENTED while security_scan::run reads it. A registry that exists so an operator can trust what a recipe does is worse than useless when it is inaccurate. Both corrected, `bench_name` and `cmd` added, and `test_command` deleted — it had neither a reader nor a writer, so it described a situation that could not arise. Also: CLAWMATES_JUDGE_MODEL had two different defaults (opus-4-8 in routes/topology.rs vs opus-5 in cm_runtime::judge_model) and a doc comment naming a third; topology now calls the one function. GITEA_TOKEN's absence in mission_plan is stated rather than degrading to the same "could not be read" string a private repo produces. BRAINHUB_API_KEY needed no change — hub::push already rejects an unset key with a named error. That half of the finding was overstated. Co-Authored-By: Claude Opus 5 <[email protected]>
129 lines
4.1 KiB
Rust
129 lines
4.1 KiB
Rust
//! The guard on the security-scan sweep.
|
|
//!
|
|
//! `phase_runner::scan_finished_security_phases` fires `security_scan::run`
|
|
//! for finished `security_scan` phases. The scan needs Docker; the part that
|
|
//! decides whether it runs twice, once, or never is pure SQL, and it is the
|
|
//! part that fails silently in both directions — rescanning forever, or never
|
|
//! scanning at all and leaving a phase that looks identical to a clean repo.
|
|
|
|
use cm_domain::{Workspace, WorkspaceId};
|
|
use uuid::Uuid;
|
|
|
|
async fn seed_mission(pool: &sqlx::PgPool) -> Uuid {
|
|
let ws = Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "Security Sweep Test".into(),
|
|
plan: "team".into(),
|
|
};
|
|
cm_db::repo::workspaces::insert(pool, &ws).await.unwrap();
|
|
let id = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO missions (id, workspace_id, title, template_kind, status)
|
|
VALUES ($1, $2, 'security test', 'security_hardening', 'running')",
|
|
)
|
|
.bind(id)
|
|
.bind(ws.id.as_uuid())
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
id
|
|
}
|
|
|
|
async fn seed_phase(
|
|
pool: &sqlx::PgPool,
|
|
mission_id: Uuid,
|
|
kind: &str,
|
|
status: &str,
|
|
order_idx: i32,
|
|
) -> Uuid {
|
|
let id = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO mission_phases (id, mission_id, kind, order_idx, status, completed_at)
|
|
VALUES ($1, $2, $3, $4, $5, now())",
|
|
)
|
|
.bind(id)
|
|
.bind(mission_id)
|
|
.bind(kind)
|
|
.bind(order_idx)
|
|
.bind(status)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
id
|
|
}
|
|
|
|
async fn mark_scanned(pool: &sqlx::PgPool, mission_id: Uuid, phase_id: Uuid) {
|
|
cm_db::repo::missions::upsert_task(
|
|
pool,
|
|
cm_db::repo::missions::UpsertTask {
|
|
mission_id,
|
|
phase_id,
|
|
external_id: cm_api::security_scan::SCAN_MARKER,
|
|
title: "security scan complete — ran [gitleaks], 0 finding(s)",
|
|
assigned_agent_id: None,
|
|
status: "created",
|
|
run_id: None,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_finished_security_phase_is_selected_until_it_carries_a_scan_marker() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let mission = seed_mission(&pool).await;
|
|
let phase = seed_phase(&pool, mission, "security_scan", "completed", 0).await;
|
|
|
|
let selected = cm_api::phase_runner::unscanned_security_phases(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
selected.iter().any(|(p, _)| *p == phase),
|
|
"a finished security_scan phase with no marker must be selected — \
|
|
otherwise the scan never runs and the phase reports completed having \
|
|
scanned nothing"
|
|
);
|
|
|
|
// A clean scan writes NO findings, so the marker is the only evidence the
|
|
// scan happened. This is the case that would otherwise rescan forever.
|
|
mark_scanned(&pool, mission, phase).await;
|
|
|
|
let selected = cm_api::phase_runner::unscanned_security_phases(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
!selected.iter().any(|(p, _)| *p == phase),
|
|
"a phase carrying the scan marker must not be selected again, even \
|
|
though it has zero findings"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn only_finished_security_phases_are_selected() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let mission = seed_mission(&pool).await;
|
|
|
|
let running = seed_phase(&pool, mission, "security_scan", "running", 0).await;
|
|
let coding = seed_phase(&pool, mission, "coding", "completed", 1).await;
|
|
let failed = seed_phase(&pool, mission, "security_scan", "failed", 2).await;
|
|
|
|
let selected: Vec<Uuid> = cm_api::phase_runner::unscanned_security_phases(&pool)
|
|
.await
|
|
.unwrap()
|
|
.into_iter()
|
|
.map(|(p, _)| p)
|
|
.collect();
|
|
|
|
assert!(
|
|
!selected.contains(&running),
|
|
"scanning a phase still running would scan a half-written checkout"
|
|
);
|
|
assert!(!selected.contains(&coding), "only security_scan phases scan");
|
|
assert!(
|
|
selected.contains(&failed),
|
|
"a FAILED security phase is exactly the one worth scanning — the \
|
|
scanners are how we find out what state it left behind"
|
|
);
|
|
}
|