Files
clawmates/templates/workflows/security_hardening.toml
T
Omar SobhandClaude Opus 5 5c2c63f8e8 feat(missions): a human can finally reach the plan/roster review gate
Phase 4 of the plan, plus the PLAN_COMPLETE decision and the gitea_forge
cleanup from Phase 5.

THE REVIEW UI

mission_plan and mission_roster have been complete and reachable by curl
since they shipped, with zero frontend. That matters more than a missing
screen usually would: the decide step is not a convenience, it IS the
safety mechanism. Approving a plan replaces the mission's phases; approving
a roster flips it to the composed engine. A gate nobody can reach is a gate
that is always open or always shut.

MissionProposalDrawer, modelled on LevelUpDrawer which already does
load → review → decide. Reached from a mission's SETUP tab. Verified end to
end against the live backend, not just compiled: a model proposed a roster,
approval flipped the mission to `composed`, and approval on a non-draft
mission was refused.

The plan view shows each phase's done_when, and says plainly when one is
absent — a phase without a completion condition is never judged and reports
completed whatever it did, so its absence is the thing worth seeing.

AND THE DEFECT BUILDING IT FOUND

Every refusal path computed a precise reason — "the mission is running, not
a draft", "no node can boot that backend any more" — logged it to stderr,
and returned a bare {"error":"bad request"}. The person who needed the
sentence was the one clicking Approve; they got two words, and the reason
went to a server log they cannot read.

ApiError::Refused(String) carries it now. Same argument ApiError::Unavailable
was added for ("a 500 with 'internal error' sent them looking for a bug that
was not there"), one status code down. Live: the 400 now reads "this mission
is completed — a roster can only be approved while it is a draft, because
approving one rewrites how the mission will run".

PLAN_COMPLETE, decided

The Skill-Use measurement found that int-xx-marker-protocol documents
PLAN_COMPLETE and task_card_parser never implemented it, so an agent
following the skill exactly was silently ignored. Implemented rather than
removed from the skill: the planner needs a way to say it is done
specifying, and agents already emit it.

Marker ids are now strictly INT-<digits>. `starts_with("INT-")` accepted the
range form `INT-01..02` — observed live — which parsed into an id matching
no real item, so a task card appeared for something that did not exist while
the two items it covered stayed open. Rejecting is right: an ignored marker
is visible, a plausible row is not.

GITEA_FORGE, REMOVED

Named in nine places, defined in none. Harmless while provision_claw ignored
the bundle list; once the list was honoured, an undefined name became a
capability an agent is told it has and does not. Removed from seven team
templates, a workflow recipe, the auto-provision path, and a dropdown a user
could pick it from.

A new test asserts every bundle a template names is defined in the runtime
config — and it immediately found `web_fetch` in two templates I had missed
removing by hand. Same shape as the skill-binding test, one layer up.

Agents reach the forge through git over HTTPS with the ambient GITEA_TOKEN,
which is why nothing ever broke.

Full workspace suite green (106 binaries); frontend builds clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:56:54 -07:00

125 lines
6.4 KiB
TOML

key = "security_hardening"
title = "Security Hardening"
blurb = "Scan the repo for vulnerabilities, research patches, then apply + verify."
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]
# 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]
# `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"
# 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 nothing from this line. `gitea_forge` and `security_scan` were removed
# from it entirely: neither is defined anywhere, and now that provision_claw
# HONOURS the team template's bundle list, naming a bundle that does not exist
# stopped being harmlessly inert.
mcp_bundles = ["clawmates_door", "clawmates_skills"]
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"