Skip to content

fix(security-tracker): harden the local and Jira backends after review - #1683

Merged
potiuk merged 4 commits into
apache:mainfrom
potiuk:fix/security-tracker-review
Oct 17, 2026
Merged

potiuk merged 4 commits into
apache:mainfrom
potiuk:fix/security-tracker-review

Conversation

@potiuk

@potiuk potiuk commented Oct 17, 2026

Copy link
Copy Markdown
Member

Summary

  • Why. feat(security): security-tracker contract with local-files and private Jira backends #1647 merged the local-files and Jira security-tracker backends without the fixes from its last review rounds, and the final review still had open findings. Both backends run git or send credentials outside the sandbox (through vetted-ops), so these matter.
  • Local-files tracker:
    • publish pushes HEAD:<branch> to exactly the remote and ref it checked, with --no-follow-tags. push.default=matching, a remote's push refspecs or push.followTags can no longer send other branches or tags.
    • git reads attributes from the empty tree (GIT_ATTR_SOURCE, git 2.42+), so no .gitattributes can switch on a filter, diff or merge driver, even one configured after the per-command config check.
    • Carried over from the earlier rounds: only https/ssh pushes, no gc/maintenance hooks, and a tree comparison against the destination, which catches a -s ours revert.
    • Descriptor names may contain spaces (in progress), still as one path component.
    • A bare number naming two reports is refused as ambiguous, and SEC-001_xss resolves by SEC-001.
    • Inline lists split on real item boundaries ([Bob's report, triaged], escaped quotes).
    • Comment-marker authors are percent-encoded, so names with spaces read back.
  • Jira tracker:
    • A code 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 as fields.
    • New --deployment {server,cloud}, which vetted-ops always passes, so an inherited JIRA_DEPLOYMENT=cloud cannot point a Server tracker at Cloud endpoints.
  • Setup:
    • A fresh scaffold has tracker.platform: TODO (read as github), so the backend question is asked during onboarding.
    • The Jira steps map every label to a whitespace-free name and require doctor to pass.
  • CVE JSON: generate-cve-json still 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 ### X heading starts the field: the first one outside every closed code block, else the first. Before, a PoC quoting ### CVE tool link with an example CVE ID ahead of the real field supplied that ID.
    • No layout of fences, Jira macros or duplicate headings in reporter text can merge a private field into a public one: boundaries never depend on code blocks. Two earlier attempts in this branch let them, and roborev caught both.
    • A Jira {code}/{noformat} block opens only on a line that starts with the macro.
  • Plugin packaging: the jira and local-tracker backends reached the magpie-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. The plugin's copy of the vetted-ops sources and README is also brought back in line with tools/vetted-ops.

A review finding about the Jira URL (credentials sent to a URL from committed configuration) needed no change. jira_bridge.rest sends the token only to a host the user confirmed, requires https, and refuses redirects.

Type of change

  • Skill change (setup Step 3a; the shared tracker-backend block in the security skills)
  • Tool / bridge contract (tools/security-tracker/contract.md)
  • Python package (tools/local-tracker, tools/jira, tools/vetted-ops)
  • Groovy reference impl
  • Cross-cutting (sandbox: git run outside it; plugin packaging)
  • Documentation
  • Project template (tracker.platform: TODO)
  • CI / dev loop

Test plan

  • prek run --all-files passes
  • tools/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 (need groovy). New tests cover fence matching and --deployment winning over JIRA_DEPLOYMENT.
  • tools/vetted-ops: all passed. A new test checks that --deployment is always passed.
  • generate-cve-json: all passed. New tests cover:
    • six code-block shapes hiding a duplicate ### CVE tool link line;
    • the attachment splice;
    • eight ways reporter text could try to merge the private Security mailing list thread into 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).
  • The plugin's jira and local-tracker copies run with uv run --project from a copy outside the repository.
  • Eval suites for setup / security skills: not run (prose in Step 3a and the shared block)

Notes for reviewers

  • The per-command git config check still leaves a race window. Attributes from the empty tree close the filter, diff and merge driver part of it. Config that runs a program without attributes (credential.helper, a remote's receivepack, 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

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
@github-actions github-actions Bot added family:security security-* skills family:setup setup-* skills family:tools tools/* family:docs Docs, MISSION.md, READMEs family:ci .github workflows, prek, validators capability:triage Sweep + classify + propose disposition capability:fix Implement code change to resolve an issue capability:intake Import external signal + sync tracker state capability:resolve Close-out: invalidate, dedupe, CVE allocate, announcement capability:stats Read-only dashboards, metrics, governance evidence capability:platform Framework / agent substrate skills (install, verify, doctor, override, status, setup bootstrap) contract:tracker Tool capability: issue / board / label backend contract:cve-authority Tool capability: CVE allocation / record management / publication substrate:sandbox Tool substrate: agent isolation, egress control, settings audit substrate:framework-dev Tool substrate: build / validate / eval the framework itself contract:people contract:security-tracker Tool capability: security-tracker backend for embargoed reports (GitHub / files / Jira) labels Oct 17, 2026
@potiuk
potiuk merged commit 1cf5cee into apache:main Oct 17, 2026
58 checks passed
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
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 &lt;, 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 &lt; anywhere in report
text, in the body (fenced lines and code spans included, where &lt; 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

capability:fix Implement code change to resolve an issue capability:intake Import external signal + sync tracker state capability:platform Framework / agent substrate skills (install, verify, doctor, override, status, setup bootstrap) capability:resolve Close-out: invalidate, dedupe, CVE allocate, announcement capability:stats Read-only dashboards, metrics, governance evidence capability:triage Sweep + classify + propose disposition contract:cve-authority Tool capability: CVE allocation / record management / publication contract:people contract:security-tracker Tool capability: security-tracker backend for embargoed reports (GitHub / files / Jira) contract:tracker Tool capability: issue / board / label backend family:ci .github workflows, prek, validators family:docs Docs, MISSION.md, READMEs family:security security-* skills family:setup setup-* skills family:tools tools/* substrate:framework-dev Tool substrate: build / validate / eval the framework itself substrate:sandbox Tool substrate: agent isolation, egress control, settings audit

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant