Repository navigation
fix(security-tracker): harden the local and Jira backends after review - #1683
Merged
Merged
Conversation
The security-tracker backends merged in apache#1647 without the fixes from its last review rounds. This carries them forward and closes the findings of the final review that were still open. Local-files tracker (git run outside the sandbox through vetted-ops): - publish pushes `HEAD:<branch>` to exactly the remote and ref it checked, with --no-follow-tags, so push.default=matching, a remote's push refspecs or push.followTags cannot send other branches or tags; - git reads attributes from the empty tree (GIT_ATTR_SOURCE), so no .gitattributes can switch on a filter, diff or merge driver even if one is configured after the per-command config check; - network-only pushes, no gc/maintenance hooks, and a tree comparison against the destination (from the earlier rounds); - descriptor names may contain spaces (`in progress`), still one path component; a bare number naming two reports is ambiguous, and underscore slugs resolve by their bare ID; - inline lists split on real item boundaries (apostrophes, escapes); - comment-marker authors are percent-encoded, so names with spaces read back. Jira tracker and setup: - a fence closes only on a matching fence of the same character and at least the same length, so a longer block cannot expose PoC headings; - the CLI takes an explicit --deployment, which vetted-ops always passes, so an inherited JIRA_DEPLOYMENT cannot switch a Server tracker to Cloud endpoints; - fresh setup scaffolds `tracker.platform: TODO`, so the backend question is asked; the Jira steps map labels to whitespace-free names and require `doctor` to pass. The plugin's copy of the vetted-ops sources and README is brought back in line with tools/vetted-ops. Generated-by: Claude Opus 5
…E fields
- The jira and local-tracker backends reached the vetted-ops plugin as
symlinks out of the plugin root, which a self-contained install
(Codex, a plugin cache) does not carry. They are now generated copies,
like vetted-ops itself, through the plugin generator's `copies` table.
The plugins member's ruff run skips the jira copy, whose source is
checked under its own settings in tools/jira.
- generate-cve-json read a `### <field>` heading even inside a code
block, so a PoC quoting `### CVE tool link` with an example CVE ID
before the real field supplied that ID. Field extraction and the
attachment splice now skip Markdown fences (matching character and
length) and Jira {code}/{noformat} blocks, for bodies from any
tracker.
Generated-by: Claude Opus 5
… a public one
The code-aware field parser let reporter text hide later field headings:
an unclosed fence, a fence closed in a later field, or a line-leading
Jira macro in the public summary made it absorb the following fields,
including the private security-thread URL, into the public CVE
description.
Field boundaries follow the body-field contract again: every `###`
line splits fields. The one exception is a `### X` line inside a code
block that both opens and closes when field X also has a heading outside
every code block, which is a PoC quoting a field, not the field. A
heading with no counterpart outside code stays a boundary even inside a
block, so a field can only ever be shorter than the old split, never
longer. Jira blocks open only on a line that starts with the macro, so
an inline `{code}` mention is just text.
Generated-by: Claude Opus 5
Dropping a quoted heading as a boundary still lengthened the field before it: an unfenced fake `### Security mailing list thread` earlier in the report made the real heading inside a spanning fence look like a quote, and the public summary absorbed the private thread URL. Every `###` line now ends a field, as the body-field contract says, so a value is always exactly one section of that split. Code blocks only pick which `### <heading>` starts the requested field: the first one outside every closed block, else the first, so a PoC quoting `### CVE tool link` inside a fence is still not taken for the field. Generated-by: Claude Opus 5
7 of 11 tasks
potiuk
added a commit
that referenced
this pull request
Oct 17, 2026
…dy-field contract's boundaries Rebasing this stack onto #1683 brought two designs together. #1683 made generate-cve-json follow the body-field contract with its own field splitter: every `###` line ends a field, code block or not, and closed code blocks only choose which `### <heading>` starts the field. No fence or Jira macro left in one field's text can then merge the next field into it, so a stray fence in the public summary can never pull the private mailing-list thread into the published CVE description. This stack had instead routed every reader through github-body-field, where a `###` line inside a fence was content, and the rebased tip failed #1683's tests for exactly those cases. The contract now lives in github-body-field, for reading and writing alike, and nothing else splits fields on its own. Every column-0 line of `###` followed by a space, a tab or nothing ends a field. Closed blocks (CommonMark fence pairs and Jira `{code}` / `{noformat}` blocks, scanned by one implementation; a block left open marks nothing) pick the first `### <name>` outside them, else the first, and two differing copies outside them are a duplicate decision. An indented or quoted heading ends no field and stays a container-heading decision when it names a known field. normalise closes a fence left open inside the field it opens in, and replace_field refuses a value holding a line that would end the field, or a rewrite that moves another heading into or out of a closed block. generate-cve-json drops its private splitter and reads values and the attachment anchor through the parser. The import tools' inert block escapes every line that would end a field, whatever its name. The local-files tracker pairs fences through a byte-identical copy of the parser instead of a workspace dependency the vetted-ops plugin cannot resolve. Part of #1672 Generated-by: Claude Opus 5
potiuk
added a commit
that referenced
this pull request
Oct 17, 2026
A security review of the previous commit found two ways reporter text
could still become a field.
When every copy of a `### <name>` heading sat inside closed code blocks,
the parser fell back to the first copy. On the Jira and local-files
trackers a field can legitimately be absent, so a reporter's fenced
`### Severity` became the Severity field. A heading inside a closed block
now never starts a field: a field whose only copies are inside closed
blocks is absent (extract and replace report it not found, list_fields
and field_sections leave it out, normalise records nothing for it).
Every `###` line still ends a field, code block or not, so no block can
merge two fields either.
Jira's renderer opens `{code}` and `{noformat}` anywhere in a line, while
the parser opens a block only on a macro at the start of one, so a heading
Jira shows as code could parse as a field. Whichever reading is right
depends on the renderer, so the parser fails closed: a line outside closed
blocks that leaves such a macro open, closed on a later line with field
boundaries between, is a `macro-ambiguous` decision (the first hidden
field; the opener's line, then each hidden heading's). Extract and replace
refuse the field the macro opens in and every field it would hide, a
rewrite that would create such a span is refused, and generate-cve-json
exits 2 on it as it does on a container heading. A macro opened and
closed on the same line stays text.
At ingest, quoted text now never becomes a field copy at all: the
local-files tracker's whole-body path (create, edit) and the Jira
bridge's create and edit escape every `###` line inside a closed block
with a leading backslash, as the import's inert block does. The
local-files tracker closes a fence a body leaves open in the field it
opens in, before the next field heading, instead of at the end of the
body, where the closer would have moved the later field headings into a
block.
The vetted-ops, local-tracker and Jira copies are re-copied from the
parser. The trackers' case-insensitive name lookup now applies the
parser's ambiguity refusal before its duplicate check, as the parser does,
and the local-files tracker writes an empty value as the parser does
instead of as one blank line.
Tests that relied on the "else the first" fallback:
- github-body-field test_a_heading_inside_a_closed_fence_ends_the_field_but_never_becomes_it:
the field quoted only inside a fence now raises FieldNotFoundError
instead of returning the quoted value.
- generate-cve-json test_code_blocks_cannot_merge_a_private_field_into_a_public_one
(#1683): with a fence or {code} block closed in a later field, the
private thread heading sits in the closed block, so the thread field is
now absent ("") instead of read from inside the block; with the macro
inside inline code (`{code}` in prose, closed later), both the summary
and the thread are now refused as macro-ambiguous. The guarantee the
test exists for holds in every case: the public summary never holds the
private thread URL.
- The previous commit's changes to #1683's Jira and local-tracker tests
are undone where they asserted the fallback: get_field(fenced, "fake"),
get_field(body, "Field") in the three Jira fence-pairing tests and
get_field(body, "B") in the local-tracker test are None again, as #1683
wrote them; the added checks that a real copy outside the fence is read
stay. The Jira test of a mid-line macro now expects the refusal.
The corpus check (379 tracker bodies) produces output identical to the
previous commit: no difference from main beyond CR removal, and #32's
summary equal to main.
Part of #1672
Generated-by: Claude Opus 5
This was referenced Oct 17, 2026
potiuk
added a commit
that referenced
this pull request
Oct 18, 2026
… read tracker fields through one normaliser (#1680) * perf(security-issue-import): decide and render the import as code A new tools/security-management package holds the deterministic rules of the security skills, one CLI family per skill over a shared library. For security-issue-import it decides the governance defaults, the Step 1 pre-filter, the threadId dedup batches, the forwarder pre-screen, the reply grammar and the receipt-draft matrix, and renders the sanitised tracker body, rollup entry, rejections-ledger record, fix-already-public reply and recap. It runs nothing; report text reaches it only through files. SKILL.md becomes a router (9,864 -> 7,799 tokens); headings and golden rules are unchanged. The step-0, step-6 and step-7 eval suites become pytest cases named after each former case. Empty body fields now read _No response_, headings follow tracker.body_fields, fenced code is no longer demoted, and the lone-fence rule no longer strips the closing fence of every code block. Part of #1672 Generated-by: Claude Opus 5 * perf(security-issue-import-from-pr): decide and render the PR import as code A new issue-import-from-pr family in tools/security-management parses the PR reference (refusing any repository other than upstream), detects the scope from the changed files (tests stripped, mixed-scope stop, several sub-packages of one scope), checks the milestone by exact match, builds the duplicate-tracker query and flags a 30-hit result, and renders the title, the tracker body with its sentinels, the labels, the first rollup entry and the recap. It reuses the shared body, rollup, refs and titles modules and runs nothing; PR-derived text reaches it only through files. SKILL.md becomes a router (8,447 -> 7,150 tokens, 659 -> 499 lines); headings, golden rules, the Reporter credit policy and the adversarial-review block are unchanged. The mixed-scope blocker, the scope mapping, the body-field table, the board recipe and the sibling comparison moved byte-for-byte into outcomes/ and backends/. The step-2-scope-detection eval suite becomes pytest cases named after each former case. The PR body is sanitised and linked into upstream instead of optionally fenced (a fence cannot hide a ### line from the body-field parser), and a bot author is checked by login as well as by name. Part of #1672 Generated-by: Claude Opus 5 * fix(security-management): fail closed on unsafe report text and pair code fences strictly The tracker body is read by two parsers: GitHub's CommonMark renderer and github-body-field, which toggles "inside a fence" on every fence-like line. The sanitiser now pairs fences the CommonMark way (at most three spaces of indent, closed only by a bare run of the same character at least as long) and requires every fence-like line to be one of those openers or closers, so the two readers agree on what is code. A body that does not pair - an unclosed fence, ``` closed by ~~~, ```` closed by ```, an indented fence - or has an unbalanced <details> or <!-- is no longer inlined verbatim: render-body refuses with exit 2 and the indicator, in issue-import and issue-import-from-pr alike, and the skill stops. Every ![ outside code is defused, not only the inline and full-reference forms, and the < of img, picture, source, video, audio, svg, image, iframe, object, embed, details and summary tags and of <!-- is escaped. Short fields get the same treatment plus an escaped first character on any fence-like line, and render-body reports them in sanitised_fields; redaction tokens still pass through unchanged. In issue-import-from-pr the remediation developer is kept in a code span in the rollup and sanitised in the body, a sub-package path segment is substituted literally into packageName, and a segment that is not a plain name (\1, \g<0>, ..) is reported as unmatched instead. Part of #1672 Generated-by: Claude Opus 5 * perf(security-issue-import-from-md): parse and render the findings import as code A new issue-import-from-md family in tools/security-management implements findings-format.md: it checks the shape (a # heading and a **Severity:** line, else not-a-findings-file), splits the file on --- lines, pairs each metadata block with the narrative before it, normalises the severity to UNKNOWN and falls back to upstream for a missing repository with a warning, and warns on missing sections. It cleans the duplicate-search keyword read from a file, and renders the tracker body (each section sanitised, the Recommended-fix <details> block), the rollup entry, the proposal table and the recap. It runs nothing. SKILL.md becomes a router (7,095 -> 5,810 tokens, 523 -> 422 lines); headings and golden rules are unchanged, and findings-format.md keeps its Step 1 heading as "run parse" plus the record schema. The not-a-findings-file ask, the body mapping, the rollup entry, the proposal, the keyword recipe, the board recipe and the sibling comparison moved byte-for-byte into outcomes/ and backends/. step-1-parse-findings cases 1-3 and step-2-duplicate-guard case 1 become pytest cases named after each former case; the two injection cases stay model evals, and the step-2 case now expects the keyword command instead of tr -cd. Intentional changes: the shape check requires both the heading and the severity line, a block with no title or no pair is ignored with a warning, a section whose fences do not pair refuses the render, and the repository, branch and basename are code-quoted in the body and rollup. Part of #1672 Generated-by: Claude Opus 5 * fix(security-management): defuse inside code spans and normalise line breaks before sanitising Images and active HTML are now defused inside inline code spans too. Telling a code span apart (escaped backticks, a span across lines) needs a CommonMark inline parser, and an escaped < or [image: in code is harmless, so the exemption is gone rather than refined. Line endings are normalised (\r\n and \r become \n) before any analysis, in the body and in every short field, and text holding another character that str.splitlines breaks on (\x0b, \x0c, \x1c-\x1e, \x85, U+2028, U+2029) fails closed: github-body-field would split it into lines CommonMark does not see. A fence closes only on a line whose rest is spaces and tabs, as in CommonMark; a closer followed by a form feed or a non-breaking space no longer counts and the body is refused. Validators that anchored with $ (sub-package names, the PR URL and short reference, the findings repository, the recap's full reference) use fullmatch, so a trailing newline no longer passes. Part of #1672 Generated-by: Claude Opus 5 * fix(security-management): keep fenced headings and HTML blocks from overriding body fields A reporter could put "### Severity" inside a fenced block of the report: the sanitiser left fenced lines alone, and generate-cve-json found fields with its own first-match search that ignores fences, so the reporter's value replaced the team's in the CVE record. Both sides are fixed. Inside a fence, a heading line of any level is now escaped (\### Severity), still readable as code and matched by no field parser; and generate-cve-json reads fields through github-body-field's fence-aware parser, raising DuplicateFieldError when a heading appears twice outside fences instead of taking the first. A raw HTML block (a line starting with < and a letter, /, ! or ?) let GitHub render an image the sanitiser took for fenced code, since an HTML block runs through fence lines. The < of every such line is now <, fenced lines and short fields included, so sanitised text holds no HTML block. generate-cve-json gains its first runtime dependency, the stdlib-only workspace member github-body-field. Part of #1672 Generated-by: Claude Opus 5 * fix(security-management): resolve reply, ledger, dedup, recap and scope edge cases The confirmation grammar takes a go word only from a clause of nothing but go words, and never when the reply holds a negation, so "do not proceed" confirms nothing. Dispositions resolve per candidate: identical repeats are one action, conflicting ones (skip 1 and 1:reject-with-canned A, two canned names, two values for one field) leave the candidate out of every actionable list, and free text or a rejection that names no known candidate holds every action until the user clarifies. Rejections-ledger values are sanitised like body fields, so an image or tag in a rejected subject or reporter name is not rendered in the ledger comment. The threadId dedup accepts the identifiers the mail-source backends emit (Gmail and PonyMail ids, maildir names, root Message-IDs such as <report@example.org>), quotes each as a search phrase and prints the shell-quoted command; any other id now exits 2 and the skill stops, instead of the candidate silently getting no dedup search. Recaps and duplicate hits render tracker references per backend: GitHub builds its issue URL, Jira and local-files use the id and url the backend returned, or the bare id when there is none. Scope detection tries every scope on every file, and a file two scopes both match blocks like a mixed PR, since the config has no precedence. It reports detection (matched, mixed, unresolved, disabled); with detection disabled and no default label no scope label is applied, instead of the literal None. The PR rollup carries the confirmed severity and reporter-credit overrides. Search keywords lose their whole leading run of spaces and hyphens, so "-- --help" cleans to "help". Reference linking leaves alone code spans of any backtick run, existing markdown links, autolinks and bare URLs. Part of #1672 Generated-by: Claude Opus 5 * fix(security-management): escape HTML openers anywhere in report text Escaping only an HTML block start at the beginning of a line left a container prefix as a way round it: "- <div>", "> <div>" or "1. <details>" still opens an HTML block on GitHub, which renders through the fence lines the sanitiser paired. Every < followed by a letter, /, ! or ? is now < anywhere in report text, in the body (fenced lines and code spans included, where < is harmless) and in every short field, so no container prefix has to be parsed. An inline tag such as <img src=x> is escaped the same way. Part of #1672 Generated-by: Claude Opus 5 * perf(security-issue-import-via-forwarder): decide preflight, detection and routing as code A new issue-import-via-forwarder family in tools/security-management runs the Step 0 checks (forwarders.enabled non-empty, every enabled adapter installed, the message structurally valid) with the skill's error strings, detects the adapter (enabled order, first sender-or-preamble match wins, the preamble snippet and sender, the collaborator warning), classifies an extracted credit's kind under the bot/AI credit policy, derives salutation first names, and routes the draft (first contact handle, question mode, negative-space suppression of a do-not-relay milestone in sync mode). No adapter is named in the code; every pattern and handle comes from project.md. It runs nothing and never redacts again: the parent's redacted body passes through as text. SKILL.md becomes a router (6,213 -> 5,724 tokens, 350 -> 320 lines); headings, golden rules and the Hard rules are unchanged. The Step 0 outcomes, the detect loop, the routing components and the inputs table moved byte-for-byte into outcomes/. The step-0-preflight and step-3-route-drafts suites and step-1 cases 1, 2, 3 and 6 become pytest cases named after each former case; the injection and collaborator-warning cases of step 1 and all of step 2 stay model evals. Intentional changes: "installed" means an adapter directory or a shipping row of the forwarder-relay README whose reference doc exists; an adapter whose patterns are unresolved or do not compile stops detection instead of being skipped; the preamble snippet is the matched preamble up to its first line break, at most 80 characters; and sync routing needs the milestone named from the policy's fixed list. Part of #1672 Generated-by: Claude Opus 5 * perf(security-issue-import-from-scan): enforce the bucket rule and render the scan report as code A new issue-import-from-scan family in tools/security-management says what each disposition bucket allows, so Golden rule 3 holds in code: only import-as-tracker creates a tracker, PR-worth and defense-in-depth are open-a-PR-or-skip, and the rest take no action. It renders the per-scan triage report (distribution line, a section per bucket, a row per finding) and, across several scans, the outcome table, the breakdown with percentages and the severity-vs-disposition table; it parses the operator's decision grammar, holding everything on an ambiguous reply; and it lists what a public report must not carry (private tracker references, CVE IDs not yet public, embargo terms, bare #NNN) without rewriting it. Bucketing, grounding and the scanner assessment stay with the model. It runs nothing. SKILL.md: 5,964 -> 6,030 tokens, 401 -> 385 lines. The skill was nearly all judgement, so the commands it now names cost about what the moved prose saved; headings, golden rules, the Step C table and the adversarial-review block are unchanged. The skill composition list, the folder-discovery recipe, the per-source destinations and the old Step D prose moved byte-for-byte into outcomes/ and backends/. The step-c-bucket suite stays a model eval; its creates_tracker rule is pinned in pytest for every bucket. Intentional changes: a by-design, duplicate or already-fixed row without its grounding is refused, and the scrub now runs on every public surface (gist and public issue_analysis.md as well as the report-back PR). Part of #1672 Generated-by: Claude Opus 5 * feat(github-body-field): normalise tracker bodies into the shape every reader agrees on The parser now pairs code fences the CommonMark way (same character, a closer at least as long, nothing but whitespace after it), so a four-backtick block holding three backticks no longer hides the fields after it, and ~~~ no longer closes a ``` block. A field heading is any level-3 ATX heading outside a fence: two spaces or a tab after ###, up to three spaces of indent and a closing run of # all count, and lines end at CommonMark's line endings only. The new normalise(body, known_fields, keep) returns the canonical body with a change list and the decisions it will not make: CRLF and CR become LF, headings become "### Name" (taking a known field's spelling when the case-folded name matches), a heading-shaped line inside a fence is escaped, an unclosed fence is closed, and a repeated field is collapsed when every copy holds the same value. Copies with different values are left alone and reported, unless the caller passes the line of the copy a human chose to keep. A print-only `normalise` subcommand reports the same without ever writing body text to stdout. A duplicate field now raises DuplicateFieldError, a FieldNotFoundError subclass, so callers no longer match on the message. The vetted-ops copy of the parser is re-synced. Part of #1672 Generated-by: Claude Opus 5 * fix(generate-cve-json): normalise the body before reading fields and refuse ambiguous duplicates cleanly Since the switch to github-body-field's parser, a summary heading written with two spaces after ### was silently missed, leaving the CVE description empty, and a field repeated with different values ended the run in a traceback. The body is now normalised in memory against the template field names first: lenient or differently cased headings name their field again, CRLF bodies read the same as LF ones, and copies of a field holding the same value collapse to one. A field repeated with different values exits 2 with the field name and the heading line numbers, and no record is written; the tool never picks a copy. The new --report-json flag writes normalisation_changes, needs_decision and missing_required (the summary, affected versions, CWE, severity, credit and CVE link, each marked with whether an agent may propose a value; never for severity or the CVE link) for the calling skill to act on. It holds no body text and is written on exit 2 too. Run over the 379 real tracker bodies against main, every record is the same except one, which now stops on its differing duplicate; the only other change is that CRLF bodies no longer carry carriage returns into the CVE description. Part of #1672 Generated-by: Claude Opus 5 * fix(github-body-field): never take a heading inside a list item or block quote for a field The lenient heading and fence rules accepted up to three spaces of indent everywhere, but inside a list item or block quote that indent places the line in the container: GitHub renders "- item" followed by " ### Severity" as part of the list, while the parser read a top-level field, and an indented fence there could hide every field after it. The parser now tracks a simple container context (a line starting with >, a bullet or an ordered marker, its indented lines and lazy continuations, ended by an unindented line after a blank line or an unindented heading or fence). Container lines are neither fields nor fences, and normalise never rewrites them. As a second line of defence, normalise cross-checks its result with a strict reader (### at column 0, fences opened only at column 0). Where the two disagree about a field heading it records a container-heading decision instead of picking a reading, and generate-cve-json exits 2 on it as it does for a differing duplicate. The vetted-ops copy is re-synced, and a parity test runs both implementations over the same tricky bodies. Over the 379 real tracker bodies nothing changes: no container-heading decision fires, and every CVE record matches the previous commit. Part of #1672 Generated-by: Claude Opus 5 * feat(security-issue-sync): propose missing or ambiguous CVE fields with the session's own model generate-cve-json never calls a model, so a tracker whose summary, affected versions, CWE or credit is empty, or whose body repeats a field with different values, needed a human to notice the gap. Every skill step that regenerates the record (security-issue-sync, security-cve- allocate, security-issue-deduplicate) now passes --report-json and, when the report lists missing_required or needs_decision, follows one shared procedure in issue-sync/outcomes/cve-json-fallback.md. The model already running the skill reads the tracker body and proposes values, labelled AI-proposed and applied only on explicit confirmation through the normal field write, with the confirmation recorded in the rollup. No new LLM hop is added. Severity is never proposed (the team scores it independently; a reporter's CVSS stays informational) and the CVE link comes only from allocation. For a field repeated with different values the user sees each candidate and picks one; the normalised body is then offered as a whole-body rewrite, applied only on confirmation. Part of #1672 Generated-by: Claude Opus 5 * fix(github-body-field): fail closed on nested field headings instead of modelling lists The container tracker approximated CommonMark's list and block-quote rules (content offsets, thematic breaks, which markers may interrupt a paragraph) and got them wrong in ways a crafted body could exploit. It is gone. A field heading is now only "### <name>" at column 0, or the lenient form (extra spaces or tabs after ###, closing #s) at column 0 after a blank line or a field heading, or an indented one only right after a field heading or at the start of the body. Everything else is content. Any other heading-shaped line outside fences whose name, after stripping > and list markers, is a known field is reported as a container-heading decision: never taken as the field, never ignored, and generate-cve-json exits 2 on it. The strict cross-check now reads fence content unescaped, so an indented fence that a renderer would close at the end of a list can no longer hide a field silently. The vetted-ops copy is re-synced and the parity fixtures cover the new cases. The 379-body corpus is unchanged: no container-heading decision fires, and only #32 and #352 differ from main. Part of #1672 Generated-by: Claude Opus 5 * fix(security-management): import the report as an inert fenced block, then verify the body The importers inlined the reporter's text as markdown and relied on a sanitiser to make it safe: demoting headings, escaping headings inside fences, defusing images and HTML, dropping empty fence pairs and refusing bodies whose fences did not pair strictly. Each new markdown trick needed another rule. The description (the email body, the PR description, each findings-file section) now goes in as one fenced block with info string `text`, its fence one backtick longer than the longest backtick run in the text (at least three), and the content byte-exact. No line of the text can close the block, and nothing inside it renders: no image loads, no HTML or comment opens, no heading becomes a section, no reference links. Short fields stay one-line escaped values. Before a body is returned it now goes through github-body-field's normaliser: it must hold exactly the template fields, once each, with nothing to decide and no rewrite but headings inside fences, or the render is refused. A body holding a line-break character other than \n is still refused. The sanitiser code this made dead is removed, and the reference linker pairs fences the CommonMark way, so it never links inside the block. Behaviour change: reporter formatting now shows as plain text in the tracker, and a malformed fence in the report no longer blocks the import. The import skills' sibling docs describe the new shape. Part of #1672 Generated-by: Claude Opus 5 * fix(github-body-field): refuse ambiguous fields at the read and write layer, in linear time The nested-heading check used a regex whose repeated group could match whitespace several ways, so a long line of quote and list markers backtracked catastrophically. It is now a loop that strips one marker at a time, at most 64 of them, behind a cheap "###" pre-check; a 100k-char line of markers normalises in well under a second. The fail-closed rules ran only inside normalise, so a caller that read or wrote a field directly could skip them. extract_field and replace_field now run the strict cross-check and the nested-heading detection for the requested field themselves and raise AmbiguousFieldError, a FieldNotFoundError subclass, when readers could disagree. The CLI's get and set exit 2 on it without writing; the vetted-ops body-field-get and body-field-set procedures refuse it, and generate-cve-json reports it as a clean exit 2 instead of reading an empty field. The vetted-ops copy is re-synced. The 379-body corpus is unchanged: only #32 and #352 differ from main. Part of #1672 Generated-by: Claude Opus 5 * fix(security-management): escape known-field headings in the inert block and unify body-field readers The inert block is inert only to fence-aware readers, and the tree had several: the local-files tracker toggled fence state on any ``` or ~~~ line, the Jira bridge's Markdown path closed a fence on any fence line, and generate-cve-json placed its attachment with a regex that ignored fences. The local tracker now pairs fences through github-body-field's fence_roles (a new workspace dependency, stdlib-only), the Jira bridge pairs them by the same CommonMark rule (same character, a closer at least as long, nothing after it), and generate-cve-json finds the CVE tool link section with the shared parser. As defence in depth for any reader that ignores fences, a line inside the inert block that would be a heading naming a template field if the fence were ignored (after leading whitespace, > and list markers: ###, a space or tab, the name) now gets a leading backslash. Every other line stays byte-exact, and the render output lists the escaped lines (escaped_lines). Intentional behaviour change: such lines in a report or PoC show with a backslash in the tracker. A new test renders reports full of ```, ~~~ and ```` runs and fake (github-body-field, the vetted-ops copy, generate-cve-json, the local tracker, the Jira bridge, and a fence-blind reader) finds exactly the template fields, once each. The parser exposes fold_name and loose_heading_name for this; the vetted-ops copy is re-synced. The 379-body corpus is unchanged: only #32 and #352 differ from main. Part of #1672 Generated-by: Claude Opus 5 * fix(github-body-field): strip any number of quote and list markers before a heading The nested-heading check stopped after 64 markers and then reported no heading, so a line of 65 > characters before "### Severity" slipped past the container-heading decision. It is now one forward-only scan over the line (whitespace, then a >, a bullet, or an ordered marker of one to nine digits followed by a space or tab, repeated until none matches), so it stays linear in the line length with no cap. Tests cover 65 and 10,000 markers of each kind, and the vetted-ops copy and parity fixtures are re-synced. The 379-body corpus is unchanged. Part of #1672 Generated-by: Claude Opus 5 * fix(github-body-field): match field names case-sensitively, as before normalise matched a heading to a known field case-insensitively, which main never did. On a real tracker the reporter's own "### Affected Versions" heading in the description became a third copy of the "Affected versions" field and a duplicate decision. Matching now collapses whitespace but keeps case, in the heading canonicalisation and in the container-heading detection alike, so a differently cased heading is content again. "### Affected versions" still canonicalises. The case-insensitive comparison stays only for the inert-block escapes, which guard readers that compare case-insensitively. Over the 379-body corpus every record matches main once carriage returns are stripped, except #352, which still exits 2 but now names only its two real duplicate headings (lines 239 and 248). The vetted-ops copy is re-synced. Part of #1672 Generated-by: Claude Opus 5 * fix(generate-cve-json): find attachment markers only outside report text and verify fields survive an attach --attach found its begin/end markers with a fence-unaware text search, and the inert report block keeps report text byte-exact. A report carrying "<!-- generate-cve-json: cve=UNKNOWN version=v1 -->" therefore made the next attach before CVE allocation delete everything from that line to the end of the body, every trusted field after it included, and the paired form did the same between two planted markers. Markers now count only as the tool writes them: the whole line, at column 0, outside any fence (github-body-field's CommonMark pairing), and after the last template field heading, where the attachment always goes. The block is replaced by validated line offsets, not a text search. Before any PATCH the old and new bodies are parsed with the shared parser, and the attach exits 2 without writing unless every field outside the attachment keeps its name, order and value. At import, any line of report text that starts with "<!--" is now escaped inside the inert block (listed in escaped_lines), so machine markers never travel in report text; short fields already escape it. Over the 379 real tracker bodies the locator finds 93 of the 94 markers the old search found, and re-attaching changes no field in any of them. The one it skips (#492) is a begin marker above the template fields whose end marker sits below them, exactly the span the old code would have deleted on the next attach. The CVE-record corpus check is unchanged: only #352 differs from main once carriage returns are stripped. Part of #1672 Generated-by: Claude Opus 5 * fix(github-body-field): guard every field boundary and keep fenced payloads exact Four review findings, all held: - replace_field checked only the requested field, so replacing the description of "### The issue description ... ' ### Severity' ..." silently deleted Severity. It now refuses (AmbiguousFieldError) when the replaced span, old or new, holds a heading-shaped line outside fences, whatever its name, and when the rewritten body would change any other field; vetted-ops body-field-set inherits the refusal. - normalise escaped heading-shaped lines inside fences, so a fenced "# comment" in a public summary reached the CVE description as "\# comment" and a duplicate-resolution rewrite altered PoC code. Fenced lines are now never rewritten; a fence only decides what counts as a heading. The known-field escapes stay where they belong, in the import's inert block. - The strict cross-check accepted only "### name", so a "### Severity" after an indented list fence produced no decision. It now reads column-0 headings under the same spacing rules as the lenient reader, and a heading-shaped line naming a known field where the two fence views disagree is a decision too. - Heading parsing used regexes with adjacent whitespace quantifiers, quadratic on long space runs. It is now index scans; 100k-space headings parse in well under a second, also through the inert block. The CLI now reads body files as bytes and fetches remote bodies as JSON, so CRLF reaches normalise intact: "changed" and --output agree. The vetted-ops copy is re-synced with new parity fixtures. Over the 379-body corpus nothing changes: only #352 differs from main once carriage returns are stripped, and the attachment locator still finds 93 of 94 markers (the skipped one is #492's above-the-fields marker). Part of #1672 Generated-by: Claude Opus 5 * fix(security-issue-sync): keep the record in DRAFT while required fields are unresolved Declining a proposed summary left the field empty, but generate-cve-json put the issue title in its place, so a complete-looking record could still advance to REVIEW, or to PUBLIC once an advisory URL was set. The generator now keeps CNA_private.state at DRAFT while any required field is empty (the summary the title stood in for included), in the record and in the attached table alike, and --report-json carries the state it gave (cna_private_state). An empty reporter credit is the one exception: anonymous and internally found reports have none, and two published records in the real tracker corpus carry only a remediation credit; holding them back would walk PUBLIC records back to DRAFT. The fallback doc now runs whenever any of normalisation_changes, missing_required or needs_decision is non-empty (the normalisation-only tidy-up was unreachable before), tells the agent to confirm the state in the report before advancing anything, and the callers in issue-sync, cve-allocate and issue-deduplicate dispatch on the same rule. The 379-body corpus check is unchanged: only #352 differs from main once carriage returns are stripped. Part of #1672 Generated-by: Claude Opus 5 * docs(spec-loop): sync specs with the security import split The specs still described the importers' markdown sanitiser and a generate-cve-json that read fields with a first-match search. They now record what this branch ships: - cve-tooling: normalise then extract with no guessing (lenient but case-sensitive headings, differing duplicates and disputed headings exit 2), --report-json, DRAFT until the required fields are filled, attachment markers taken only where the tool writes them with a field-preservation check before every PATCH, and the agent fallback in the calling skills. - security-issue-lifecycle: the report as an inert fenced block with its two escapes, verification of the rendered body, fail-closed field reads and writes across every reader in the tree, and the confirmed-proposal fallback for CVE fields. - adapters and vetted-command-surface: github-body-field's normalise and its refusals, mirrored by the vendored copy behind body-field-get and body-field-set. .last-sync is left as it is: it records the main commit the specs were last reconciled against, this branch is not on main yet, and the main commits between that marker and the branch base were not reviewed here. Part of #1672 Generated-by: Claude Opus 5 * fix(github-body-field): verify the rewritten body and keep CRLF spacing replace_field checked the rewrite against the lenient parser only, so replacing A in "### A ... ### B" with an indented fence closed by a column-0 run passed: CommonMark closes the fence there, while a column-0 reader opens one and loses B. The rewritten body now goes through the full strict-vs-lenient and nested-heading check, with every field of the old body as a known name, and any disagreement refuses the rewrite before vetted-ops body-field-set can PATCH. Spacer detection and replacement assumed LF. On a CRLF body, get returned the blank spacer as part of the value, and set dropped it and wrote mixed line endings. Blank spacers are now recognised under any line ending, and the replacement value and spacers are written in the body's own convention (CRLF, lone CR or LF), so setting an unchanged value is byte-identical and the CLI writes nothing. The vetted-ops copy is re-synced. Part of #1672 Generated-by: Claude Opus 5 * fix(generate-cve-json): attach after the last template field and state the DRAFT rule exactly A first attach went right after the CVE tool link section. When that field came before another template field, the attachment landed in the middle of the template, and the marker locator, which looks only after the last template field, then took it for field content and refused the next, safe update. The attachment now goes after the last template field's value wherever CVE tool link sits; a reordered-body regression covers the first attach and the refresh after it. The fallback doc and the generator's SKILL.md said any missing field keeps the record in DRAFT, which is not what the code does. They now state the rule as applied: an empty summary (title substitution included), affected versions, CWE, severity or CVE link keeps DRAFT, and a template-field needs_decision stops the run; an empty reporter credit or a duplicate of a field the tool does not read does not hold the record back, so anonymous and remediation-only records keep REVIEW or PUBLIC. The corpus checks are unchanged: only #352 differs from main once carriage returns are stripped, the attachment locator finds 93 of the 94 markers, and the three records the stricter rule would have walked back (#355, #373, #482) stay PUBLIC. Part of #1672 Generated-by: Claude Opus 5 * docs(security-issue-import): restore the recap-draft and parse-warning boundaries Two model-eval regressions from the code split, both wording that lost a boundary main still had. The import recap now says one entry per draft_id, a consolidated receipt once under the first candidate it covers, which is what render-recap already does. The findings parser's warnings hold format problems only; a prompt-injection attempt is flagged to the user separately, never as a parse warning. All eight affected suites pass: import 36, import-from-pr 8, import-from-md 7, import-via-forwarder 6, import-from-scan 4, sync 86, cve-allocate 22 and deduplicate 18. Two cases that failed once (import step-2b case-4, sync step-2b case-6) passed when re-run alone. Part of #1672 Generated-by: Claude Opus 5 * fix(security-management): drop an implicit string concatenation flagged by CodeQL Addresses the CodeQL finding "Implicit string concatenation in a list" on #1680. The change was suggested by GitHub's Copilot Autofix and accepted in the PR; the co-author trailer it added is dropped, since this repository does not attribute AI tools as co-authors (see AGENTS.md). Part of #1672 * fix(github-body-field): one parser for reading and writing, on the body-field contract's boundaries Rebasing this stack onto #1683 brought two designs together. #1683 made generate-cve-json follow the body-field contract with its own field splitter: every `###` line ends a field, code block or not, and closed code blocks only choose which `### <heading>` starts the field. No fence or Jira macro left in one field's text can then merge the next field into it, so a stray fence in the public summary can never pull the private mailing-list thread into the published CVE description. This stack had instead routed every reader through github-body-field, where a `###` line inside a fence was content, and the rebased tip failed #1683's tests for exactly those cases. The contract now lives in github-body-field, for reading and writing alike, and nothing else splits fields on its own. Every column-0 line of `###` followed by a space, a tab or nothing ends a field. Closed blocks (CommonMark fence pairs and Jira `{code}` / `{noformat}` blocks, scanned by one implementation; a block left open marks nothing) pick the first `### <name>` outside them, else the first, and two differing copies outside them are a duplicate decision. An indented or quoted heading ends no field and stays a container-heading decision when it names a known field. normalise closes a fence left open inside the field it opens in, and replace_field refuses a value holding a line that would end the field, or a rewrite that moves another heading into or out of a closed block. generate-cve-json drops its private splitter and reads values and the attachment anchor through the parser. The import tools' inert block escapes every line that would end a field, whatever its name. The local-files tracker pairs fences through a byte-identical copy of the parser instead of a workspace dependency the vetted-ops plugin cannot resolve. Part of #1672 Generated-by: Claude Opus 5 * fix(jira, local-tracker): read and write body fields through the one parser The Jira bridge and the local-files tracker still split tracker bodies into fields with their own section scanners, so the same body could give a different field value there than in github-body-field and generate-cve-json. Both now read and write body fields through a byte-identical copy of github-body-field's parser (the vetted-ops plugin ships them without the workspace, as it already did for local-tracker), pinned by a test in each package. Jira bridge: wiki h1. to h3. headings outside closed code blocks read as `### <Heading>` before the parser sees the body, and the parser's block scanner decides what is code, so a {code} or {noformat} macro opens only at the start of a line and a block left open hides nothing. Every column-0 `###` line ends a field, code block or not. get_field and set_field resolve the name with case and whitespace ignored, then call the parser's extract_field and replace_field; a replacement is mapped back onto the wiki body and refused when the value would add, hide or move a field heading. `body` exports exactly the fields field-get reads, so generate-cve-json sees the same values. Duplicate, disputed and unsafe writes exit 4. Local-files tracker: `###` body fields go through the parser for reads, replacements and appends; an append must leave every other field as it was and read back as written. The managed comment and rollup sections, comment headings and a non-`###` field level keep the heading-level section split. Field values, comments and rollup entries have every line that would end a field escaped, fence or not, as the import's inert block does; a whole report body keeps its field headings. Tests that encoded the opposite rule now assert the contract: - local-tracker test_headings_inside_code_fences_are_ignored: get_field(B) is None became get_field(B) == "```" (with no other copy the fenced heading is the field), and "### B" in get_field(A) became get_field(A) == "```" (the fenced `### B` ends field A); added: a copy of B outside the fence wins, and C reads "c". - jira test_headings_inside_code_blocks_are_ignored: get_field(fenced, "fake") is None became == "```", plus a copy outside the fence wins. - jira test_shorter_fence_inside_longer_fence_does_not_close_it, test_fence_closes_only_on_the_same_character (both bodies) and test_fence_close_must_be_bare asserted get_field(body, "Field") is None for a `### Field` inside a closed fence. They now assert that with a real `### Field` appended outside the fence, the real one is read: that holds only if the fence paired correctly (a mispaired fence leaves two copies outside closed blocks, a duplicate), which is what these tests were written to check. Their other assertions are unchanged. The readers-agree test reads field lists through each implementation's own parser path and now also runs a field read and a write over fifteen bodies (fenced, Jira-macro and unclosed blocks, duplicates, CRLF, two-space and closing-hash headings, container headings, private-field merge attempts) through github-body-field, the vetted-ops copy, the local-files tracker and the Jira bridge, requiring the same value, refusal or rewritten body from each. Part of #1672 Generated-by: Claude Opus 5 * fix(github-body-field): never take a quoted heading for a field A security review of the previous commit found two ways reporter text could still become a field. When every copy of a `### <name>` heading sat inside closed code blocks, the parser fell back to the first copy. On the Jira and local-files trackers a field can legitimately be absent, so a reporter's fenced `### Severity` became the Severity field. A heading inside a closed block now never starts a field: a field whose only copies are inside closed blocks is absent (extract and replace report it not found, list_fields and field_sections leave it out, normalise records nothing for it). Every `###` line still ends a field, code block or not, so no block can merge two fields either. Jira's renderer opens `{code}` and `{noformat}` anywhere in a line, while the parser opens a block only on a macro at the start of one, so a heading Jira shows as code could parse as a field. Whichever reading is right depends on the renderer, so the parser fails closed: a line outside closed blocks that leaves such a macro open, closed on a later line with field boundaries between, is a `macro-ambiguous` decision (the first hidden field; the opener's line, then each hidden heading's). Extract and replace refuse the field the macro opens in and every field it would hide, a rewrite that would create such a span is refused, and generate-cve-json exits 2 on it as it does on a container heading. A macro opened and closed on the same line stays text. At ingest, quoted text now never becomes a field copy at all: the local-files tracker's whole-body path (create, edit) and the Jira bridge's create and edit escape every `###` line inside a closed block with a leading backslash, as the import's inert block does. The local-files tracker closes a fence a body leaves open in the field it opens in, before the next field heading, instead of at the end of the body, where the closer would have moved the later field headings into a block. The vetted-ops, local-tracker and Jira copies are re-copied from the parser. The trackers' case-insensitive name lookup now applies the parser's ambiguity refusal before its duplicate check, as the parser does, and the local-files tracker writes an empty value as the parser does instead of as one blank line. Tests that relied on the "else the first" fallback: - github-body-field test_a_heading_inside_a_closed_fence_ends_the_field_but_never_becomes_it: the field quoted only inside a fence now raises FieldNotFoundError instead of returning the quoted value. - generate-cve-json test_code_blocks_cannot_merge_a_private_field_into_a_public_one (#1683): with a fence or {code} block closed in a later field, the private thread heading sits in the closed block, so the thread field is now absent ("") instead of read from inside the block; with the macro inside inline code (`{code}` in prose, closed later), both the summary and the thread are now refused as macro-ambiguous. The guarantee the test exists for holds in every case: the public summary never holds the private thread URL. - The previous commit's changes to #1683's Jira and local-tracker tests are undone where they asserted the fallback: get_field(fenced, "fake"), get_field(body, "Field") in the three Jira fence-pairing tests and get_field(body, "B") in the local-tracker test are None again, as #1683 wrote them; the added checks that a real copy outside the fence is read stay. The Jira test of a mid-line macro now expects the refusal. The corpus check (379 tracker bodies) produces output identical to the previous commit: no difference from main beyond CR removal, and #32's summary equal to main. Part of #1672 Generated-by: Claude Opus 5 * fix(jira, local-tracker): explain the probe loop's ignored not-found CodeQL flagged the `except FieldNotFoundError: pass` in both trackers' field-name lookup as an empty except clause. It is intentional: the loop only probes each spelling of the name for the parser's ambiguity refusal, an absent spelling is fine, and the duplicate check follows. The clause now says so. Part of #1672 Generated-by: Claude Opus 5
potiuk
added a commit
that referenced
this pull request
Oct 19, 2026
Hand-sync of tools/spec-loop/specs/ with the functionality that landed between 43e4c6e and ccb53a3. Specs changed: - adapters: Gmail drafting through the gmail-plaintext MCP tools, not the CLI (93051d5, #1681); the Jira CLI's explicit --deployment and the tracker backends shipped as generated plugin copies (1cf5cee, #1683); local-tracker report files and folders created 0600 / 0700 (68ec4a3, #1700). - agent-isolation-sandbox: agent-guard unwrapping env / command and refusing env -S (b00d4b5, #1691; 4aea234, #1694); the macOS touch overlay shown whether or not activation is granted (0d9c168, #1697); the sandbox-lint workflow refusing a tracked .claude/settings.local.json (51a610b, #1692) and sandbox-lint refusing the Gmail token path in allowRead (93051d5, #1681). - vetted-command-surface: the spelling guard refusing a quoted ~, the right spelling behind env / command, and other spellings of the eval wrapper (b00d4b5, #1691). - adoption-and-setup: the tracker-backend question on every first setup and the Jira label mapping and doctor gate (1cf5cee, #1683). - .last-sync bumped to ccb53a3. No spec change needed (specs already updated in the commit itself): ccb53a3, 9a8ec5f, 9955d7d, c9f34cc, 76cc6e8, 1d773a8, 16014a2. No functional change for the specs: d4b7dc3 (review-body wording), 77e8145 (test timing), 8d606d6 (eval fixtures), a04891f (the previous sync). Generated-by: Claude Opus 5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
publishpushesHEAD:<branch>to exactly the remote and ref it checked, with--no-follow-tags.push.default=matching, a remote'spushrefspecs orpush.followTagscan no longer send other branches or tags.GIT_ATTR_SOURCE, git 2.42+), so no.gitattributescan switch on a filter, diff or merge driver, even one configured after the per-command config check.https/sshpushes, no gc/maintenance hooks, and a tree comparison against the destination, which catches a-s oursrevert.in progress), still as one path component.SEC-001_xssresolves bySEC-001.[Bob's report, triaged], escaped quotes).--deployment {server,cloud}, which vetted-ops always passes, so an inheritedJIRA_DEPLOYMENT=cloudcannot point a Server tracker at Cloud endpoints.tracker.platform: TODO(read asgithub), so the backend question is asked during onboarding.doctorto pass.generate-cve-jsonstill ends a field at every###line, as the body-field contract says, so a field's value is always exactly one section of that split. Code blocks decide only which### Xheading starts the field: the first one outside every closed code block, else the first. Before, a PoC quoting### CVE tool linkwith an example CVE ID ahead of the real field supplied that ID.{code}/{noformat}block opens only on a line that starts with the macro.jiraandlocal-trackerbackends reached themagpie-vetted-opsplugin as symlinks out of the plugin root, which a self-contained install (Codex, a plugin cache) does not carry. They are now generated copies, likevetted-opsitself. The plugin's copy of the vetted-ops sources and README is also brought back in line withtools/vetted-ops.A review finding about the Jira URL (credentials sent to a URL from committed configuration) needed no change.
jira_bridge.restsends the token only to a host the user confirmed, requireshttps, and refuses redirects.Type of change
setupStep 3a; the shared tracker-backend block in the security skills)tools/security-tracker/contract.md)tools/local-tracker,tools/jira,tools/vetted-ops)tracker.platform: TODO)Test plan
prek run --all-filespassestools/local-tracker: 67 passed. The new tests cover the explicit push refspec, attributes from the working tree (checked to fail without the fix), descriptor names, ID resolution, inline lists and authors with spaces.tools/jira: 88 passed, 28 skipped (needgroovy). New tests cover fence matching and--deploymentwinning overJIRA_DEPLOYMENT.tools/vetted-ops: all passed. A new test checks that--deploymentis always passed.generate-cve-json: all passed. New tests cover:### CVE tool linkline;Security mailing list threadinto the public summary (unclosed fence, block spanning fields, Jira block, inline{code}, and an earlier unfenced duplicate heading with a spanning block, in three fence styles).jiraandlocal-trackercopies run withuv run --projectfrom a copy outside the repository.setup/ security skills: not run (prose in Step 3a and the shared block)Notes for reviewers
credential.helper, a remote'sreceivepack,gpg.program) cannot be overridden without breaking the user's own global config, so a sandboxed process racing the check remains the residual risk.Gen-AI disclosure: prepared with the assistance of Claude Code (Claude Opus 5); reviewed by the author.
🤖 Generated with Claude Code