fix(missions): the security scan phase now scans, and task upserts work

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]>
This commit is contained in:
Omar Sobh
2026-08-19 08:08:27 -07:00
co-authored by Claude Opus 5
parent 4358964c05
commit 18dc0b964b
12 changed files with 699 additions and 30 deletions
+96 -5
View File
@@ -5,29 +5,120 @@ requires_repo = true
default_team_template = "rust_sdlc"
# ── What is real here, and what is decoration ────────────────────────
#
# The scanners themselves are REAL: `gitleaks`, `trivy`, `semgrep` and
# `cargo-audit` are installed in the runtime image and `runtime_preflight`
# probes for all four at boot, naming the consequence when one is absent. An
# agent in a security_scan phase can run them from Bash today.
#
# `tools` is REAL too: `security_scan::run` reads it to pick which scanners to
# run and upserts each finding as a mission_task. What was missing was anything
# that FIRED it — it was reachable only from an operator button, so this
# workflow's scan phase never scanned. `phase_runner::scan_finished_security_phases`
# now runs it when the phase finishes, guarded on a completion marker so a
# clean repo is not rescanned forever.
#
# What remains decoration is annotated inline below. And the part that actually
# mattered: no phase carried a `task` or a `done_when`.
# A phase with no `done_when` never enters `evaluating`, is never judged, and
# reports `completed` whatever it did. So this recipe could run all three
# phases, scan nothing, patch nothing, and go green. That is the same defect
# `research_and_code.toml` was fixed for, and it is why the tasks below name
# the scanners explicitly rather than trusting `tools` to deliver them.
[[phases]]
kind = "security_scan"
order_idx = 0
[phases.config]
# Slice 8 wires these tools as an MCP bundle. Each finding becomes
# a mission_task with external_id = CVE/RUSTSEC/gitleaks fingerprint.
# READ by `security_scan::run` — this list gates which scanners the platform
# runs against the checkout after the phase finishes. The agent ALSO runs them
# itself during the phase (see the task): the platform pass is the independent
# record, the agent pass is what lets it write a report about what it found.
tools = ["cargo_audit", "gitleaks", "trivy_fs", "semgrep"]
produces = ["md"]
task = """
Scan this repository for security problems and write findings down.
Run the scanners that are installed in your container — `cargo audit`, \
`gitleaks detect`, `trivy fs .` and `semgrep --config auto` — from the repo \
root. If one is missing or errors, say so explicitly in the report rather than \
omitting it: a section absent because a tool failed reads identically to a \
section absent because nothing was found, and those are opposite conclusions.
Write SECURITY-FINDINGS.md with one entry per finding: the identifier \
(CVE / RUSTSEC / rule id), the file and line, what an attacker could actually \
do with it, and whether it is reachable in our code or sits in an unused \
dependency path. Rank by exploitability, not by the scanner's severity field.
A clean scan is a real and useful result. Say which tools ran, on what, and \
that they found nothing — do not manufacture findings to fill the report.
"""
done_when = "SECURITY-FINDINGS.md exists and states, for each of the four scanners, whether it ran and what it found, with every reported finding carrying an identifier, a location, and a reachability judgement"
max_iterations = 2
commit_policy = "always"
[[phases]]
kind = "research"
order_idx = 1
[phases.config]
produces = ["md", "pdf"]
# `pdf` dropped: PDF rendering was removed from the delivery path (artifacts are
# served as Markdown), so asking for it produced a format nothing generates.
produces = ["md"]
default_topology = "hub_spoke"
# The research phase reads the security_scan phase's findings from
# mission_tasks and produces a patch strategy per finding.
# NOTE: `input_from_phase` is INERT — DECLARED_BUT_UNREAD. Phases do not receive
# a structured hand-off from a named predecessor; the next phase reads the
# previous phase's committed FILES out of the shared checkout. That is why the
# task names SECURITY-FINDINGS.md by path.
input_from_phase = "security_scan"
task = """
Turn the scan findings into a patch strategy.
Read SECURITY-FINDINGS.md from the repo root — that is the previous phase's \
output, committed to this mission's branch. For each finding that is actually \
reachable, write into SECURITY-PLAN.md: the fix, the specific file and \
function it touches, what could break, and how the fix will be verified.
Where the fix is a dependency bump, check what the new version changes — a \
major bump presented as "update the version" is how a security patch becomes \
an outage. Where a finding is not worth fixing, say so and say why; an \
unreachable advisory in a dev-dependency is a legitimate "no action".
"""
done_when = "SECURITY-PLAN.md exists and gives, for every reachable finding in SECURITY-FINDINGS.md, either a named fix with the file it touches and how it will be verified, or an explicit justification for taking no action"
max_iterations = 2
commit_policy = "always"
[[phases]]
kind = "coding"
order_idx = 2
[phases.config]
# NOTE: `loop` is INERT — DECLARED_BUT_UNREAD ("phase iteration uses
# max_iterations + done_when"). Kept so the intent stays visible beside the two
# keys that actually drive the loop.
loop = "until_all_findings_closed"
max_iterations = 3
# Security requires reviewer approval on top of green tests.
commit_policy = "on_reviewer_approval"
# NOTE: `mcp_bundles` is INERT AT PHASE LEVEL — bundles come from the TEAM
# template (`mission_orchestrator` binds `template.mcp_bundles`). This phase
# gets neither `gitea_forge` nor `security_scan` from this line, and there is no
# `security_scan` bundle to get. Left visible rather than deleted because the
# gap between what a recipe asks for and what a phase receives is the thing
# worth being able to see.
mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge", "security_scan"]
task = """
Apply the patch strategy and prove it worked.
Work through SECURITY-PLAN.md. Make the smallest change that closes each \
finding, and run the test suite after each one so a regression is attributable \
to a single fix rather than to the batch.
Then RE-RUN the scanner that produced each finding and record the new output in \
SECURITY-FINDINGS.md under a "after remediation" heading. A fix that was not \
re-scanned is a claim, not a result — and this phase's whole value is the \
difference between those two.
If a fix cannot be made safely, leave the finding open and say why. An open \
finding that is documented is worth more than a closed one that is not true.
"""
done_when = "every finding in SECURITY-PLAN.md marked for fixing is either closed with a re-run of the scanner that found it recorded in SECURITY-FINDINGS.md, or left open with a stated reason, and the test suite passes"