Skip to content

Split SECURITY.md into audited specs, publish per-audience security pages - #538

Merged
nedtwigg merged 11 commits into
mainfrom
security-fixup
Sep 3, 2026
Merged

Split SECURITY.md into audited specs, publish per-audience security pages#538
nedtwigg merged 11 commits into
mainfrom
security-fixup

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Sep 3, 2026

Copy link
Copy Markdown
Member

Why

SECURITY.md was one 17k-word file mixing the public promise with the auditor's checklist, and the website's security copy was hand-written prose that could drift from it. This splits the file by audit domain, makes the spec the single source, and renders each audience its own page from that source.

What changed

Specs. docs/specs/security.md is the public face — guarantees, what is not defended, known gaps, how it is checked, reporting. The FAIL IF checklists move into one file per audit domain: security-local.md, security-remote.md, security-supply-chain.md, security-ci.md, security-audit.md, each with a rationale file below the fold. Every check is preserved. Root SECURITY.md becomes the GitHub security-policy pointer.

The old security-application.md splits by audience: security-remote.md takes the Remote Control checks verbatim (48 FAIL IF); security-local.md takes loopback listeners plus new sections for terminal output, browser panes, the dor control socket, and persisted state (15 FAIL IF, 7 new). Each new claim was verified against the code — one inversion and five over-strong claims were caught and corrected before publishing.

Website. /docs/security publishes every section of the umbrella spec, with the guarantees table and the two lists narrowed to that page's audience — the spec file on GitHub is where every row appears together. /docs/self-host and /supply-chain render the spec's own guarantee rows, undefended edges, and known gaps for their audience out of docs.security.json rather than keeping copies; securityAudiences splits the table and both lists by the spec each entry's links name, and an entry naming no spec, a spec in neither group, or both fails the build. Dropping the hand-written copy also drops one claim that was wrong (the installer does not reject Funnel — manage verify is what fails). Final commit reframes the coordinating server as optional on both pages, since it is only needed for phone push and remote control.

Lints. spec-lint check 16 proves each security spec is claimed by exactly one audit prompt, self-tested. assertRouteFragments holds a link into a published page against that page's headings, so repo-relative links stay verified down to the fragment. e2e-lint, deploy-lint, and every citation are repointed at the new files.

Testing

pnpm test green: 2148 lib, 175 website, 62 standalone, plus all spec/deploy/e2e lints and their self-tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MKfi4TNKoqQNELstoeKAu2

nedtwigg and others added 8 commits September 3, 2026 00:38
…ocs/security

- docs/specs/security.md is the public face — guarantees, what is not
  defended, known gaps, how it is checked, reporting — published whole
  at https://dormouse.sh/docs/security: new page and rail entry, README
  and homepage links; the generator now resolves relative repo links to
  GitHub at build time and fails on a missing target
- docs/specs/security-{application,supply-chain,ci,audit}.md carry the
  FAIL IF checklists, one file per audit domain, each with a rationale
  file: 17,419 words in one file become 12,788 across five plus 5,970
  of evidence below the fold, every check preserved (99 bullets)
- Root SECURITY.md is now the GitHub security policy pointer, budgeted
  and ratcheted with the specs
- Audit prompts claim specs by file under their **Scope line; spec-lint
  check 16 proves each security spec has exactly one owner (self-tested)
- e2e-lint pins its phrases in security-application.md; every
  SECURITY.md citation in code, specs, installers, and workflows is
  repointed to the spec that now owns the heading
- The INCONCLUSIVE audit issue headline now says it is not a finding,
  which the contract already claimed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzKs92Rgryer7JRn9DPNv2
…ot copies

- `/docs/self-host` and `/supply-chain` now show the security spec's own
  guarantee rows, undefended edges, and known gaps for their audience,
  rendered from docs.security.json; `securityAudiences` splits the table
  and both lists by the spec each entry's links name, and an entry naming
  no spec, a spec in neither group, or both fails the build
- `SITE_ROUTES` sends a repository link into a published file to its page
  rather than GitHub, so the umbrella keeps repo-relative links spec-lint
  verifies down to the fragment; `assertRouteFragments` holds a fragment
  into a published page against that page's headings
- security-pages.test.tsx pins the rendered entries by their repo links
  and requires the audit-method link rather than forbidding others;
  the "installer rejects Funnel" claim is gone with the hand-written copy
  (it is `manage verify` that fails)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DVadJTAp3djwEFhFdsGMxG
…age per audience

- docs/specs/security-remote.md: the Remote Control checks, moved verbatim
  (48 FAIL IF); docs/specs/security-local.md: loopback listeners moved,
  plus first-draft sections for terminal output, browser panes, the dor
  control socket, and persisted state (15 FAIL IF, 7 new), each claim
  verified against code and reviewed — one inversion and five over-strong
  claims caught and corrected before publishing
- The umbrella gains five local guarantees, three undefended edges, and
  one known gap (the standalone log is written at the umask and records
  the dor socket path); push rows say push is opt-in, and
  remote-security-model.md states the rule: a Host that never enrolls to
  a server sends none
- The pages now render three audiences from the spec: /docs/security shows
  the local application and the pipeline, /docs/self-host remote control,
  /supply-chain the supply chain; the umbrella page narrows its own three
  blocks to its audience and points at the other two
- application-security owns both new specs; every citation, lint, and
  prompt repointed; dor-cli.md's "re-checked on every use" corrected to
  what the code does (checked before every bind); the OSC 52 registry row
  now names its owner

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DVadJTAp3djwEFhFdsGMxG
… a server

Enrollment is where the relay, the phone, and push begin, so the precondition
is stated once at the top of security-remote.md and in the umbrella's intro,
and the Web Push clause returns to its verbatim form.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DVadJTAp3djwEFhFdsGMxG
The self-host runbook and the supply-chain inventory both read as if the
coordinating server were part of every install. It is only needed for phone
push notifications and remote control, so say that where each page introduces
it, and drop the Claude Code name from the runbook's agent instruction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKfi4TNKoqQNELstoeKAu2
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 3, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0c8ec27
Status: ✅  Deploy successful!
Preview URL: https://8ecf21e9.mouseterm.pages.dev
Branch Preview URL: https://security-fixup.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The split holds up. I re-ran the lints against the PR's own AGENTS.md (the PR-run harness restores that file from base, so a naive node scripts/spec-lint.mjs here reports six phantom "spec not indexed" problems): spec-lint, spec-lint-selftest (17 load-bearing checks), e2e-lint, deploy-lint, loopback-lint, and public-docs-lint are all green. I also confirmed checkSecurityFold is load-bearing rather than vacuous by planting a Reserved: paragraph and a ## Future heading in docs/specs/security.md — both go red, and inlineToText(node.children) really does see paragraph text.

"Every check is preserved" checks out: the old SECURITY.md carried 92 - FAIL IF bullets and I traced every one into the new specs (106 now). The ones my first pass flagged as missing — the build.rs runtime pin, ci.yml's generator gate, the .vscode/ diff-window bullet, the application-security fan-out, the VERDICT: first-line rule — are all there, reworded or hard-wrapped.

Four things below, all doc/test-level; nothing wrong with shipped behavior. The fragment one is the only one I'd call a gap rather than a nit: this PR adds assertRouteFragments precisely so a link into a published page can't name a heading that page doesn't render, then writes three such links in page components, where that check cannot see them.

One coordination note: #531 also edits SELF_HOST.md and scripts/spec-word-budgets.json, so whichever lands second will conflict on the budgets JSON. It does not rename ## Keeping the relay up while the laptop sleeps, so the fragment docs/specs/security.md links there survives either order.

Comment thread website/src/pages/security-pages.test.tsx
Comment thread website/scripts/generate-docs.js
Comment thread SECURITY.md Outdated
Comment thread website/src/pages/SecurityDocs.tsx Outdated
- security-pages.test.tsx holds every `#fragment` these three pages write
  into each other against the anchors the target page actually renders.
  `assertRouteFragments` cannot see them: it walks the generated Markdown,
  and these links are written in page components. Verified load-bearing by
  renaming `## What the installer does` in SELF_HOST.md — the generator
  stays green and only this check goes red.
- SECURITY.md said "four audited checklists" above five links.
- generate-docs.js: the `/docs/security` doc comment was stranded above
  `SECURITY_AUDIENCES` by a reorder; moved onto `buildSecurity` and
  extended to say what `pageBlocks` narrows.
- SecurityDocs.tsx and website-docs.md said the spec is "published whole"
  at `/docs/security`; the page renders `pageBlocks`, so its guarantees
  table and two lists carry one audience's entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKfi4TNKoqQNELstoeKAu2

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four fixes check out, and the new fragment check is load-bearing: I renamed ## What the installer does in SELF_HOST.md and it goes red with /supply-chain links /docs/self-host#what-the-installer-does: expected [ 'security-model', …(14) ] to include 'what-the-installer-does'. Reading id="…" out of the rendered <main> rather than headings is the right call — the only ids inside <main> come from AnchoredHeading (DocsLayout's one other id, docs-nav-drawer, is outside it), so the check can't be satisfied by a non-anchor. Lints all green against the PR's own AGENTS.md: spec-lint, its self-test (17 checks), e2e-lint (20), deploy-lint (82), loopback-lint (5), public-docs-lint (13), installer-verify-test (46), and website/ at 191 tests.

One thing this commit missed. The correction it applies — the page is every section, not the file verbatim — landed in SecurityDocs.tsx, generate-docs.js, and two of the three spots in website-docs.md, but three other places still make the un-corrected claim, and two of them are the ones a reporter reads first. SECURITY.md says the spec is "published as written at" the URL, docs/specs/security.md's own front matter says "Published as written at", and docs/specs/website-docs.md's Surface table (line 31) still says "Whole docs/specs/security.md, minus title and front matter" while line 13 in the same file now says "every section of it". Measured off the generated docs.security.json, the page renders 9 of the file's 21 guarantee rows, 6 of 13 undefended edges, and 4 of 8 known gaps — so "as written" overstates it by about two thirds of the rows, and the reader following that link from SECURITY.md gets no signal the rest exist. Suggestions inline.

Two smaller ones I'd leave to your judgement rather than suggest: the PR description carries the same sentence ("/docs/security publishes the umbrella spec whole"), and SECURITY_DELTA's docstring in generate-docs.js opens "docs/specs/security.md is published whole — the guarantees, what is not defended, the gaps … are the point of the page — so nothing but the file's own title and its spec front matter is withheld". That one is true of the delta, which is what it documents; it only reads wrong because it reaches forward to the page.

Budget note for the docs/specs/security.md suggestion: that file is at 1849 words against a 1850 budget, so the reword needs node scripts/spec-lint.mjs --ratchet docs/specs/security.md in the same commit. SECURITY.md (37 words of headroom) and website-docs.md (16) absorb theirs.

Comment thread SECURITY.md Outdated
Comment thread docs/specs/security.md Outdated
Comment thread docs/specs/website-docs.md Outdated
The page renders 9 of the spec's 21 guarantee rows, 6 of 13 undefended
edges, and 4 of 8 known gaps, so "published as written" overstates it.
Last commit corrected that in SecurityDocs.tsx and two spots in
website-docs.md and missed three:

- SECURITY.md, the first thing a reporter reads before following the link
- docs/specs/security.md's own front matter, the file the narrowing is
  applied to; +6 words, so its budget is ratcheted to 1900
- website-docs.md's Surface table, which still said "Whole" one screen
  below the route list this PR changed to "every section of it"

SECURITY_DELTA's docstring stays about the delta but no longer reaches
forward to the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKfi4TNKoqQNELstoeKAu2
@nedtwigg

nedtwigg commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Both judgement calls taken:

  • The PR description carried the same sentence; rewritten to "every section … with the guarantees table and the two lists narrowed to that page's audience".
  • SECURITY_DELTA's docstring stays about the delta, as you say it should, but no longer reaches forward to the page: "Every section of docs/specs/security.md publishes … The per-audience narrowing is audienceBlocks, not this delta."

Your counts reproduce exactly off the generated docs.security.json — the page renders 9 of 21 guarantee rows, 6 of 13 undefended edges, 4 of 8 known gaps.

At 75c6ac77: pnpm test green (lib 2148, website 176, vscode-ext 113, standalone 62, plus server and server-lib-common), spec-lint OK at 31 specs / 62 files with the ratcheted budget.

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three landed at 75c6ac77 and read correctly — SECURITY.md now qualifies the link a reporter follows, docs/specs/security.md's front matter states the split at the file it is applied to, and website-docs.md line 31 agrees with line 13. The ratchet is right: 1856 words against the new 1900 budget, and re-running --ratchet on it is a no-op. spec-lint (31 specs, 62 files), spec-lint-selftest, e2e-lint, deploy-lint, loopback-lint, and public-docs-lint are all green at that head.

Both judgement calls read well too — scoping SECURITY_DELTA's docstring to the delta and pointing at audienceBlocks for the narrowing keeps each comment about the thing it sits on.

One instance left, inline: AGENTS.md line 61, which this PR added.

Comment thread AGENTS.md Outdated
The spec index this PR added carried the same page claim the last two
commits corrected elsewhere, and it is the file every agent session loads
first. Budget ratcheted to 2800: the qualification cannot be stated in
fewer words than the claim it replaces.

The two remaining "published whole" phrases in website-docs.md, and the
three in public-docs-lint.mjs, are the file-vs-excerpt sense — every
section of the file publishes, so it may carry nothing staged — and stay
correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKfi4TNKoqQNELstoeKAu2
@nedtwigg
nedtwigg merged commit 93a72f1 into main Sep 3, 2026
19 checks passed
@nedtwigg
nedtwigg deleted the security-fixup branch September 3, 2026 22:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants