Repository navigation
[sec-check] Content-only CI path skips the contract tests that hold CONTENT-SECURITY.md to the gates it documents #2003
Description
Security Finding
Severity: medium
Type: unsafe-pattern (CI gate does not cover the path it guards)
ADR 0011 decides that the focused path must "Run the applicable document-contract checks" (line 67) and only "Skip full unit coverage" (line 70). The step that implements it runs three tests:
# .github/workflows/ci.yml:109-114
- name: Check content contracts
if: ${{ !cancelled() && env.CONTENT_ONLY == 'true' }}
run: >-
node --test tests/docs-contract.test.mjs
tests/markdown-references.test.mjs
tests/community-group-coverage.test.mjsThe repository has roughly two dozen other unit tests whose subject is a committed authored document — exactly the documents isAuthoredDocumentPath() admits. None of them runs on the focused path, so a PR that touches only those documents can change what they say with fully green CI.
Reproduced on c686990
1. CONTENT-SECURITY.md — the submitter-facing statement of the active-content allowlists.
tests/content-security-contract.test.mjs exists precisely to stop that document drifting from scripts/lib/mdx-active-content.mjs, svg-active-content.mjs and uri-safety.mjs. CONTENT-SECURITY.md is in ROOT_DOCUMENTS (scripts/lib/content-only-ci.mjs), so editing it alone classifies as document-only and that test does not run.
Adding `iframe` to the "Inert inline HTML elements" bullet (line 235) and running npm run fix:format:
classifyChanges('pull_request', <sha>, [{status:'M', path:'CONTENT-SECURITY.md'}])→{"documentOnly":true,"documents":["CONTENT-SECURITY.md"]}- focused lint →
prettier"All matched files use Prettier code style!",cspell"Issues found: 0",markdownlintclean - focused contracts →
tests 19 / pass 19 / fail 0 node --test tests/content-security-contract.test.mjs→fail 1,AssertionError … + 'iframe',
So the page a contributor is sent to when a gate rejects their submission can claim <iframe> is inert while ALLOWED_ELEMENTS still rejects it, and nothing on the focused path objects.
2. CONTRIBUTING.md — also in ROOT_DOCUMENTS. Changing the first `npm run check:unused` to `npm run check:unusedX` leaves the focused path green but fails 4 assertions in tests/contributing-just-build-parity.test.mjs (lines 210, 272, 284) and tests/dev-environment.test.mjs (line 241).
Impact
Security- and process-documentation can state something the code does not enforce, and the anti-drift tests written to prevent exactly that are skipped on the only changes that can cause it. An undocumented allowance is a hole nobody reviewed; a documented allowance the gate does not grant rejects submissions the page says are fine. This is the failure mode tests/content-security-contract.test.mjs was written to stop.
This is not a classifier weakness — classifyChanges() is fail-closed on symlinked .md, type-change (T), rename (R100), .. segments, backslashes, absolute paths, non-existent files, docs/architectures/** pages and empty changesets, all verified on c686990.
Recommendation
Replace .github/workflows/ci.yml:109-114 with:
- name: Check content contracts
if: ${{ !cancelled() && env.CONTENT_ONLY == 'true' }}
run: npm run test:unitnpm run test:unit is node --test with no coverage instrumentation: 3791 passing, 94 s on c686990, against the ~15–20 min of browser suites and the coverage/validator/lint work the focused path drops. It stays inside ADR 0011 (coverage is still skipped) and it cannot go stale, which is the maintenance burden ADR 0011 line 73 already anticipated for a hand-maintained list.
Decision taken by hive: recommend npm run test:unit over enumerating the ~two dozen document-bound tests, as the simplest option that cannot drift. If an explicit list is preferred, it must at minimum add tests/content-security-contract.test.mjs, tests/contributing-just-build-parity.test.mjs, tests/dev-environment.test.mjs, tests/root-docs-links.test.mjs, tests/adr-contract.test.mjs and tests/governance-hygiene-parity.test.mjs.
A guard assertion belongs in tests/content-only-ci-workflow.test.mjs, which already pins this step's if: condition.
This fix needs a human or an ISSUES_PRS_MERGE agent to land. The change is confined to .github/workflows/ci.yml, and this agent's token is minted at the contributor tier, which does not carry the Workflows permission — GitHub rejects such a push server-side. No part of the fix lives outside the workflows directory, so there is no accompanying PR.
🐝 Hive Agent: security | Instance: hosted-available-lke648397-260827-5n31 | SHA: c686990
— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88
Activity
hivecommons-hive commentedon Oct 30, 2026
Folded finding. This was filed as a separate issue, but 1 open agent-filed issue (plus 2 closed as completed in the last 14 days) already cite
github/workflows/ci.yml, so it was folded into the oldest of them instead of opening another. Treat this issue as the tracker for that component: prefer one structural fix that covers every folded variant over a PR per variant.[sec-check] CONTRIBUTING.md still says check:audit runs on every PR; #1999 gated it on FULL_VALIDATION
Security Finding
Severity: low
Type: unsafe-pattern (stale documentation of a supply-chain gate)
#1999 gated the advisory-allowlist step on the full-validation path:
It updated the matching prose at
CONTRIBUTING.md:73— "The full 'Validate repository' gate also runsnpm run check:audit" — but missed the second description of the same gate 264 lines later:That line was last touched by #1920 and is now false: a document-only PR classifies as
CONTENT_ONLY,FULL_VALIDATIONisfalse, andcheck:auditdoes not run. The file now contradicts itself about when the dependency advisory gate applies.Impact
check:auditis the control that fails a build on any high/critical advisory not in theSECURITY.mdallowlist, and on any moderate/low advisory already fixable in range. A contributor or maintainer reading line 337 is told the supply-chain gate covers every PR. It does not, and the paragraph goes on to describe failure handling ("flag the failure for maintainers, who own the allowlist") for a gate that may never have run. Misstating the coverage of a security gate is how a gap goes unnoticed.Recommendation
Align line 337 with line 73 and bind the claim to the workflow so it cannot drift again: a parity assertion that no
npm run <script>CONTRIBUTING.md says runs "on every PR" corresponds to avalidatestep gated onFULL_VALIDATION.A PR follows. Unlike #2003, this fix is entirely outside
.github/workflows/, so it is pushable.🐝 Hive Agent:
security| Instance:hosted-available-lke648397-260827-5n31| SHA:c686990— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88
hivecommons-hive commentedon Oct 30, 2026
🛠️ Hive agent
sec-check(hive hosted-available-lke648397-260827-5n31) started work on this issue at 2026-10-30T03:38:22Z. A pull request referencing this issue supersedes this mark; it lapses on its own at 2026-10-30T05:38:22Z if none appears.mrbobbytables commentedon Oct 30, 2026
Both halves are now in flight: #2004 (CONTRIBUTING prose, in the merge queue) and #2010 (workflow step extended to every document-reading suite). I kept an explicit suite list rather than switching the focused path to
npm run test:unit, per the ADR 0011 intent of running only applicable document contracts; the list is pinned by the workflow test so it cannot silently shrink.ci: run every document-contract suite on the focused path