Skip to content

Correct the security specs and audit prompts; close two manage-verify gaps - #715

Draft
nedtwigg wants to merge 3 commits into
specs-website-auditfrom
specs-security-audit
Draft

nedtwigg wants to merge 3 commits into
specs-website-auditfrom
specs-security-audit

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Sep 20, 2026

Copy link
Copy Markdown
Member

Spec audit, group 7 (the security*.md specs, SECURITY.md, SELF_HOST.md, the .github/audit/ prompts). Stacked on #714 (the spec-audit PRs merge in order #708#715); independent of the alert series. No FAIL IF condition was deleted; several were corrected.

Call-outs

  • Latent bug in an audited rule: security-audit.md's Domains FAIL IF said .github/audit/ / .vscode/ must be outside every consumer of the workflow-audit window, which could never fire on the failure its own rationale names (one consumer widened without the others). Now any.
  • Two manage verify gaps: a dangling previous pointer failed only on Linux (macOS/Windows green-ticked a pruned rollback target), and no platform inspected the service definition or wrapper for a credential. Both are now checks in all three installers with deploy-lint rules and self-test mutations (112 → 118 checks). The Windows branch is lint-read only — no PowerShell on this machine.
  • The supply-chain audit prompt never used $AUDIT_PAT, so its admin-scoped FAIL IF would read null and record a false FAIL; fixed. The application-security prompt now carries the Hosted guidance its 11 claimed rules need.

Also: recovery.json is 0600/0700, not "written at the umask" (Windows is the real residual); the ten-minute Bash cap is now tied to BASH_DEFAULT_TIMEOUT_MS; pull_request_target scoped to an on: trigger; the monotone-upward issue-title rule stated; two unpinned claims now name their tests; SECURITY.md points at the Domains table instead of listing five of six audited specs; spec-lint gains check 17 (FAIL IF only in security*.md) and check 15 now covers root specs, both self-tested; SELF_HOST.md gets its rationale file and loses ≈100 words of restated security-remote.md rules; the Hosted freshness window is one constant pinned against the server gate.

Budgets: SELF_HOST 6200→6100, security-remote 5850→5750, security-supply-chain 1250→1150.

Deferred (owner decisions or larger refactors): one release-pointer mechanism, one sentinel predicate for the audit fragments, one report merge, manage status vs verify, the fixed PORT key, the generate-deps override tables.

🤖 Generated with Claude Code

nedtwigg and others added 2 commits September 20, 2026 01:05
…ionale

Fixes inaccuracies the spec audit found in the security group, and moves
evidence out of the specs that had no headroom left for the corrections.

Corrections
- security-local.md's persisted-state FAIL IF named "three callers" of
  `write_file_atomically`; there are five. It now says every caller and tells
  the auditor to enumerate from the file.
- security-audit.md's Domains FAIL IF quantified `.github/audit/` and
  `.vscode/` as outside *every* consumer of the workflow-audit diff window,
  which reads as "absent from all" and so never fires on the failure it exists
  to catch — widening one consumer without the others. Inverted to *any*. The
  rule is latent today (all four consumers derive from one WINDOW array), but
  it was an audited rule that checked nothing.
- security-remote.md and SELF_HOST.md claimed deploy-lint is the only
  automated signal about the Windows installer; ps1-cmdlet-lint reads the same
  file.
- security.md said `recovery.json` is written at the umask. It is written
  0600 under a 0700 directory; the real residual is Windows, where the mode is
  a no-op and only standalone applies a DACL.
- security.md's Persisted state and Desktop Releases rows said `audit`; both
  are pinned on every `pnpm test` (`cargo test`, sign-and-deploy.test.mjs).
  security-ci.md now names the same test.
- security-local.md's Settings-Sync FAIL IF said the rule was kept by a call
  not existing; a test scans for it.
- security-ci.md's `pull_request_target` rule now quantifies over an `on:`
  trigger, so the comment in hosted-preview.yml is not a false FAIL.
- security-audit.md now names `BASH_DEFAULT_TIMEOUT_MS`, states the
  monotone-upward issue-title rule, says `claude_args` rather than `--agents`,
  and drops the false "disjoint scopes" claim.
- SECURITY.md named five audited checklists and omitted security-hosted.md;
  it now points at security.md's Domains table. Same fix on the website page.
- hosted.md: root `pnpm test` runs the deploy suites, and archive hashes are
  `verifyPackages`, not `preflight`. security-hosted.md gains its `Pinned by`.
- SELF_HOST.md: `setup-password.json` added to the state tree, the stale
  dev-relay port claim corrected, and the Windows kill block no longer selects
  by image name, which its own trap forbids.

Prompts
- supply-chain.md now carries the `GH_TOKEN=$AUDIT_PAT` instruction its
  secret-scanning and Dependabot checks need; without it both read as absent.
- application-security.md gains the Hosted paths security-hosted.md requires
  and Hosted bullets for its qualitative pass.

Lint
- A root-level spec now pairs with a root-level rationale, so check 15 covers
  SELF_HOST.md (6,036 words, previously exempt) and check 11 sees its markers.
- New check 17: a `**FAIL IF**` rule leads a line only in a security spec, so
  an audited rule cannot be written where no domain claims it.
- Both carry self-test cases.

Condensation
- SELF_HOST.rationale.md is new; the Invariants, mechanism-map and traps
  narratives move into it or point at the code comment that already holds
  them, and the Installer contract points at security-remote.md rather than
  reproducing it.
- security-remote.md, security-supply-chain.md, security.md, security-local.md
  and security-audit.md drop restatements their rationale files or the audit
  prompts already carry. Budgets ratcheted down for SELF_HOST.md (6200→6050),
  security-remote.md (5850→5750) and security-supply-chain.md (1250→1150).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…o values

Two things SELF_HOST.md's "Definition of done" states for all three platforms
were checked on fewer than three, so `manage verify` reported healthy on an
install that was not.

- **A dangling `previous` pointer.** Only Linux failed it. macOS and Windows
  compared `previous` to `current` and stopped, so a pointer at a pruned
  release passed green and `manage rollback` then failed on a release that was
  not on disk. Both now fail it, as Linux does.
- **A credential in the service definition.** Nothing inspected the plist, the
  unit, the scheduled-task XML, or the `run-relay` wrapper for one: a plist
  carrying `DORMOUSE_SETUP_PASSWORD` passed verify. All three now search the
  definition and the wrapper for every credential name the installer knows,
  exempting the enrollment offer's `_FILE` path, which they legitimately carry.

Both are deploy-lint rules with per-platform patterns, so deleting the control
from any one installer fails the build, and the self-test's mutation proves
each is load-bearing (112 -> 118 checks).

Also:
- `manage verify`'s "Serve does not proxy" failure prints the `manage serve`
  command that re-applies the mapping, on all three.
- The audit workflow parses the status file into `FILE_STATUS` and assigns
  `STATUS` from it once, deleting `STATUS_FILE_VERDICT` — a derived variable
  with one reader that existed only because `STATUS` was mutated in place.
- The Hosted account screen's recent-login window was a `600_000` literal
  beside the packed adapter's `freshAge: 10 * 60`; a pgstencil bump would have
  desynced the UI gate from the server gate, visible as a "connect" button that
  always 302s to `/login?error=`. One `LOGIN_FRESH_AGE_MS` in
  `hosted/server/policy.ts` now, pinned against the adapter's own built options
  by `hosted/server/tests/policy.test.ts`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: eb1604a
Status: ✅  Deploy successful!
Preview URL: https://5983d880.mouseterm.pages.dev
Branch Preview URL: https://specs-security-audit.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.

Feedback on work in progress, not a merge verdict — mark it ready when you want the full review.

Two issues in the new manage verify credential check, and one note on the Hosted constant, inline. The spec compression itself reads correctly where I sampled it against the rationale files and the code.

Standalone Smoketest is red on this head; the run is still in progress, so its log isn't readable yet.

# rest"). A name, not a value: the installer supplies none of these, so one
# appearing means a hand-edit or a regression put a credential where any
# process that can read the definition can read it.
if (("$taskXml" + "`n" + $wrapperText) -match 'DORMOUSE_SETUP_PASSWORD|DORMOUSE_VAPID_PRIVATE_KEY|DORMOUSE_ENROLL_TOKEN(?!_FILE)') {

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 check reports a pass for a definition it never read. $taskXml comes back $null whenever Export-ScheduledTask fails — the three cases the comment at its assignment already names — and "$null" is the empty string, so the -match cannot fire and this prints Pass "the service definition names no credential". $wrapperText has the same shape: it stays '' when bin\run-relay.ps1 is missing. The file's other $taskXml consumer guards for exactly this, with if (-not $taskXml) { Fail "the task definition could not be exported -- it was not searched for the source checkout" }.

The two shells land in the same place by a different route: grep -q exits 2 when a named file is absent, which is neither 0 nor a match, so a missing $PLIST, $UNIT_FILE or $ROOT/bin/run-relay takes the else and green-ticks the check. (grep -qE PAT present.txt missing.txt exits 2 when present.txt doesn't match.) SELF_HOST.md -> "Definition of done" states the opposite two bullets above this one: "a definition it cannot read at all fails rather than passes".

Failing first on unreadable inputs restores that — -not $taskXml -or -not $wrapperText here, and an [ -r "$PLIST" ] && [ -r "$ROOT/bin/run-relay" ] arm on unix.

# rest"). A name, not a value: the installer supplies none of these, so one
# appearing means a hand-edit or a regression put a credential where any
# process that can read the definition can read it.
if grep -qE 'DORMOUSE_SETUP_PASSWORD|DORMOUSE_VAPID_PRIVATE_KEY|DORMOUSE_ENROLL_TOKEN[^_]' \

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.

DORMOUSE_ENROLL_TOKEN[^_] requires a character after the name on the same line, so a bare export DORMOUSE_ENROLL_TOKEN at end of line — inheriting the value from the environment, which is a shape a wrapper edit can plausibly take — doesn't match: printf 'export DORMOUSE_ENROLL_TOKEN\n' | grep -qE 'DORMOUSE_ENROLL_TOKEN[^_]' exits 1. The Windows (?!_FILE) lookahead does catch it, so that hand-edit fails verify on Windows and passes on macOS and Linux — the divergence SELF_HOST.md -> "Installer contract (maintainers)" calls a finding ("Those lines bind all three installers — a control present in one and absent from another is a finding").

An end-anchored alternative closes it in ERE on both shells:

'DORMOUSE_SETUP_PASSWORD|DORMOUSE_VAPID_PRIVATE_KEY|DORMOUSE_ENROLL_TOKEN[^_]|DORMOUSE_ENROLL_TOKEN$'

Not posted as a one-click suggestion because scripts/deploy-lint.mjs's two unix patterns pin this string literally — they need the same edit in the same commit, or pnpm lint:deploy goes red. Same line in deploy/local/install-macos.sh.

Comment thread hosted/src/App.tsx
type Provider,
type Session,
} from "./api";
import { LOGIN_FRESH_AGE_MS } from "../server/policy";

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.

Worth weighing: this pulls hosted/server/policy.ts into the frontend import graph, and with it providerBindings — the function that reads the OAuth client secrets out of env. Rollup should drop it, but docs/specs/security-hosted.md -> "Origin boundary" has an auditor "inspect the frontend import graph", and the comment above the providerIds re-export in policy.ts says the allowlist lives in ./providers.js so the frontend can import it "without this module's dependencies" — which this import walks back.

A module holding only LOGIN_FRESH_AGE_MS, imported by both policy.ts and App.tsx, keeps the single source of truth (and the new freshAge test) without widening that graph.

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