security-management
Capability: substrate:analytics
Harness: agnostic
The deterministic core of the security-* skills.
Each skill family is a subcommand: security-issue-import (issue-import),
security-issue-import-from-pr (issue-import-from-pr)
security-issue-import-from-md (issue-import-from-md)
security-issue-import-via-forwarder (issue-import-via-forwarder)
security-issue-import-from-scan (issue-import-from-scan)
security-issue-triage (issue-triage),
security-issue-invalidate (issue-invalidate),
security-cve-allocate (cve-allocate),
security-issue-deduplicate (issue-deduplicate),
security-issue-sync (issue-sync)
and security-issue-fix (issue-fix).
The tool takes what the agent saved — search results, the redacted report text, the confirmed plan — and decides or renders everything rule-shaped:
the Step 1 pre-filter, the batched threadId dedup, the forwarder pre-screen, the tracker body and its inert report block, the rollup entry, the rejections-ledger record,
the fix-already-public reply, the receipt-draft matrix, the confirmation-reply grammar and the recap.
The agent keeps the judgement: classification, field extraction, third-party PII identification, fuzzy duplicates, prior rejections, canned-response choice and injection flagging.
It runs nothing.
No command invokes gh, a mail backend, pii-redact or pii-reveal, posts, drafts or touches the network.
Report text arrives already redacted, and redaction tokens (N-…, E-…) pass through unchanged.
A render that would emit something the skill must refuse (a malformed report body, a rollup label holding <, > or ·, a ledger record missing a key)
exits 2 with {"ok": false, "refused": "<why>"}.
Prerequisites
- Runtime: Python 3.11+, stdlib only; run with
uv run --project <framework>/tools/security-management. It imports three stdlib-only workspace members instead of copying their rules:github-body-field(what a### Fieldsection is) andgenerate-cve-json(the bot/AI credit policy) andmagpie-vcs(the embargo scrub of public text). - CLIs: None.
- Credentials / auth: None.
- Network: None.
How to use
Untrusted text reaches the tool only through a file the agent wrote with its Write tool, never through argv:
uv run --project <framework>/tools/security-management security-management issue-import render-body \
--input <scratch>/body-<threadId>.json
Every command prints one JSON object; rendered text is in its text field.
Shared rules
security_management.shared holds what every import skill applies; it has no CLI of its own.
| Module | Rule |
|---|---|
body |
inert_block: the report text, line breaks normalised (other splitlines breaks refused), byte-exact inside a fence (except a line that would end a field under the body-field contract — ### then a space, a tab or nothing, at column 0 or behind >, list markers or indent, whatever the name — or that starts with <!--, which gains a leading \\ and is listed in escaped_lines) with info string text one backtick longer than its longest backtick run (at least three), so nothing in it renders and no line of it can close the block. short_field: a short field with headings demoted, every HTML opener escaped, images and active HTML defused, fence lines escaped. verify: the rendered body through github-body-field’s normalise — exactly the template fields once each, nothing to decide, nothing to rewrite. render: one ### <heading> per field role, headings from tracker.body_fields, empty fields _No response_, Severity Unknown, the injection callout only when the model sets it. |
rollup |
Entry bodies at column 0, one-line values, the action label refused when it holds <, > or ·, and handed back shell-quoted. |
refs |
bare_refs / linkify for #NNN and owner/repo#NNN outside code spans (any backtick run), fences, links, autolinks and URLs, ported from pr-management’s _BARE_REF; tracker_link for a GitHub, Jira or local-files reference. |
titles |
strip_mail_prefixes, strip_pr_title, md_title. |
recipients |
security_cc: the security CC of every draft, each key from the first layer that declares it. |
drafting |
choose: Gmail or Maildir (the person over the project), then backend: maildir when configured, else oauth_curl, and claude_ai_mcp only without OAuth credentials and with no link in the body; anchor: what each backend attaches the draft by; scan: tracker references, the CVE-tool URL, bare references, re-opening phrases, CVSS and other ASF projects in a composed reporter draft. |
grammar |
parse_reply: the confirmation grammar; anything unplaceable goes to ambiguous_tokens for the agent to ask back, conflicting dispositions hold their candidate out of every list, unscoped free text holds everything, and a negation confirms nothing. |
keyword |
The tr -cd allowlist for search keywords, read from a file, never starting with -. |
fields |
read: template fields of an existing tracker body through github-body-field (normalise, then extract_field); a field it will not decide on is undecided, never guessed or read as empty. |
threads |
classify: the Security mailing list thread field as an import path (archive URL, Gmail-only, PR or markdown sentinel, indeterminate), the primary thread by the tools/gmail/threading.md relay signals, and the root Message-ID. |
dispositions |
The six triage classes, the two proposal markers (**Triage proposal**, **Proposed disposition: ) and parse_time. |
numbered |
parse: the numbered-proposal reply of cve-allocate and issue-deduplicate — all, a list (with a comment after a dash only when it names other items to hold them back), cancel; anything else is an edit, and nothing applies by default. |
rollup.notification_line |
The Reporter notification line (notified, team_member, draft_pending only once the draft is checked to be in Drafts, omitted). |
selectors |
issue_numbers: N, #N, #M, #N-#M, owner/repo#N, an issue URL, refusing another repository; the CVE token rule. |
shell |
dq / w, a copy of release-management’s. |
Commands
issue-import
| Command | Prints |
|---|---|
governance --config <security-intake-config.md> |
reporter_acknowledgement_model, window_days, config_file_found; manual / 90 when the file or the disclosure_governance block is absent, a warnings entry for a value outside the vocabulary. |
prefilter --threads F |
Per thread: decision (keep / drop), rule (1 GHSA relay, 2 CVE-tool bookkeeping, 3 anything else), class, pretag; dropped and counts. Every message must match rule 2 to drop; a missing subject or sender keeps; named_thread_id is never dropped. |
dedup-queries --thread-ids F [--tracker T] |
batches of at most six ids, each a quoted phrase OR’d into query, and the shell-quoted command; ids are Gmail / PonyMail ids, maildir names or root Message-IDs, and any other id exits 2 with rejected_ids. |
dedup-attribute --thread-ids F --hits F |
tracked (id → hits), untracked; at 100 hits truncated, no untracked, and rerun batches; exit 2 with rejected_ids as above. |
relay-prescreen --message F --forwarders F |
invoke_subskill: false only when no enabled adapter’s sender_pattern / preamble_match (first 400 characters) matches; an unresolved or invalid pattern invokes. |
keyword --file F [--kind keywords|email-local] |
cleaned, empty. |
title --input F |
title: the subject without its mail prefixes. |
linkify --input F |
text with every bare reference linked into repo, and the bare_refs found. |
render-body --input F |
The body text, sanitised_fields, fields as github-body-field parses them, fields_ok, extra_fields, reporter_credit_is_bot, bare_refs; exit 2 with malformed_indicator for a stray line break, or when verify refuses the rendered body. |
render-rollup --input F |
label, label_shell, text: the import entry, with the receipt line for a draft, a bundle, a converged disposition or a suppression, and the malformed-body note. |
render-ledger --input F |
The <!-- rejection v1 --> record; values on one line, archive unresolved (archive lag) when not given. |
render-fix-public-reply --input F |
The thank-without-credit reply, merged on / opened on per PR, one PR or a list. |
ack-decision --input F |
draft_created, draft_auto_ack_tagged, suppressed_reason, rollup_note, rollup_receipt, proposal_note, route, window_days — the Step 7 item 4 matrix. |
render-recap --state F |
issues_created ({number, url} on GitHub, {id, url} on Jira and local-files, per platform), drafts_waiting (a shared draft once), skipped (full owner/repo#NNN), cve_tool_bookkeeping_dropped (pre-filtered included), text. |
parse-reply --input F |
action, import_items, skip_items, reject_with_canned, reject_with_public_fix, edits, overrides, keep, non_import_confirmed, ambiguous_tokens, ambiguous_items. |
issue-import-from-pr
| Command | Prints |
|---|---|
parse-ref --ref R --upstream U |
number from N, owner/repo#N or the PR URL (trailing path ignored); exit 2 for another repository or free text. |
scope --files F --scope-config F |
scope (or mixed_scope_blocker), stop, detection (matched, mixed, unresolved, disabled), overlapping, mixed_scopes, sub_packages (also as affected_providers), packages, files_counted, test_files_stripped (any tests/ segment), unmatched, matched. |
milestone-check --input F |
exists (exact match only), near_matches, blocker. |
title --input F |
title without conventional-commit prefix, skip-ci markers and trailing (#N); too_short under 25 characters. |
render-body --input F |
The body text: imported-from note over the PR body as an inert block (its references stay unlinked text), the mailing-thread sentinel, PR with the fix, Remediation developer (_No response_ for a bot, remediation_developer_is_bot); sanitised_fields, fields_ok, bare_refs; exit 2 with malformed_indicator for a PR body that does not pair. |
labels --input F |
labels: scope, pr_open / pr_merged, security_marker from tracker.labels; never needs_triage; warnings for a closed PR. |
render-rollup --input F |
label, label_shell, text: the first rollup entry, column 0. |
dup-query --pr N --tracker T [--hits F] |
The search query and command; with --hits, hits with links, duplicate, truncated at 30 and its warning. |
render-recap --input F |
The recap text and hand-off, bare_refs. |
issue-import-from-md
| Command | Prints |
|---|---|
parse <file> --upstream U |
findings[] (index, title, tracker_title, severity, category, repository, branch, date_created, the section payloads, has_*, warnings), warnings, total_findings; exit 2 with not-a-findings-file without a # heading, a **Severity:** line or a pair. |
keyword --file F [--tracker T] |
cleaned, empty and the single-quoted search_command. |
render-body --input F |
The body text: each section an inert block (exit 2 on a stray line break or a body verify refuses), the field mapping, the Recommended-fix <details>; sanitised_fields, fields_ok, reporter_credit_is_bot, bare_refs. |
render-rollup --input F --k K --n N |
label, label_shell, text. |
render-proposal --input F |
The proposal table, default disposition and reply forms, duplicates linked. |
render-recap --input F |
The recap text and hand-off. |
issue-import-via-forwarder
| Command | Prints |
|---|---|
preflight --forwarders F --message F --framework D |
outcome (proceed, no-forwarder-config, abort with exit 2), preflight_passed, error, note; installed means a tools/forwarder-relay/<name>/ directory or a shipping README row whose doc exists. |
detect --message F --forwarders F [--collaborators F] |
The first enabled adapter whose sender pattern or preamble regex (first 400 characters) matches: match, sub_skill_applied, preamble_snippet, sender_matched, collaborator_warning, adapters; exit 2 on an unresolved pattern. |
credit-kind --input F |
kind (tool under the bot/AI policy, else the adapter’s kind_hints, else human), is_bot, credit_unknown. |
first-name --input F |
first_name of a handle or display name; null (generic salutation) for an address. |
route --mode M [--milestone K] --adapter F |
to_recipients (first handle) and fallback_recipients, addressing_block_emitted, question_mode, negative_space_suppressed (a do-not-relay milestone in sync mode). |
issue-import-from-scan
| Command | Prints |
|---|---|
bucket-action --bucket B |
creates_tracker (true only for import-as-tracker: Golden rule 3), action (tracker, pr-or-skip, none), route. |
render-report --findings F --buckets F [--title T] |
The report text (distribution line, a section per bucket, a row per finding; for several scans the outcome table, percentages and severity vs disposition), distribution, trackers_possible; a by-design, duplicate or already-fixed row without grounding is refused. |
parse-decision --input F |
action (apply, skip, cancel, ambiguous), confirmed (each with its bucket’s creates_tracker and action), skipped, ambiguous_tokens; an ambiguous reply holds everything. |
scrub --text F --tracker T [--allowed-cves F] |
clean and hits (private-tracker-ref, cve-id, embargo-term, bare-ref, with line); the text is never rewritten. |
issue-triage
Every command takes --input F, a JSON file.
| Command | Prints |
|---|---|
selector |
action (list_query, verbatim, cve_search; error exits 2), issue_numbers, scope_label, cve_token, retriage, no_preflight, explicit, and the plain gh command (--limit 1000; --retriage drops the needs triage filter). |
queue |
stop (empty, limit at 1000, ask for a CVE on several trackers, confirm for --retriage over 50 unless confirmed), echo, numbers, and batches of at most 20 aliased issues (query, file, command as -F query=@file). |
preflight |
The four-rule skip over trackers or raw responses: kept, skipped (date, class, rule), mail_checks (rule 4 still to read), explicit_notes, missing_numbers, all_skipped, disabled. |
precedent-signal |
disposition_signal from the rated precedents, budget_exhausted, trust_boundary_load_bearing, the Direct precedent citations. |
mentions |
handles (PR author, reviewer, topic owners, scope default only as fallback; cap 3; roster only; never the viewer), dropped, routing_fallback, fallback_note. |
render-proposal |
The comment text (markers, class line, severity, fix shape, class additions incl. the FIX-ALREADY-PUBLIC reply draft, action items, mention line, names scrubbed, references linked), file, command, unlinked_cves; refuses an unknown class, a CVSS severity, more than 3 handles or one off the roster, an INFO-ONLY heading not in the list, a PROBABLE-DUP without kept tracker. |
scrub |
text with bare display names as @-handles (outside code and URLs) and references linked, replacements_made, linked_refs. |
parse-reply |
action (post, cancel, ambiguous), post_items, post_now, skip_items, edits, reclassifications, redraft_items, force_triage, ambiguous_tokens, ambiguous_items; nothing posts by default. |
render-recap |
distribution (six keys), trackers, next_steps by class, next_step_notes, skipped, sync_note, text, bare_refs. |
issue-invalidate
Every command takes --input F, a JSON file.
| Command | Prints |
|---|---|
selector |
mode (single, bulk, proposed), numbers, the gh issue view / invalidate proposed list command, and for bulk the aliased batches with thumbs-up reactions; another repository exits 2. |
consensus |
agreed (a roster member’s INVALID proposal with a thumbs-up or acknowledgement from another roster member), awaiting with the recap note, stop at the 1000 cap. |
hard-stops |
verdict (proceed, noop, stop, escalate, probe, ask), cve_id, message from state, labels, the CVE tool link field and the generic CVE state. |
import-path |
import_path, email_draft_step, thread_id (field, else the rollup provenance), thread_source, secondary_threads, root_message_id, reporter_credit; an ambiguous field exits 2. |
labels |
labels_to_add (invalid), labels_to_remove (needs triage, scope, pr created / pr merged when set), security_issue_stays, the gh issue edit command. |
board |
The introspection query file and command; given items, the archiveProjectV2Item mutation or the not on the board note. |
draft-plan |
action (skip, ask, surface_existing, blocked, create), notification_case, and for a draft to, cc, subject (Re: <root subject>), backend, anchor; links without OAuth credentials exit 2. |
check-draft |
clean and hits: tracker reference, CVE-tool URL, bare reference, re-opening phrase, CVSS, other ASF projects, a duplicate’s missing canonical CVE. |
render-close |
The brief closing comment text. |
render-rollup |
label, label_shell, text (at most 5 quotes, the canned link, exactly one Reporter-notification line), quotes_dropped, needs_amend, bare_refs; no notification case exits 2. |
parse-reply |
action (apply, apply_silent, replace_email_body, repick_canned, cancel, ambiguous), email_body_override, canned_section_override. |
apply-plan |
The 6a-6g steps in order, with file paths and commands, and the label plan. |
render-recap |
The recap text with the hand-off line, bare_refs. |
cve-allocate
Every command takes --input F, a JSON file.
| Command | Prints |
|---|---|
selector |
issue, cve_override, resume_step (4 with an override) and the Step 1 commands; a malformed CVE token, two CVE IDs, two trackers or another repository exits 2. |
blockers |
verdict (proceed, blocked), blockers, warnings (needs triage, only when no hard blocker fired), existing_cve, already_wired, scope_label; empty scope_labels exits 2. |
preflight |
path (api, recipe, or null with the next check command), reason, offer_setup, setup_command. |
title |
original_title, normalized_title, word_count, changed, over_strip_warning (fewer than 3 words). |
recipe |
path (api, recipe, relay), text, the vulnogram-api-allocate command with the title shell-quoted, bare_refs; an unanswered authorisation question exits 2. |
allocate-result |
next (step-4, stop, setup, check-records, recipe), cve_id, message, rerun_allocate (true only after an expired token). |
cve |
cve_id and record_url from the user’s paste-back; none, a malformed token or two IDs exit 2. |
draft-plan |
action (skip, ask, surface_existing, blocked, create), notification (omitted, team_member, notified, draft_pending) and for a draft to, cc, subject, backend, anchor, secondary_threads. |
check-draft |
clean and hits: a missing CVE ID, the CVE-tool URL, a tracker reference, a bare reference, CVSS, other ASF projects. |
proposal |
The numbered items (5 only when a draft is created), proposal_items_count, the cve_tool_link_value, notes, text. |
render-rollup |
label (CVE allocated (<CVE-ID>)), label_shell, text, needs_amend, bare_refs; a draft id not checked against Drafts exits 2. |
parse-reply |
action (apply, cancel, edit, ambiguous), items_applied, items_skipped. |
apply-plan |
The confirmed items’ steps in order, with file paths and commands, per backend. |
render-recap |
The recap text, bare_refs. |
issue-deduplicate
Every command takes --input F, a JSON file.
| Command | Prints |
|---|---|
selector |
keep, drop (kept first) and the two Step 1 reads; one tracker, three, a range or another repository exits 2. |
classify |
verdict (proceed, blocked, probe), blockers (scope_unclear, scope_mismatch, closed_tracker, already_duplicate, cve_on_drop, cve_public, drop_further_along), keep_cve, retract. |
fields |
Both sides’ template fields (the CVE JSON attachment cut off), has_cve_json_attachment, labels, milestone, cwe_conflict, severity_propagation_risk; an undecided field exits 2. |
merge-body |
The kept body rewritten field by field (replace_field), changed_fields, blockers (a CWE conflict), scores_removed, severity_note, tool_credits, skipped_credits; a missing headline, analysis or needed merged value exits 2. |
render-rollup-kept, render-rollup-dropped |
label, label_shell, text, needs_amend, bare_refs. |
proposal |
The numbered items (7 only with two CVEs), blocked, text. |
parse-reply |
action (apply, cancel, edit, ambiguous), items_applied, items_skipped, note. |
apply-plan |
The confirmed items’ steps in order, per backend; an open blocker or the same tracker twice exits 2. |
render-recap |
The recap text, blockers, bare_refs. |
issue-sync
Every command takes --input F, a JSON file.
| Command | Prints |
|---|---|
selector |
queries (each with id, kind, command, limit), explicit numbers, searched_cve_ids, no_preflight; a malformed CVE-… token sets error: invalid_cve_token and searches nothing. |
resolve |
resolved (ascending; rejections-ledger dropped, closed announced cut to 90 days, a CVE kept only where its CVE tool link names it), explicit, title_matches to confirm, warnings for a capped list, the echo. |
preflight-query |
One aliased GraphQL read per 50 trackers: file, query, command. |
preflight |
Per tracker decision and rule, dispatched, urgent, skipped, the Pre-flight skipped text; explicit trackers and --no-preflight are never skipped. |
bucket |
cve_affecting, non_cve_affecting, walk_order, review_ready_push and its bucket. |
parse-bulk-reply |
action (apply, force_sync, cancel, none, ambiguous), apply per tracker, edits, force_sync, skip. |
parse-reply |
The single-tracker form through shared.numbered. |
check |
clean and violations (surface, guardrail, evidence) for reporter CVSS, other ASF projects, bare CVE IDs or the CVE-tool URL in an email, and bare #NNN. |
security-cc |
security_cc, cc_fallback, warning and warning_text, draft_blocked, cc, search_list, through the project / organization / framework layers. |
draft-backend |
backend (maildir, oauth_curl, claude_ai_mcp), decided_by (user.md, project.md, default); no credential with links in the body exits 2. |
ghsa-route |
access_tier, action, channel, and for an admin hand-off the draft tool, addressee, thread, rollup marker and three parts. |
push-decision |
action (skip, block, hand-off, push, pending with the next command or gate), gate_failed, handoff_variant, state_advance, recap_blocker. |
disclosure |
issue_age_days, overdue_for_disclosure, approaching_window_end, grace_period_expired, distributor_notify_pending, expected_disclosure_by. |
staleness |
stale and days: the team’s latest message older than the timeout with no reporter reply since. |
closed-announced |
cve_org (notify, already_notified, wait, blocker, retry, skip), security_pages_reminder_pending, advisory_records and its next step, blockers. |
release-vote |
release_vote_in_progress and release_vote_thread_url for an open [VOTE] matching the fix-PR milestone. |
render-recap |
The recap text, bare_refs. |
issue-fix
Every command takes --input F, a JSON file.
| Command | Prints |
|---|---|
flow |
flow (pull-request, patch-then-commit, stop), follow_subversion_flow, open_pr, from magpie-vcs fix-flow’s exit and JSON. |
scrub |
Per surface (branch, title, body, commit_message, newsfragment, comment) valid, terms and hits; clean, forbidden_terms_found; for a newsfragment include_newsfragment (false) and security_framing_violation. |
backport |
backport_needed and backport_labels for the milestone against the next main release, plus confirmed extra lines. |
parse-reply |
The plan confirmation (items 1–7) through shared.numbered. |
review-plan |
run, skip_notice (silent, one-line), the title and body file passed, and the adversarial-review command. |
svn-share |
The three private proposals, attach_via, public_surface_used (false), next (stop-await-review). |
svn-gate |
action (ask, stop, run-propose-commit, revise, await-confirmation), the prefixed commands; a commit / new-branch without --scrub exits 2. |
render-recap |
The recap text, bare_refs. |
Tests
uv run --directory tools/security-management pytest -q
The cases of the retired model-graded security-issue-import suites step-0-preflight, step-6-confirm and step-7-apply
are pytest cases named after each former case, in tests/test_issue_import.py and tests/test_grammar.py.
The cases of security-issue-import-from-pr/step-2-scope-detection are in tests/test_issue_import_from_pr.py,
those of security-issue-import-from-md/step-1-parse-findings (cases 1–3) and step-2-duplicate-guard (case 1) in tests/test_issue_import_from_md.py,
and those of security-issue-import-via-forwarder/step-0-preflight, step-1-detect-adapter (cases 1, 2, 3, 6) and step-3-route-drafts in tests/test_issue_import_via_forwarder.py.
tests/test_issue_import_from_scan.py pins the security-issue-import-from-scan bucket rule the step-c-bucket model eval grades.
tests/test_issue_triage.py holds the cases of the retired security-issue-triage suites step-1-selector, step-5-confirm and step-7-recap, and cases 1–2 of step-6-body-scrub;
it pins the step-26-precedent-search signal rule, which stays a model eval.
tests/test_issue_invalidate.py holds the cases of the retired security-issue-invalidate suites step-2-import-path, step-5a-labels, step-5b-closing-comment, step-5d-email-draft, step-5e-rollup-entry, step-5f-confirm and step-7-recap.
tests/test_cve_allocate.py holds the cases of the retired security-cve-allocate suites step-1-blocker-checks, step-2-title-normalize, step-3-allocation-recipe, step-4-propose-updates, step-5-confirm and step-7-recap.
tests/test_issue_deduplicate.py holds the cases of the retired security-issue-deduplicate suites step-1-classify, step-2-extract-fields, step-3-merge-body, step-4-rollup-entries, step-5-confirm and step-6-recap.
tests/test_issue_sync_state.py pins the security-issue-sync Step 1 observations;
tests/test_issue_sync.py and tests/test_issue_sync_rules.py hold the cases of its retired suites step-bulk-selectors, step-bulk-orchestration, step-3-confirm, step-6-recap, step-guardrails, step-security-cc, step-draft-backend, step-ghsa-access-tier and step-5b-push-decision.
tests/test_issue_fix.py holds the cases of the retired security-issue-fix suites step-0-fix-flow, step-5a-branch-name, step-5c-title-check, step-5e-backport, step-5f-newsfragment, step-6-confirm and step-11-recap, and pins the deterministic parts of steps 7, 8 and 9.
tests/test_body.py checks that the tracker-body-template.md files of the sibling import skills carry exactly the rendered headings.