@rightkit/release 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +122 -9
  2. package/admission-files.mjs +3 -2
  3. package/advisory-db.json +4 -0
  4. package/advisory-db.mjs +124 -0
  5. package/advisory-exceptions.mjs +13 -10
  6. package/app-check.mjs +257 -38
  7. package/bin/darwin/rightkit-swap +0 -0
  8. package/bin/rightkit-agent-hook.mjs +9 -0
  9. package/bin/rightkit-gate.mjs +495 -344
  10. package/build-release.mjs +13 -6
  11. package/cli/right-release.mjs +29 -6
  12. package/compile-inputs.mjs +66 -0
  13. package/consumer-contracts.json +1044 -0
  14. package/consumer-contracts.mjs +627 -0
  15. package/consumer-input-contracts.mjs +422 -0
  16. package/consumer-policy.mjs +588 -0
  17. package/consumer-source-contracts.mjs +469 -0
  18. package/deny-default.toml +42 -0
  19. package/deterministic-scans.mjs +240 -29
  20. package/disk-command.mjs +270 -30
  21. package/disk-policy.json +50 -10
  22. package/disk-policy.mjs +482 -35
  23. package/disk-report.mjs +129 -0
  24. package/door-policy.mjs +228 -0
  25. package/gate-executor.mjs +30 -0
  26. package/gate-stages.mjs +110 -0
  27. package/github-updater-release.mjs +48 -8
  28. package/init-command.mjs +41 -0
  29. package/legion-semantic-contracts.mjs +428 -0
  30. package/ownership-class-rules.mjs +277 -0
  31. package/ownership-key-authority.mjs +3 -1
  32. package/ownership-mandatory-set.mjs +3 -2
  33. package/package.json +10 -3
  34. package/publish-cargo.mjs +5 -2
  35. package/publish-update.mjs +14 -11
  36. package/release-config.mjs +51 -6
  37. package/rightkit-versions.json +172 -167
  38. package/shell-contract-bridge.json +251 -0
  39. package/shell-contract.mjs +1069 -0
  40. package/sign-updater.mjs +24 -4
  41. package/source-closure-rules.mjs +204 -0
  42. package/stage-sidecar-command.mjs +2 -1
  43. package/stage-sidecar.mjs +437 -114
  44. package/suite-api.generated.mjs +96 -6
  45. package/suite-trust-roots.mjs +198 -0
package/README.md CHANGED
@@ -28,6 +28,12 @@ Not coverable after packing: an NSIS exe (LZMA payload) and a compressed DMG. Th
28
28
 
29
29
  Every Right app has absolutely NO access to DevTools; the single exception is ViewRight, which may inspect the HTML content it opens, never its own UI or shell. The rules above therefore refuse `devtools` (and rightkit-tauri-shell's `content-devtools`) for every app, with one narrow allowance (`devtools-policy.mjs`): an app whose right-release config declares `devtools: "content-only"`, **and** that is listed in `devtools-policy.json` (`contentOnlyApps`, today only `viewright`), **and** whose Cargo manifest depends on `rightkit-tauri-shell` with `content-devtools`, **and** whose `build.rs` declares an application ACL manifest. Then the source layer accepts `devtools` / `content-devtools`, and the binary layer accepts the `tauri-devtools` signatures only in a binary that also carries `RIGHTKIT-SHELL-DEVTOOLS-CONTENT-ONLY-COMPILED` (written by rightkit-tauri-shell when `content-devtools` is compiled). `--all-features`, `qa-native`, `qa-control`, `dev-native`, `dev-inspect` and every other rule stay refused. The allowance is recorded in the sealed manifest (`devtools`) and re-checked against the allow-list when a sealed file is verified. Any other app declaring it is refused (`devtools-policy.test.mjs`).
30
30
 
31
+ ## Shell contract rules (`shell-contract.mjs`, preflight SC-R1..SC-R9)
32
+
33
+ How an app wires `rightkit-tauri-shell` is checked from committed files alone, before any build. `shell-contract.mjs` exports `SHELL_CONTRACT_RULES` (`{ id, gap, run({ root, files }) }`, findings `{ file, subject, problem, fix }` like the G13-G21 rules); `scripts/preflight.mjs` runs them on any repository with a `tauri.conf.json` and ratchets their findings in `ci/app-check-baseline.json` (the nine rules are the SC-R rows of the table in "The app check") (identity `rule|file|subject`, no line numbers). Run one with `node scripts/preflight.mjs --repo <app dir> --only shell-csp`. Each app is a tracked `tauri.conf.json`; a `tauri.<platform>.conf.json` is merged into it by JSON Merge Patch (arrays replace) and judged as its own target only when its patch restates the checked key.
34
+
35
+ The module imports node builtins only (preflight runs with no `node_modules`), reads Rust and TSX with a comment- and string-aware scanner instead of a parser (an expression it cannot resolve is skipped, never guessed), and reports a rule that fails on one app as a `rule-error` finding instead of throwing. Its content-devtools Cargo test is a copy of `selectedContentDevtoolsCrate` in `devtools-policy.mjs`; `shell-contract.test.mjs` holds the two together.
36
+
31
37
  ## Release switch scan (M21/M23, owner decision D4, ratified)
32
38
 
33
39
  `release-switch-scan.mjs` proves that release code cannot use an environment variable, argument, settings key or build flag to select a test mode, data root, executable, library, model or identity. The runtime half is `rightkit_daemon::mode`: a build without the `test-mode` cargo feature reads no override selector at all. Registry: `release-switch-registry.json` (forbidden features, the `RIGHTKIT-TEST-MODE-COMPILED` marker, deny names with their class). Apps add their own deny names under `releaseSwitches.forbid`. Selector classes: S1 test activation, S2 authority or identity override, S3 executable/library/model, S4 internal state or data root (S1-S4: forbidden, no allow-list), S5 user-content location (allowed, user gesture), S6 diagnostics/tuning (allowed, bounded value).
@@ -90,7 +96,7 @@ On desktop (non-iOS) targets the source walk covers the whole app root, so app t
90
96
  | Ownership | `ownership`, `ownership-approval`, `ownership-report`, `ownership-mandatory`, `adoption-evidence` |
91
97
  | Test | `test` (the app's declared suites) |
92
98
  | Legal | `license-notices npm`, `license-notices cargo`, `license-notices binaries` |
93
- | Other | `dev`, `doctor`, `suite-doctor`, `qa`, `dmg`, `cache`, `apple`, `inventory`, `model`, `addon`, `hooks`, `lsclean`, `generate-dmg-background`, `publish cargo`, `publish npm`, `publish swift` |
99
+ | Other | `dev`, `doctor`, `suite-doctor`, `qa`, `dmg`, `cache`, `disk` (see "Disk hygiene on the agent volume"), `apple`, `inventory`, `model`, `addon`, `hooks`, `lsclean`, `generate-dmg-background`, `publish cargo`, `publish npm`, `publish swift` |
94
100
 
95
101
  ## The app check: deterministic first, no flag skips it
96
102
 
@@ -131,16 +137,61 @@ The rules (`right-release check --list` prints their ids and gaps; `scripts/pref
131
137
  | `switches-inventory` | G20 | a directory with a `right-release.config.json`, read as data (or a legacy `right-release.config.mjs`), and no `switches.json` (or its `releaseSwitches.inventory`) |
132
138
  | `speech-link` | G21 | `import Speech` under `swift/RightKitSwift/Sources` or `Tests` |
133
139
  | `e2e-under-cargo-test` | G22 | `cargo test`, `cargo run` or `cargo nextest` (plain, `rustright cargo`, or the retired `rightkit`) that runs an end-to-end crate in the broker: it holds the compile slot for the whole run |
140
+ | `suite-key-literal` | C3-04 | the Right Suite licence public key pasted as a byte array, 64 hex digits, base64 or `\x` escapes, or the updater key text outside a `tauri.conf.json`, in a non-test file; take it from the contract's generated constants |
141
+ | `suite-updater-key` | C3-14 | a `tauri.conf.json` `plugins.updater.pubkey` that is not a copy of a key in `contracts/right-suite-api.v1.json` (missing, a placeholder, another key or an unparseable file); a platform override may leave it to the base config |
142
+ | `engine-record-store` | C3-05 | a call to `from_record_store` outside `crates/rightkit-license`: the owner binary hands the engine a snapshot through `rightkit_credentials::handoff` instead |
143
+ | `shell-window-hidden` | SC-R1 | a window created at startup (desktop targets) without `visible: false`; a platform patch that restates `app.windows` must restate it |
144
+ | `shell-plugin-order` | SC-R2 | the shell plugin installed from `setup`, or behind a non-QA plugin (the shell's `single_instance_plugin` and QA plugins may come first; a shell built with `single_instance(false)` may follow the plugin that arbitrates) |
145
+ | `shell-handler` | SC-R3 | an `rk_shell_*` command the renderer invokes (a literal, the body of an `@rightkit/tauri-shell` import, or the members it reaches on its `createShell()`: `shell.window.close()` is `rk_shell_window`) that is neither listed in `generate_handler!` nor covered by `handler!` (the command map of `@rightkit/tauri-shell` ships in the package as `shell-contract-bridge.json`, generated by `scripts/generate-shell-contract-bridge.mjs`); a shell it cannot follow needs at least one listed |
146
+ | `shell-csp` | SC-R4 | no CSP, or one without `default-src 'self'`, `frame-ancestors 'none'` or `connect-src`, or with `unsafe-inline` / `unsafe-eval` in `script-src` (`default-src` when there is none); `style-src 'unsafe-inline'` and `ipc:` are fine |
147
+ | `shell-grants` | SC-R5 | a `core:window:allow-*` grant that `rk_shell_window` mirrors (minimize, maximize, unmaximize, toggle-maximize, set-fullscreen, close, hide, show) while that command is registered; start-dragging and verbs the shell has no command for stay allowed |
148
+ | `qa-window-label` | SC-R6 | QA `Control` bound to a label other than the shell's `main_label` (a bare `Control` binds the launcher's `[ui] window_label`, which defaults to `main`, so an app whose window is not `main` names it with `.window(..)`), or a main label no config or code window carries |
149
+ | `devtools-declaration` | SC-R7 | the five sites (release config `devtools: "content-only"`, `devtools-policy.json`, `content-devtools` feature, `build.rs` ACL manifest, `.devtools_policy(ContentOnly)`) disagree once any of the declaration, the feature or the policy is present |
150
+ | `shell-lifecycle-external` | SC-R8 | `lifecycle_only` / `lifecycle_plugin` beside a `WebviewUrl::External` window; `host_windows()` keeps hardening and the navigation policy |
151
+ | `legal-receipt-native` | SC-R9 | `<Onboarding>` (`legalMode` not `none`/`notice`) with no `legalReceipt` or a hand-written one, or a `LegalGate` that stamps the acceptance with the webview clock while the app has no `createNativeLegalReceiptPort` |
152
+ | `release-config-identity` | CA-01 | a `right-release.config.mjs` whose default-exported object declares `schema` but whose top-level `app` or `version` is missing, `undefined`, `null`, empty or not a string (read as text, never run); a value it cannot prove from a string literal or a top-level `const` string in the same file is an UNSUPPORTED finding, not a pass; a JSON config needs strings, or a `$fromJson`/`$fromToml` node whose selected value (resolved from the committed file as `release-config.mjs` does) is a non-empty string |
153
+ | `pnpm-node-floor` | CA-05 | a workflow job that runs pnpm and sets up a Node below what the pinned pnpm needs (pnpm 11: Node 22.13, `consumer-contracts.json`), or a `.node-version`/`.nvmrc` below it |
154
+ | `rust-dependency-msrv` | CA-05 | a `Cargo.lock` crates.io entry whose `rust_version` (the pinned crates.io index snapshot in `consumer-contracts.json`) is above the concrete channel of the nearest `rust-toolchain(.toml)`; `stable` and versions the snapshot lacks are not judged |
155
+ | `release-age` | CA-06 | a `pnpm-lock.yaml` package published (pinned npm snapshot in `consumer-contracts.json`) after the check's clock less `minimumReleaseAge` (explicit, or the pnpm 11 default of 1440 minutes) and not in `minimumReleaseAgeExclude` |
156
+ | `notice-lock-digest` | CA-14 | a notice or licence file whose `<lock> sha256=<hex>` (right-release license-notices, @rightkit/legal npm appendix) or `<lock> sha256 <hex>` (legal-snapshot notices) differs from the committed lock (a legal-snapshot `notices.hashes` entry, else the nearest lock of that name); a lock digest in another form, or for a lock the tree does not hold, is UNSUPPORTED; history folders (evidence, fixtures, plans, archive) are not judged |
157
+ | `doc-comment-bare-cr` | CA-15 | a carriage return that ends no line inside a Rust `///`, `//!`, `/** */` or `/*! */` doc comment (rustc: bare CR not allowed in doc-comment); CRLF line ends and plain comments are fine |
158
+ | `checkout-step-input` | CA-07 | a workflow step that reads a tracked path (a script, a source file, a `working-directory`) its job's cone-mode sparse checkout does not materialize; git pathspecs, globs and comments are not inputs, and a full checkout reads everything |
159
+ | `source-relation` | CA-09 | a value in one committed file that names a row another committed file does not have: the relations RightKit knows (`SOURCE_RELATIONS`: SwiftRight's `hand-entries.json` `statusDocBlock` to a `docs/sr1-status.md` table row) plus an app's own `.rightkit/source-relations.json` (`{ "schema": 1, "relations": [{ id, source, select, target, adapter: "markdown-table-first-cell" }] }`) |
160
+ | `checkout-longpaths` | CA-11 | a job on a GitHub-hosted Windows runner whose checkout holds tracked paths over the Win32 limit under `D:\a\<repo>\<repo>\` (`<repo>` from the GitHub `origin` remote, never the checker's directory; file 259, directory 247 characters) with no step before it that provably runs `git config --system\|--global core.longpaths true` as its whole script (or `GIT_CONFIG_COUNT` covering a `KEY_n`/`VALUE_n` pair); a conditional, echoed, custom-shell or action form, or an unknown repository name, is an `UNSUPPORTED:` finding, never a pass; long paths outside the sparse cone, off Windows or on a self-hosted runner are fine |
161
+ | `workflow-action-pair` | CA-04 | one job of a workflow runs `github/codeql-action` `init`, `autobuild`, `analyze` or `resolve-environment` from different refs (a floating tag beside a commit counts); separate jobs, `upload-sarif` and other repositories keep their own refs |
162
+ | `referenced-input` | CA-07 | a `package.json` script or workflow `run:` that runs `node`/`sh`/`bash`/`python <file>` absent from the commit or outside the repository, a `pnpm --dir`/`-C`/`npm --prefix` directory, a workflow `working-directory` or `actions/setup-*` `cache-dependency-path` the commit lacks; ignored (`.gitignore`) and build-output paths, `${...}`/globs, directories an unconditional `actions/checkout` or `actions/download-artifact` step earlier in the same job writes (`path:`), and `fixtures/` trees are not judged; an input only a later step, another job, an `if:`/`continue-on-error` step or an `actions/cache` restore could produce is reported UNSUPPORTED |
163
+ | `caddy-global-options` | CA-08 | a Caddyfile whose first block is the global options block (no address) holding a site directive (`encode`, `reverse_proxy`, ...), or whose first address is only `{$VAR}` placeholders with no default (an empty variable turns it into the global options block); an explicit global block first or `{$VAR:default}` passes, unknown names (plugin options) are not judged |
164
+ | `legion-schema-projection` | LG-02 | in a tree with Legion's schema generator, a schema it byte-compares that the checked commit's own `.gitattributes` leaves to line-ending conversion (info/attributes, global and system attributes do not count) (Windows checks it out as CRLF and `--check` reports SCHEMA DRIFT), or an `enum` that is not the code-owned Rust `&[&str]` list it projects |
165
+ | `legion-legacy-tokens` | LG-03 | in a tree with Legion's naming ledger, a legacy token (`seer`, `nemesis`, `forge`, `sentinel`, `sorcerer`) in a path or file that `src/config/naming-legacy-allowlist.json` does not classify, or classifies with another exact count; immutable qualification evidence is exempt as Legion's producer exempts it |
166
+ | `legion-retirement-ledger` | LG-04 | in a tree with Legion's retirement ledger, a skill, script, hook, doctrine, agent-rule or native command file deleted after `c1d80c9d` (bound to the full id `c1d80c9d190e0f45dfd630d4d65e8bed748381ce`) with no `docs/provenance/retirements.md` row; a shallow clone fails as unverified |
167
+ | `legion-authority-body` | LG-05 | in a tree with Legion's roster, an agent card without its doctrine body verbatim (a variant also its two roster bodies), a card naming `doctrine/` or `src/roster/` outside its method, or a council-seat body that differs from doctrine |
168
+ | `retired-token` | CA-13 | a token the app retired (declared in `.rightkit/consumer-policy.json` `retiredTokens`: tokens, scoped globs, extensions) in a scoped file, outside a whole-file (`allowFiles`) or exact-fragment (`allowFragments`: text, `identifier` or `string`) exception, each with a reason |
169
+ | `route-deny-policy` | CA-13 | the app's declared deny set (`routeDenyPolicies`: a policy JSON of `denied`, `exceptions` and `irreversible` segments and fragments) disagrees with its committed route table or OpenAPI document: a denied or excepted route that is not a route, a DELETE not denied, an irreversible-looking mutation neither denied nor excepted, a route both denied and excepted, or a denied route the irreversible marks do not cover |
170
+ | `layer-reach` | CA-13 | a value import chain (read lexically, never run) from a declared `from` file reaches a `forbid` file of the same `layerBoundaries` entry; a `stop` file is a reviewed API, accepted and not walked; a load with a non-literal specifier is a finding |
171
+ | `spec-route-parity` | CA-13 | the committed API spec and the committed route table disagree (`specRouteParity`): a documented route the spec lacks, a spec operation that is not a route, or a declared undocumented route the spec documents |
172
+ | `source-closure` | SF-01 | in any committed `*.json`, a sealed record `{ sourceClosure: { sources: { "<path>": "sha256:<hex>" }, digest } }` whose sources are missing from the tree or changed since sealing, or whose `digest` is not sha256 over the canonical JSON of `sources`; a digest over a projection is declared in `.rightkit/source-closures.json` by naming a RightKit-approved transform identity (`APPROVED_TRANSFORMS`: source path, families, dropped keys and input schema are RightKit's), applied to the current bytes and compared; the repository never supplies pointers. Membership a producer would now add is not visible (it is computed by repository code) |
173
+ | `mandatory-foundation` | OW-05 | an app (root `rightkit-mandatory.json`, or a `right-release.config.(mjs\|json)` at the root or beside a `src-tauri/tauri.conf.json`) without its declaration; or whose profile requires a foundation owner of `ownership-mandatory-set.json` that it does not declare as a dependency (missing or dev-only), or that selects an unknown or unratified profile; or a `notApplicable` row that is not a ruling ratified for the declared `app` in `appAmendments`, reason verbatim (the row then still applies). Owner-pending rows never fail |
174
+ | `ownership-exceptions` | OW-06 | a `.rightkit/ownership-exceptions.json` with an unknown key, or an entry without `capability`, `package`, `reason`, `owner`, `approvedBy`, a real `date`, a real `expires` and an `issue` naming a RightKit issue; an entry past its `expires` date fails with no identity, so no baseline silences it; an entry no ratified ruling (`appAmendments.<app>.exceptions`: capability, package or crate, path when named, RightKit issue, expiry no later than the ruling) covers |
175
+
176
+ The CA-13 rules check contracts an app declares about itself in `.rightkit/consumer-policy.json` (schema 1; the shape is in the
177
+ header of `consumer-policy.mjs`). An app without the file has nothing to check. They read JSON and source text only: no repository
178
+ script, generator or config runs, so a contract that can only be judged by running the app's code (an OpenAPI body generated at
179
+ runtime, for one) stays with the app's own test.
134
180
 
135
181
  `e2e-under-cargo-test` (G22): the broker compiles; it does not run end-to-end tests. An end-to-end crate is a Cargo package that depends on
136
182
  `rightkit-qa` not optionally (the optional dependency is an app's in-app control server) and has a `.rs` file outside `examples/` that names
137
183
  a driver module of it (`harness`, `control`, `suite`, `scenario`, `engine`, `in_app`, `interactive`, `page`, `browser`, `android`); a name
138
184
  like `*-e2e` is not evidence. A command selects it by `-p`/`--package`, `--manifest-path`, `--workspace`/`--all` (the members of the
139
- workspace in the working directory, less `--exclude`), `--test`, or for `cargo run` `--bin`. It looks in `package.json` scripts, shell,
185
+ workspace in the working directory, less `--exclude`), `--test`, or for `cargo run` `--bin`; and by naming nothing at all in a virtual workspace (a
186
+ `[workspace]` with no `[package]`): `cargo test` and `cargo test --manifest-path Cargo.toml` test every member, or the `default-members` when it declares
187
+ them, less `--exclude` (a root that holds a package of its own tests that package only, so a bare run selects nothing there). It looks in `package.json` scripts, shell,
140
188
  PowerShell, batch, Node and Python scripts, Makefile and justfile recipes, workflow `run:` steps, git hooks, and code in `README.md`,
141
189
  `AGENTS.md`, `CLAUDE.md` and `docs/` (history documents excluded). `--no-run`, `rightkit-qa run-tests`, `rightkit-qa journeys run` and
142
- `--test qa_surface_guard` are not findings. The identity is `e2e-under-cargo-test|<file>|<script or crate>`. Fix: compile with `--no-run`, then
143
- `rightkit-qa run-tests -p <crate> [--test <target>] -- <libtest args>`.
190
+ `--test qa_surface_guard` are not findings. A code span in a runbook's running text that is only the command (`` `cargo test` ``, `` `rustright cargo test` ``, no argument) is a command line only where an unnegated imperative (run, use, execute, invoke, call, type) stands right before it in its clause ("Run `cargo test` before you push"); "`cargo test` unit tests", "`cargo test` would hold the compile slot", "only CI runs `cargo test`", a list label "`cargo test`: Rust unit tests" and "do not run `cargo test`" name the command and are not findings. With an argument, in a fenced block, a script, a `package.json` script, a recipe, a workflow step or a hook, the same command is judged whatever stands around it; a PowerShell `<# ... #>` block comment is a comment. The identity is `e2e-under-cargo-test|<file>|<script or crate>`. Fix: compile with `--no-run`, then
191
+ `rightkit-qa run-tests -p <crate> [--test <target>] -- <libtest args>`; a whole-workspace run must also leave the crate out (`--workspace --exclude <crate>`),
192
+ since `--no-run` on the workspace compiles it all and runs none. `cargo run` has no `--no-run`: the finding says to build the program in the broker
193
+ (`rustright cargo build -p <crate> --bin <bin>`, with `--manifest-path` for a crate outside the workspace in the working directory) and run the built binary
194
+ outside it, or `rightkit-qa journeys run`.
144
195
 
145
196
  Known findings are tolerated only through a baseline: `--baseline <file>`, else the checked tree's `.rightkit/app-check-baseline.json`,
146
197
  else (from a RightKit checkout only) `ci/app-check-baseline.json`. The app's own file is always `{ "schema": 1, "known": [{ "id", "issue",
@@ -152,7 +203,20 @@ whatever a baseline says (`switches-inventory`) fails even when listed.
152
203
 
153
204
  ## The pre-push gate on a host without the broker: the Rust gate is deferred, the HOST RECEIPT gates the merge
154
205
 
155
- On a host with neither `rustright` nor the managed cargo shim on PATH (a cloud session), `rightkit-gate` defers the Rust gate to host qualification instead of refusing when git's stdin names only non-protected branches (not `main`, `master`, `trunk`, `release/*`, the `.rightgit.json` `defaultBranch` or a tag) and the process is not in CI (`CI` and `GITHUB_ACTIONS` unset): it reads the Rust units from the tracked manifests, runs every scan that needs no broker, prints `rightkit-gate: rust gate DEFERRED to host qualification (no RustRight broker on this host): <Rust units>; scans not run <ids>; the pull request needs a HOST RECEIPT before merge`, exits 0 and writes nothing into the tree. The pull request still needs a HOST RECEIPT (a host with the RustRight broker ran the full gate) before it merges, and that receipt, not the hook, remains the merge gate; a protected branch, a host with the broker or the shim, CI, a manual run or `--plan`, and `RIGHTKIT_GATE_NO_DEFER=1` keep the refusal exactly as before, with one added line saying why nothing was deferred (the protected ref, CI or the opt-out). Every pushed ref is judged in any position (git's stdin is split on single ASCII spaces, so a Unicode space in a ref name cannot hide one), and a stdin line that is not four fields refuses the push on every host and in every repository, because git sends the local field as typed and a rev expression such as `HEAD^{/one thing}` holds spaces: such a line is never dropped, so the pushed commit is always compared with HEAD. The default branch is read from the checkout, the base ref, the remote commits being replaced and `origin/HEAD`, never from the pushed commit alone (when none of those can be read, which takes a force-push whose replaced tip was never fetched in a repository with no `origin/HEAD`, the checkout's own manifest is all there is). The units come from a manifest reader, not cargo, and a standalone package (a single crate, a Tauri `src-tauri`, several packages with no `[workspace]`) is a unit like any other: when tracked manifests exist and it finds no unit, or finds a package it cannot place, the block says so instead of reporting that no Rust scan was due. An npm test scan (`test:npm:<unit>`) whose `test` script runs `rustright` or `cargo`, directly or through the `pnpm <script>`, `pnpm run`, `npm run` or `yarn` scripts of the same package.json (and their `pre`/`post` hooks; ViewRight's `test:e2e` is `rustright storage run ... -- right-release test`), cannot run on such a host either, so it is deferred with the Rust gate: named in the `scans not run` list and on a line of its own with the script chain and the command, and never reported as passed. On a host with the broker or the shim, in CI, on a protected ref and with `RIGHTKIT_GATE_NO_DEFER=1` it runs as before.
206
+ The gate chooses its executor itself (`gate-executor.mjs` `resolveExecutor`), never the caller: the RustRight broker where the machine has it (`rustright` on PATH, or its managed cargo shim), a refusal on a broker-managed machine whose broker cannot be reached, and direct `cargo` on any other host (a cloud session, a CI runner). Every host runs the same gates in the same order (app rules, class A, then the compile scans); an unmanaged host is never sent to a broker and nothing is deferred to one. Every pushed ref is judged in any position (git's stdin is split on single ASCII spaces, so a Unicode space in a ref name cannot hide one), and a stdin line that is not four fields refuses the push on every host and in every repository, because git sends the local field as typed and a rev expression such as `HEAD^{/one thing}` holds spaces: such a line is never dropped, so the pushed commit is always compared with HEAD.
207
+
208
+ `scriptBrokerCall` reads `package.json` scripts only: it follows `pnpm <script>`, `pnpm run`, `npm run`, `yarn` and the wrapper words, not `node -e`, a node script, `sh -c`, or a run in another package (`pnpm --dir`, `--filter`, `-r`). A unit whose test reaches the broker that way says so in the app's own `ci/gate.config.json`, in the `npm` entry the unit's `quietTests` already uses (the key is the unit name: the package name, or the directory with `/` as `-` for a unit of a pnpm root below the repository root):
209
+
210
+ ```json
211
+ { "npm": { "heardright": { "requiresBroker": { "reason": "the root test runs tauri-app-next/scripts/verify-local.mjs, which spawns rustright cargo test" } },
212
+ "tauri-app-next": { "requiresBroker": { "reason": "test runs scripts/run-contract-tests.mjs, which spawns rustright cargo test" } } } }
213
+ ```
214
+
215
+ The `reason` is required and is printed. The file is read from the gated commit, so the declaration is a reviewed change in the diff that adds it. Where the Rust gate is deferred, the unit's `test:npm:<unit>` scan is deferred with it and named `declared in ci/gate.config.json npm.<unit>.requiresBroker: <reason>`, never reported as passed; on a host with the broker, in CI, on a protected ref and with the opt-out it runs as it did. A declaration of any other shape (`true`, a string, an empty or blank `reason`, an array) is ignored with a note and the test runs where it can: a malformed entry never skips a test. `false` or `null` is no declaration, and a call the scripts do show is reported from the scripts. The gate does not read node scripts for a literal `rustright` or `cargo`: a literal cannot tell a spawn from a command table, a mention or a test fixture, so the declaration is the way.
216
+
217
+ ## The pre-push hook finds the gate in a repository whose root installs nothing
218
+
219
+ `rightkit-gate install-hook` writes `.githooks/pre-push` (and sets `core.hooksPath`). The hook runs the repository's own `.git/hooks/pre-push`, then `rightkit-gate check --commit HEAD`, then the gate, and finds the gate binary in `node_modules/.bin/rightkit-gate` of the repository root, else of any workspace package up to three levels down, else through `pnpm exec`, else with `node` at a path written into the hook. That path is always relative to the repository, the same for every version and a plain name, never absolute, never `../` out of the repository, never a `node_modules/.pnpm/@rightkit+release@x.y.z/` store path, and it is decided by what the tracked `package.json` files declare, never by what is installed: the same repository gets the same hook on a fresh clone, from an exports checkout, after `pnpm install` and after a version bump, so `install-hook` as a `prepare` script never meets a hook it wrote earlier as one that "already exists and differs". When the root `package.json` declares `@rightkit/release` (alone, or together with packages: Pulse declares it at the root and in `hub/`), the path is the root's `node_modules/@rightkit/release/bin/rightkit-gate.mjs` and the hook is the single-root form. When only packages declare it (CutRight's `apps/cutright`, HeardRight's `tauri-app-next`), the path is the same link in the first of them in sorted order, whichever is installed, the hook also tries each declaring package's `node_modules/.bin/rightkit-gate` directly (deeper than the glob reaches) and it runs the `pnpm` fallback as `pnpm --dir <package> exec rightkit-gate`, because `pnpm exec` at a root that does not declare the package has no `rightkit-gate` to run. When no `package.json` declares it, the gate's own path inside the repository if it runs from there and not from a `node_modules` (a checkout of the package itself), else the root's link. A declaring package whose directory is not made of letters, digits and `_ - . @ +` is named in a warning and left out. A root-install repository gets, byte for byte, the hook that 0.6.4 and 0.6.5 wrote (`fixtures/published-gate-hooks.json`); a multi-root repository whose committed hook still has a baked path regenerates it once with `rightkit-gate install-hook --force`, and the result no longer changes with the version or the install.
156
220
 
157
221
  ## The rustfmt scan reads each file's edition from its crate
158
222
 
@@ -181,6 +245,10 @@ A violation reads `<path>[:<line>]: <reason>`. For a template or a fixture it ca
181
245
 
182
246
  The gate takes the npm layout from the tracked files. Every pnpm root is discovered: the repository root, and any other directory outside `node_modules` (at most 6 levels deep, at most 64 roots) that holds a `pnpm-lock.yaml` or a `pnpm-workspace.yaml` beside a `package.json`. CodeRight has two roots, `apps/coderight-tauri` and `apps/coderight-web`, and no package at the repository root. Each root gets `pnpm-lockfile` and `pnpm-audit` run in its own directory (`pnpm-lockfile:<root>` and `pnpm-audit:<root>` below the repository root), planned when a unit of the root is in scope or its own package.json, lock or workspace file changed; each unit gets `tsc:<unit>`, a linter and `test:npm:<unit>`. A unit of the repository root keeps its package name in those ids; a unit of any other root is named from its directory (`apps-coderight-web`), so its ids carry the root path. A repository with one root plans what it always did. A unit is linted by the tool it configures: `eslint:<unit>` where an ESLint config (`eslint.config.*`, `.eslintrc*`) exists in its directory or any directory above it, `biome:<unit>` (`pnpm exec biome lint .`) where a `biome.json` or `biome.jsonc` does, both where both do and neither where neither does.
183
247
 
248
+ The scans that read the live checkout (cargo, pnpm and the tests) run only when the checkout equals HEAD where they read it: any change or untracked file there blocks the gate (`BLOCKED: the checkout differs from HEAD where broker scans read it`). The paths checked are the Rust units and workspaces and their shared build inputs, every npm unit's directory, each pnpm root's `package.json`, lock, workspace file, `.npmrc` and linter configs, and, for each npm unit, every ESLint and Biome config name in every directory between the repository root and the unit (`cleanCheckoutPaths`), because those are the files `eslint:<unit>` and `biome:<unit>` are planned from: an uncommitted `apps/eslint.config.js` above `apps/cutright` blocks the gate like one inside it.
249
+
250
+ The class B pnpm scans (`tsc:<unit>`, `eslint:<unit>`, `biome:<unit>` and `test:npm:<unit>`) run with `pnpm_config_verify_deps_before_run=false`. pnpm 11 verifies before every `pnpm run` and `pnpm exec` that `node_modules` matches `pnpm-lock.yaml` and, if not, runs `pnpm install`, which wants to purge the modules directory and aborts without a TTY (`[ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY]`) before the tool or the test starts. The gate never installs into the checkout it reads; it verifies the lockfile itself (`pnpm-lockfile` in the clean export) and runs the scans against the tree as installed, so keep the tree installed (`pnpm install`) before a push: a tree that lacks a dependency fails in the tool or the test with its own error. The setting is part of each scan's environment (shown by `--plan`, in the cache key), and a failed scan's detail leads with the first `[ERR_PNPM_...]` line pnpm printed.
251
+
184
252
  ## Declared test suites, web build, npm notices and artifact mirroring
185
253
 
186
254
  These replace per-app wrapper scripts. Each is declared in `right-release.config.mjs` and runs without a shell from the app
@@ -501,13 +569,13 @@ Supported: `title`, `icon` (`.icns` volume icon), `background` (PNG or TIFF), `i
501
569
 
502
570
  ## GitHub Releases updater lane (`publish-update`, `github-releases`)
503
571
 
504
- A public app with an in-app Tauri updater publishes with `right-release publish-update --release <sealed-id> --platform win|mac --tier update` and `distribution: { provider: "github-releases", repository: "<owner>/<repo>", updaterKeyId?: "29395F9FF466261D" }`. It is the r2-rightapps lane with a different destination, not a second signer: the updater bundles are signed by `sign-updater.mjs` with the Right Suite updater key (the one every app uses; no per-app key or trust root), sealed with their `.sig` files, and re-verified by `verifyPublicationRelease` before anything is uploaded. The lane then:
572
+ A public app with an in-app Tauri updater publishes with `right-release publish-update --release <sealed-id> --platform win|mac --tier update` and `distribution: { provider: "github-releases", repository: "<owner>/<repo>", updaterKeyId?: "29395F9FF466261D" }`. It is the r2-rightapps lane with a different destination, not a second signer: the updater bundles are signed by `sign-updater.mjs` with the Right Suite updater key (the one every suite app uses; no per-app key or trust root, see "Updater trust roots" below), sealed with their `.sig` files, and re-verified by `verifyPublicationRelease` before anything is uploaded. The lane then:
505
573
 
506
574
  - builds the same `platforms` map the R2 lane builds (`{ <target>: { signature, url } }`), with each `url` the GitHub asset URL `https://github.com/<owner>/<repo>/releases/download/v<version>/<asset>`;
507
575
  - uploads each sealed bundle and `.sig` to release `v<version>` (created when missing, never a draft), byte-verifying every asset by download, and refusing, not overwriting, a remote asset whose bytes differ;
508
576
  - writes a Tauri v2 static `latest.json` (`version`, `notes`, `pub_date`, `platforms`) and uploads it last, merged with the other platform's entries already on that release (the win and mac runs share one `latest.json`; a different version on the release is refused), so `https://github.com/<owner>/<repo>/releases/latest/download/latest.json` resolves.
509
577
 
510
- `updaterKeyId` is optional; when set, a sealed signature made by any other key refuses the publish (`PUBLICATION_SIGNATURE_KEY_MISMATCH`). Notes come from `RIGHT_RELEASE_NOTES`. `--dry-run` prints the upload plan and the manifest with no network call. Uploads use `gh` (already authenticated); no RightApps token is read.
578
+ A sealed signature made by any key other than the resolved `updaterKeyId` refuses the publish (`PUBLICATION_SIGNATURE_KEY_MISMATCH`); a Right Suite app resolves it from the contract even when the config names none (below). Notes come from `RIGHT_RELEASE_NOTES`. `--dry-run` prints the upload plan and the manifest with no network call. Uploads use `gh` (already authenticated); no RightApps token is read.
511
579
 
512
580
  Updater artifacts are declared as for R2 but need no `key`; `platform` must be a Tauri updater target (`windows-x86_64`, `windows-aarch64`, `darwin-aarch64`, `darwin-x86_64`, `linux-x86_64`, optionally with an installer suffix such as `-nsis`, `-msi`, `-app`, `-appimage`) and file names must match `[A-Za-z0-9._+-]` so GitHub does not rename them:
513
581
 
@@ -527,6 +595,17 @@ targets: {
527
595
 
528
596
  Updater signing keys are never named in the config: `sign-updater` reads the minisign key from the OS credential provider (`rightkit-credentials`, namespace `rightsuite-updater-key`; see `protected-credentials.mjs`). `targets.<platform>.updater.keyFile` is refused at config validation (`RELEASE_CONFIG_UPDATER_KEY_FILE`, listed by `right-release doctor` and the build with every other finding) and is never forwarded to the signer, so remove it from apps that still set it.
529
597
 
598
+ ### Updater trust roots (Right Suite apps)
599
+
600
+ A Right Suite app (`coderight`, `cutright`, `genright`, `heardright`, `mailright`, `postright`, `pulse`, `scraperight`, `viewright`) takes its updater trust from `contracts/right-suite-api.v1.json` (`updaterPublicKeys`, `updaterSigningKeyId`), never from its release config:
601
+
602
+ - Do not declare `distribution.updaterPublicKey` (or `updaterPubkey`, `targets.<platform>.updater.pubkey`, `publicKey`). The seal (`build`) and `publish-update` verify every `.sig` against the contract's key text, so a suite app publishes with no key declaration. A declared key that is not exactly a contract key text refuses with `PUBLICATION_SIGNATURE_KEY_MISMATCH` (`suite apps do not declare their own`), and the config check reports it as `RELEASE_CONFIG_UPDATER_PUBLIC_KEY`.
603
+ - `distribution.updaterKeyId` defaults to the contract's `updaterSigningKeyId` and is always enforced: the signature's key id must equal it, at seal and at publish. It must be a key the contract lists whose `notAfter` has not passed; any other id refuses with `PUBLICATION_SIGNATURE_KEY_MISMATCH` (the config check reports a malformed or unlisted id as `RELEASE_CONFIG_UPDATER_KEY_ID`). Name it only to publish with a listed key other than the signing key.
604
+ - An app outside the suite (Ember has its own updater key) keeps declaring `distribution.updaterPublicKey`; without one a sealed publish or seal fails closed (`PUBLICATION_SIGNATURE_UNVERIFIABLE`). Its `updaterKeyId` stays optional, and when set it is pinned at seal as well as at publish.
605
+ - `sign-updater --key-id <16 upper-case hex digits>` reads each produced `.sig` back and refuses a signature made by any other key, naming the file, the expected id and the found id, so a credential store holding the wrong key fails at signing rather than at seal. `--dry-run` prints the expected id and signs nothing. Without `--key-id` the signature is not read back.
606
+
607
+ Rotation order: (1) add the new key to `updaterPublicKeys` in the contract and regenerate the projections; (2) ship a release signed by the current key (`updaterSigningKeyId` unchanged), so the installed apps now know both keys; (3) change `updaterSigningKeyId` to the new key id and sign with the new key (`sign-updater --key-id <new id>`). Give the old key a `notAfter` to stop accepting it. The full runbook is `docs/SUITE-TRUST-ROOTS.md`.
608
+
530
609
  NSIS, MSI, `.app.tar.gz` and AppImage bundles all work the same way (any file signed by `sign-updater`). With no sealed installer the installer platform-trust check is skipped (reported in the output); installer-bearing seals still run it. The app side: `plugins.updater.endpoints` is the `latest.json` URL above and `plugins.updater.pubkey` is the suite updater public key (see `@rightkit/app-update` README).
531
610
 
532
611
  ## Android (AAB build, Play internal track, dev install)
@@ -599,7 +678,9 @@ Any app that ships more than the ffmpeg sidecar (MailRight: `mailright-engine`,
599
678
  - **`prebuilt` (#490):** verifies a host-built binary already staged at `<stageDir>/<name>-<triple>` (or copied from `from`) against an app manifest, never building or downloading: `manifest: { file, entry: "binaries.{platform}", sha256?: "sha256", approvalSha256?: "approval_sha256", approvalAlgorithm?: "approval_hash_algorithm" }` (`{platform}` is `macos-aarch64`, `windows-x86_64`, ...). The raw sha256 must match; when an approval hash is named it must match too, with `raw-sha256` or `macho-codesign-strip-linkedit-v1` (`macho-approval-hash.mjs`: the sha256 of a thin Mach-O with its code signature removed and `__LINKEDIT` normalised, so a signed and an unsigned copy of one build agree; macOS only, `codesign --remove-signature` on a private copy). A missing file, entry or digest fails closed. With `from: { aarch64: "<path>", x86_64: "<path>" }` the entry also works on `universal-apple-darwin` (#692): each arch is verified against its own manifest entry (`macos-aarch64`, `macos-x86_64`; the approval algorithm default is the macOS one), then both are lipo-merged by the same helper `ffmpeg`, `onnxruntime` and `cmake` use into `<name>-universal-apple-darwin` (both archs required, `STAGE_SIDECAR_NOT_UNIVERSAL` otherwise; nothing is merged after a failed slice). With `from` omitted the inputs are the staged `<name>-aarch64-apple-darwin` and `<name>-x86_64-apple-darwin`; a plain string `from` is refused for universal. On a thin target an object `from` picks the entry for that arch. The row carries `platform: "macos-universal"` and per-arch `slices`.
600
679
  - **`remote` archives (#491):** `extract: "<member file name>"` (with `memberSha256?`, `executable?`) unpacks one member of a zip or tgz after the archive digest passes, so a Windows zip needs no `command` entry. A bare name must be the base name of exactly one member; a name with `/` (`bin/x64-win/DirectML.dll`) must equal exactly one member path (case-sensitive), for archives that repeat a base name in several directories. Absolute, drive-prefixed, backslash, empty, `.`/`..` segment and glob-character paths are refused, and the output stays at `dest`.
601
680
  - **`ffmpeg` mixed variants, fetch and probes (#491):** `variants: { ffmpeg: "export-ass" }` lets one entry take the export-ass ffmpeg with the decode ffprobe (a tool not named uses `variant`). `sources: { ffmpeg: { url, sha256, member }, ffprobe: {...} }` downloads the bytes the catalog only pins: the archive is digest-checked, the member is unpacked and its sha256 must equal the APPROVED catalog row's content digest, so a swapped archive can never stage. A source may instead be a local path (#692): `sources: { ffmpeg: { path: "../vendor/ffmpeg-lgpl", member?: "ffmpeg" } }` (also per arch: `{ aarch64: { path }, x86_64: { path } }`). `path` is relative to the app root and may step out of it; it names the binary itself or a directory holding `<tool>[.exe]` or `bin/<tool>[.exe]` (`member` renames the file). Nothing is downloaded and there is no archive digest, but the file must still equal the APPROVED catalog row content digest (`STAGE_SIDECAR_HASH_MISMATCH` otherwise); notices, probe and universal lipo are unchanged; a missing file is `STAGE_SIDECAR_BINARY_NOT_FOUND`. `probe: true` runs `-version` and `-encoders` on a staged binary this host can run: the configuration must say `--disable-gpl` and `--disable-nonfree` and must not enable GPL, nonfree, libx264, libx265, libxvid or libfdk-aac, and ffmpeg may not list those encoders (`STAGE_SIDECAR_FFMPEG_PROBE_FAILED`; another architecture or OS is recorded as `skipped-cross-target`). The catalog has no Windows `export-ass` row yet (only `export-ass-zimg`); that row belongs to the rightkit-ffmpeg crate.
602
- - **`universal-apple-darwin` for `ffmpeg` and `onnxruntime` (#492):** each arch row is staged thin under `.right-release/slices/<triple>/` and the files are lipo-merged into `<stageDir>/<tool>-universal-apple-darwin` (onnxruntime: into `resourceDir`), requiring both archs and every row approved (onnxruntime also accepts `pinned`). For `ffmpeg`, `{arch}`, `{triple}` and `{stageDir}` in the `stage` command's arguments name the slice (it runs once per slice), `sources` need the per-arch form `{ ffmpeg: { aarch64: {...}, x86_64: {...} } }`, one source offer lists both slices, and the receipt rows carry `slices`. A merged file identical to the staged one is not rewritten.
681
+ - **`native-library` (plan item 2.8):** `{ kind: "native-library", library: "onnxruntime" | "directml" | "pdfium" | "whisper-cpp" | "ggml" | "sherpa-onnx", version, catalog, resourceDir?, licenseDir?, cacheDir?, as?, files?, companions?, source?: { dir }, platforms? }` stages the `approved` or `pinned` rows of `native-libraries.json` for the target (every serving row, or the `files` named) into `resourceDir`, with their notices and a receipt row per file. The bytes come from the row's digest-pinned archive or, for a row with no archive (the macOS whisper.cpp, ggml and sherpa-onnx builds), from `source: { dir }`; an archive file must match the whole-file sha256 and the content digest, a `source.dir` file the content digest (a re-signed copy passes, changed code does not, and a thin row refuses a universal Mach-O), and the staged bytes are checked again after the copy. A row's `companions` (the Windows onnxruntime row names DirectML) are staged beside it with a `companionOf` receipt row; `companions: false` opts out, and a file another entry also writes into `resourceDir` is refused before anything is written. A row with `hostProof: "never-run"` stages with a warning (stderr on the CLI) and carries the marker into the receipt. `kind: "onnxruntime"` stays the alias of `native-library` with `library: "onnxruntime"`. See `docs/STAGE-SIDECAR.md`.
682
+ - **`ffmpeg` `resourceDir`:** `{ kind: "ffmpeg", resourceDir: "src-tauri/resources/runtime", ... }` stages `<resourceDir>/ffmpeg[.exe]` and `ffprobe[.exe]` (no target triple, not an externalBin; an already-staged file that matches its row is made executable) from the same approved rows, notices, `sources`, probe and universal lipo merge as the externalBin layout; `bundleDir` and `resourceDir` cannot be combined.
683
+ - **`universal-apple-darwin` for `ffmpeg`, `onnxruntime` and `native-library` (#492):** each arch row is staged thin under `.right-release/slices/<triple>/` and the files are lipo-merged into `<stageDir>/<tool>-universal-apple-darwin` (onnxruntime, native-library and an ffmpeg `resourceDir` entry: into `resourceDir`), requiring both archs and every row approved (onnxruntime and native-library also accept `pinned`). A macOS `universal` native-library row is staged once and checked with `lipo -archs`, with no merge. For `ffmpeg`, `{arch}`, `{triple}` and `{stageDir}` in the `stage` command's arguments name the slice (it runs once per slice), `sources` need the per-arch form `{ ffmpeg: { aarch64: {...}, x86_64: {...} } }`, one source offer lists both slices, and the receipt rows carry `slices`. A merged file identical to the staged one is not rewritten.
603
684
 
604
685
  ## Declared Rust audit, dist-scan and the Windows Swift toolchain
605
686
 
@@ -630,6 +711,38 @@ No agent may write to the internal drive (C: on Windows, the internal disk on ma
630
711
 
631
712
  `outcome` is `allowed` (file created and removed), `blocked` (`EACCES`, `EPERM` or `EROFS`) or `error` (directory missing or another failure; `code` says which). `code` is `null` when allowed, except `UNLINK_<code>` when the probe file could not be removed. `internalWritable` is true when profile, local-temp or os-temp is `allowed`.
632
713
 
714
+ ## Disk hygiene on the agent volume (`disk check`, `sweep`, `run`, `run-root`, `schedule`)
715
+
716
+ One job keeps the agent volume (`/Volumes/D` on macOS, `D:\` on Windows) from filling. `right-release disk schedule install` installs it: an hourly launchd job (`StartInterval` 3600) on macOS, one hourly repetition trigger in a hidden Task Scheduler entry on Windows. Both run `right-release disk run --auto` from a stable per-user copy of the CLI. `right-release disk schedule status` exits 1 when the binding is broken, the job is not loaded, or no full pass finished in the last 26 hours.
717
+
718
+ - **Full pass** (`disk run`, or `--auto` once a day, on the first run after 03:30 local time): the self-check, `disk check`, the sweep (stale idle data moves to holding), the purge of expired holding folders, and the warnings. Once per UTC day it also appends one `disk.report` event. Logged as `disk.run.ok`, or `disk.floor.warn` when a floor warns. Every run event (`disk.run.ok`, `disk.floor.warn`, `disk.run.light`) carries the agent volume's `freeBytes` and its `floorId`; with the agent volume unmounted both say so (`freeBytes` null) instead of reporting the first floor, the Mac internal drive. Before the move, each candidate is checked once more for a `.keep` marker, a live lease and fresh content: one that changed since the plan stays (`disk.sweep.skip`, reasons `rechecked-keep`, `rechecked-leased`, `rechecked-fresh`). Two items with one name from different parents land as `name`, `name.1`, `name.2`, and the move line names the source parent.
719
+ - **Light pass** (`disk run --light`, and `--auto` on every other hour): stat-only. It reads the free space of the floors and sweeps the writers marked `light` and the scratch roots. It sizes no writer, walks no workspace, looks for no stray target and purges nothing. Logged as `disk.run.light`. `--auto` decides from the newest full-pass line of `disk-hygiene.jsonl`, found by reading the log backwards in growing windows (4, 16, 64 MiB, then up to 256 MiB): a busy day's sweep lines never hide it. A missing log means the job never ran (full pass). A log that exists but cannot be read (EIO, ENOTDIR, a volume that went away) keeps the run light and is said so; `disk schedule status` prints the read error instead of "no completed full run".
720
+ - **Emergency**: a floor flagged `agentVolume` that warns (on macOS, `/Volumes/D` below its 250 GiB soft floor) purges the holding folders on that volume at once, with no undo window (`disk.run.emergency`), in the full pass and the light pass alike. Holding on another volume (C: while D: is squeezed) keeps its undo window, and a warning from any other floor or writer never purges. It acts only once the operator has recorded a calibration (below); an uncalibrated emergency purges nothing, logs `disk.emergency.skipped` (`reason` `uncalibrated`, `calibration-stale` or `log-unreadable`), prints `EMERGENCY SKIPPED (<reason>)` and the run goes on as an ordinary one (normal holding window, normal sweep).
721
+
722
+ **Before the first install: calibrate.** The floors in `disk-policy.json` are proposals until someone has compared them with the real volume, and the emergency purge has no undo window, so it stays off until you do. Run `right-release disk check --json` and compare each floor's `freeBytes` with its `thresholdBytes`, then `right-release disk check --calibrate`. When no `agentVolume` floor is warning, that appends one `disk.calibrated` event (every floor with `id`, `label`, `path`, `thresholdBytes`, `freeBytes`, `agentVolume`) to `disk-hygiene.jsonl` and exits 0. When one is warning it records nothing, names the floor and the shortfall, and exits 1: lower the floor in `disk-policy.json` or free space first, so that running the volume low is a decision, never an accident. An emergency is eligible only when the log holds a calibration that lists every `agentVolume` floor of the current policy with the same `thresholdBytes` (changing a threshold, or the volume's size under a percentage floor, makes it stale; calibrate again). A log that cannot be read counts as uncalibrated. `disk schedule install` repeats this step, and `disk schedule status` prints `calibration: recorded <age> ago` or `calibration: NOT RECORDED (emergency purge disabled)`.
723
+
724
+ `right-release disk check [--json]` lists each writer's size against its cap and each floor's free space (exit 3 on a warning). It also lists the unmanaged remainder of each agent volume, the top-level entries no writer covers, and the stray cargo targets (a `target` or `.cache/cargo-target` directory tagged with `CACHEDIR.TAG` outside RustRight's roots, up to depth 4 under `claude` and `scratch`). `right-release disk sweep [--dry-run] [--days N] [--delete]` moves what is due; `--delete` removes instead of moving, and `disk purge [--dry-run] [--days N]` removes expired holding folders on demand.
725
+
726
+ Lifetimes, from `packages/release/disk-policy.json`:
727
+
728
+ | Data | Idle or held for | Policy key |
729
+ | --- | --- | --- |
730
+ | Scratch child (`/Volumes/D/scratch/<name>`) | 1 day | `scratchStaleDays` |
731
+ | Stray cargo target | 1 day, and never while git tracks something in it | `strayTargetDays` |
732
+ | Holding folder `.rightkit-swept/YYYY-MM-DD` | the date plus 1 day has passed, and nothing in it was written in the last day | `holdingDays` |
733
+ | Sweep writers (caches, temp, DerivedData) | 7 days, or the writer's own `staleDays` | `staleDays` |
734
+ | Unknown top-level entry of a workspace root | 3 days; reported only, unless the host ran `disk allow-workspace-sweep` | `unknownStaleDays` |
735
+ | Directory holding a `.keep` file | 14 days after the marker's last write; a symlink named `.keep` counts for nothing | `keepMarkerDays` |
736
+ | Run root with a live lease | until `leaseUntil` | `disk run-root --days` (default 3, at most 30) |
737
+
738
+ A holding folder is a date-named directory under `.rightkit-swept` at the root of the item's own volume. Items from the Mac internal disk park in `/Volumes/D/.rightkit-swept` while that volume is mounted. The sweep moves a unit only when nothing in it is fresher than its window, no `.keep` marker or live lease protects it, and no process has it open.
739
+
740
+ **Run roots.** `right-release disk run-root <name> [--days N] [--json]` (N is positive and at most 30) leases `<scratch>/<name>` (`/Volumes/D/scratch/<name>` or `D:\scratch\<name>`): it creates the directory and writes `.rightkit-run.json` (`{"schema":1,"name","createdAt","leaseUntil"}`). Renewing keeps `createdAt` while the lease still fits 30 days from it, otherwise `createdAt` starts over. A lease counts only in the directory it names, may span at most 30 days (plus one day) from `createdAt`, and must not be created in the future; the guard applies the same rules, and refuses any write to a file named `.rightkit-run.json`, so only this command makes one. `right-release disk run-root --release <name>` removes the lease, and the directory then ages out by the scratch rules. Creation is refused, with nothing written, while the scratch root is over its `scratch-total` cap or the owner prefix (the part of the name before its first `-`) is over its `scratchPrefixCaps` entry (`legion` 40 GiB, `rk` 30 GiB, `pulse` 20 GiB, `hr` 20 GiB). The build-control heavy-write guard admits `git clone`, `git worktree add` and curl, wget and `hf`/`huggingface-cli download` outputs only below a live lease (see `packages/build-control-policy/README.md`); the guard reads this file format and never writes it.
741
+
742
+ **Build budget.** A writer whose `budget` names parts (`sccache` for the compiler-cache rows, `total` for `build-control-targets`) takes its cap from RustRight's `buildBudget`, read from `rustright doctor --json --no-scan` (20 s timeout, no shell, nothing cached). The writer reports `capSource: "rustright"` when it does. Without RustRight the writer keeps its policy `capBytes` and reports `capSource: "policy"`. The byte caps of the rows this change adds are proposals pending the host inventory.
743
+
744
+ **Daily report.** The `disk.report` event in `disk-hygiene.jsonl` (`schema` 1) carries free space and floors, bytes by category, growth since the previous report, the largest units and the growers, over-cap writers, unmanaged bytes, uncovered top-level entries, scratch bytes by owner and a steady-state estimate. Its field names are frozen in `disk-report.test.mjs`, because Pulse's Storage card reads them. The report describes the agent volume only: when it cannot be read, `freeBytes`, `usedBytes`, `totalBytes`, `categories`, `unmanagedBytes`, `overlapBytes` and `delta` are null (steady state zero), and so is the `delta` of the next report, which has no agent-volume baseline. `du` sums allocated bytes per writer, so APFS clones can make the writers add up to more than the used bytes: `overlapBytes` is that excess, `unmanaged` is then 0, nothing is scaled, and the identity is `sum(categories) - overlapBytes = usedBytes` (for a delta, `sum(delta.categories) - delta.overlapBytes = delta.usedBytes`). It does not yet count refusals or summarise the broker's GC passes.
745
+
633
746
  ## RightKit pins are proven against the registries (`rightkit-registry-resolver.mjs`)
634
747
 
635
748
  At build admission the controller resolves the app's EXACT locked RightKit versions (every `Cargo.lock` entry and `pnpm-lock.yaml` resolution of a RightKit-owned package) against crates.io (index `cksum` + `yanked`) and npm (`dist.integrity` + `deprecated`). A new version of a known RightKit package needs no new release of this tool. The claim is scoped to KNOWN names: ownership comes from `rightkit-versions.json` and `rightkit-ownership.json`, a name prefix alone is refused, and there is no maintainer-membership proof, so a package first published after this tool still needs an ownership update. Only canonical registry sources count (no path, git, tarball or directory); the recorded minimum and `revoked` policy still apply; pins are never rewritten.
@@ -728,7 +841,7 @@ files, base Tauri config content & app-specific default-feature restrictions rem
728
841
 
729
842
  ## Template rename, PE digest, icon packing
730
843
 
731
- `right-release init --name <Product Name> --id <reverse.dns.bundle.id> [--slug <lowercase>] [--dir <root>] [--dry-run]` renames the desktop app template identity in place (see `init-command.mjs`); it runs no gate or build. Besides the text files it renames `Cargo.lock` and re-sorts it the way cargo writes it (so `cargo --locked` accepts the renamed lock), recomputes the digests in `legal/legal-manifest.json` for the documents the rename changed (a changed material document means `legal-snapshot` must run again), keeps the `r2-rightapps` distribution service name, and prints the steps only a person can do: `pnpm install`, the doctor, the legal host and text, and registering the new repository on each host (`ownership-repositories.json` and its byte copy under `packages/build-control-policy`, then `right-release ownership trust-refresh` on the Mac and the Dell, which needs a person with administrator rights).
844
+ `right-release init --name <Product Name> --id <reverse.dns.bundle.id> [--slug <lowercase>] [--dir <root>] [--dry-run]` renames the desktop app template identity in place (see `init-command.mjs`); it runs no gate or build. Besides the text files it renames `Cargo.lock` and re-sorts it the way cargo writes it (so `cargo --locked` accepts the renamed lock), carries the renamed lock's new digest into each `<lock> sha256 <hex>` / `sha256=<hex>` field of a notice `notice-lock-digest` (CA-14) judges whose lock resolves to it (the same text in reports, evidence, archives or source is left alone), recomputes the digests in `legal/legal-manifest.json` for the documents the rename changed (a changed material document means `legal-snapshot` must run again), keeps the `r2-rightapps` distribution service name, and prints the steps only a person can do: `pnpm install`, the doctor, the legal host and text, and registering the new repository on each host (`ownership-repositories.json` and its byte copy under `packages/build-control-policy`, then `right-release ownership trust-refresh` on the Mac and the Dell, which needs a person with administrator rights).
732
845
 
733
846
  The release config is `right-release.config.json` (M24, `release-config.mjs`): every command that reads one defaults to it when it sits beside the working directory, loads a `.json` path through the strict loader, and still imports a legacy `right-release.config.mjs` for an app that has not moved. `{{localAppData}}` in a JSON config is `%LOCALAPPDATA%` on Windows and `<home>/AppData/Local` elsewhere.
734
847
 
@@ -2,7 +2,7 @@
2
2
  // Each row is the predicate of a refusal the build used to raise later, after the lock, the install or the compile; the
3
3
  // message is the build's own, so the doctor, the build and the worker say the same thing. A row names where its twin lives.
4
4
  import path from "node:path";
5
- import { assertGitHubRepository } from "./github-updater-release.mjs";
5
+ import { assertGitHubRepository, isSuiteUpdaterApp } from "./github-updater-release.mjs";
6
6
  import { validateAndroidTarget } from "./android-release.mjs";
7
7
  import { assertNsisInPlaceUpgradeContract } from "./nsis-upgrade-contract.mjs";
8
8
  import { dirtyBuildInputs } from "./release-invocation.mjs";
@@ -132,7 +132,8 @@ export const FILES_ROWS = Object.freeze([
132
132
  const minisign = updaters.length > 0 && target.updater?.kind !== "appinstaller";
133
133
  if (minisign) {
134
134
  const publicKey = config.distribution?.updaterPublicKey ?? config.distribution?.updaterPubkey ?? target.updater?.pubkey ?? target.updater?.publicKey;
135
- if (typeof publicKey !== "string" || !publicKey.trim()) found.push("updater public key is required to verify updater artifacts before sealing");
135
+ // A Right Suite app's updater key is the Right Suite contract's (resolveUpdaterTrust); only another app declares its own.
136
+ if (!isSuiteUpdaterApp(config.app) && (typeof publicKey !== "string" || !publicKey.trim())) found.push("updater public key is required to verify updater artifacts before sealing");
136
137
  for (const entry of updaters) {
137
138
  if (typeof entry?.signature !== "string" || !entry.signature) found.push(`updater artifact ${entry?.file} must declare the signature file sign-updater writes next to it`);
138
139
  }
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://github.com/rustsec/advisory-db",
3
+ "commit": "21d909e4c8bd4e14c7aea58bf980c881a0053ab1"
4
+ }
@@ -0,0 +1,124 @@
1
+ // The pinned RustSec advisory database for the class A cargo-deny and cargo-audit scans.
2
+ //
3
+ // Class A is deterministic and offline for advisories: the same commit gives the same verdict until the pin in
4
+ // advisory-db.json moves. Neither tool fetches at run time; both read the checkout this module materialises at
5
+ // <cacheRoot>/advisory-db/<commit>. Moving the pin is a reviewed commit (and re-runs the scans: the pin is part of the
6
+ // scan's command environment, hence of its cache key). Fetching the pinned commit is the only network step, once per
7
+ // commit per machine; a machine that cannot fetch and has no cache refuses, it never falls back to the tools' own fetch.
8
+ import { spawnSync } from "./background-process.mjs";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const PIN_FILE = fileURLToPath(new URL("./advisory-db.json", import.meta.url));
15
+ const COMMIT = /^[0-9a-f]{40}$/;
16
+
17
+ /** The pin ({ url, commit }) from advisory-db.json; throws when it is missing or malformed. */
18
+ export function readAdvisoryPin({ file = PIN_FILE, readFile = (f) => fs.readFileSync(f, "utf8") } = {}) {
19
+ let pin;
20
+ try { pin = JSON.parse(readFile(file)); } catch (e) { throw new Error(`advisory database pin ${file} is unreadable (${e.message}); class A advisory scans refuse to run without it`); }
21
+ return checkPin(pin);
22
+ }
23
+
24
+ function checkPin(pin) {
25
+ if (!pin || typeof pin.url !== "string" || !/^https:\/\/\S+$/.test(pin.url) || typeof pin.commit !== "string" || !COMMIT.test(pin.commit)) {
26
+ throw new Error('advisory database pin must be { "url": "https://...", "commit": "<40 lowercase hex>" }');
27
+ }
28
+ return { url: pin.url, commit: pin.commit };
29
+ }
30
+
31
+ /** Where advisory databases are cached: RK_ADVISORY_CACHE_ROOT, else %LOCALAPPDATA%\RightKit (Windows) or ~/.cache/rightkit (never XDG_CACHE_HOME, see cache-policy.mjs). */
32
+ export function advisoryCacheRoot({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
33
+ const override = env.RK_ADVISORY_CACHE_ROOT;
34
+ if (override) {
35
+ if (!path.isAbsolute(override)) throw new Error("RK_ADVISORY_CACHE_ROOT must be an absolute path");
36
+ return path.resolve(override);
37
+ }
38
+ if (platform === "win32" && env.LOCALAPPDATA && path.win32.isAbsolute(env.LOCALAPPDATA)) return path.win32.join(env.LOCALAPPDATA, "RightKit");
39
+ return path.join(home, ".cache", "rightkit");
40
+ }
41
+
42
+ const defaultGit = (args, { cwd } = {}) => {
43
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true, shell: false, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
44
+ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", error: r.error };
45
+ };
46
+
47
+ /**
48
+ * Materialise pin.commit at <cacheRoot>/advisory-db/<commit> and return that path. A present checkout is reused only when its
49
+ * `git rev-parse HEAD` equals the pin; otherwise it is built in a sibling temp directory (git init, fetch --depth 1 origin
50
+ * <commit>, checkout FETCH_HEAD, verify HEAD) and renamed into place. Throws with the reason when it cannot.
51
+ * `git(args, { cwd })` => { status, stdout, stderr, error? } is injectable.
52
+ */
53
+ export function ensureAdvisoryDb({ cacheRoot, pin, git = defaultGit, fsImpl = fs } = {}) {
54
+ if (typeof cacheRoot !== "string" || !path.isAbsolute(cacheRoot)) throw new Error("advisory database: cacheRoot must be an absolute path");
55
+ const { url, commit } = checkPin(pin);
56
+ const base = path.join(cacheRoot, "advisory-db");
57
+ const dest = path.join(base, commit);
58
+ const head = (dir) => { const r = git(["rev-parse", "HEAD"], { cwd: dir }); return !r.error && r.status === 0 ? String(r.stdout).trim() : null; };
59
+ if (fsImpl.existsSync(dest)) {
60
+ if (head(dest) === commit) return dest;
61
+ // A stale or damaged directory under the pinned name is never trusted; replace it.
62
+ }
63
+ const fail = (step, r) => new Error(`advisory database ${commit} is not available: git ${step} failed (${r.error?.message ?? (String(r.stderr).trim() || `exit ${r.status}`)}); class A refuses to scan advisories without the pinned database. Connect once to fetch ${url} or set RK_ADVISORY_CACHE_ROOT to a populated cache.`);
64
+ fsImpl.mkdirSync(base, { recursive: true });
65
+ const tmp = fsImpl.mkdtempSync(path.join(base, `.tmp-${commit.slice(0, 12)}-`));
66
+ try {
67
+ for (const [step, args] of [["init", ["init", "-q"]], ["remote add", ["remote", "add", "origin", url]], ["fetch", ["fetch", "-q", "--depth", "1", "origin", commit]], ["checkout", ["checkout", "-q", "FETCH_HEAD"]]]) {
68
+ const r = git(args, { cwd: tmp });
69
+ if (r.error || r.status !== 0) throw fail(step, r);
70
+ }
71
+ const got = head(tmp);
72
+ if (got !== commit) throw new Error(`advisory database fetch returned ${got ?? "no commit"}, not the pinned ${commit}`);
73
+ if (fsImpl.existsSync(dest)) fsImpl.rmSync(dest, { recursive: true, force: true });
74
+ try { fsImpl.renameSync(tmp, dest); } catch (e) {
75
+ // A concurrent run may have installed the same commit first: accept it when it verifies.
76
+ if (!(fsImpl.existsSync(dest) && head(dest) === commit)) throw e;
77
+ }
78
+ return dest;
79
+ } finally {
80
+ fsImpl.rmSync(tmp, { recursive: true, force: true });
81
+ }
82
+ }
83
+
84
+ // cargo-deny keeps each database in <db-path>/<name derived from the url>; this is the name it derives for the RustSec url
85
+ // (cargo-deny 0.20). A different url, or a tool that names it differently, fails closed: the tool runs --offline and finds no database.
86
+ const DENY_DB_DIRS = Object.freeze({ "https://github.com/rustsec/advisory-db": "advisory-db-3157b0e258782691" });
87
+
88
+ /**
89
+ * cargo-deny has no command-line switch for the database location, only `[advisories] db-path` in its config. This writes an
90
+ * overlay config (the repository's deny.toml with any db-path replaced) and a db-path directory whose entry for the pin url is the
91
+ * pinned checkout, in a fresh temp directory. `--offline --config <overlay>` then reads exactly the pin and cannot fetch.
92
+ * Returns { configPath, cleanup }.
93
+ */
94
+ export function prepareDenyOverlay({ denyToml, dbDir, pin, tmpRoot = os.tmpdir(), fsImpl = fs } = {}) {
95
+ const { url } = checkPin(pin);
96
+ const name = DENY_DB_DIRS[url];
97
+ if (!name) throw new Error(`cargo-deny database directory name for ${url} is not known; add it to DENY_DB_DIRS in advisory-db.mjs`);
98
+ const dir = fsImpl.mkdtempSync(path.join(tmpRoot, "rk-deny-"));
99
+ try {
100
+ const dbPath = path.join(dir, "dbs");
101
+ fsImpl.mkdirSync(dbPath);
102
+ fsImpl.symlinkSync(dbDir, path.join(dbPath, name), process.platform === "win32" ? "junction" : "dir");
103
+ const lines = String(denyToml ?? "").split(/\r?\n/);
104
+ const out = []; let inAdvisories = false; let placed = false;
105
+ for (const line of lines) {
106
+ const header = /^\s*\[([^\]]*)\]\s*(#.*)?$/.exec(line);
107
+ if (header) {
108
+ inAdvisories = header[1].trim() === "advisories";
109
+ out.push(line);
110
+ if (inAdvisories) { out.push(`db-path = ${JSON.stringify(dbPath)}`); placed = true; }
111
+ continue;
112
+ }
113
+ if (inAdvisories && /^\s*db-path\s*=/.test(line)) continue;
114
+ out.push(line);
115
+ }
116
+ if (!placed) out.push("", "[advisories]", `db-path = ${JSON.stringify(dbPath)}`);
117
+ const configPath = path.join(dir, "deny.toml");
118
+ fsImpl.writeFileSync(configPath, `${out.join("\n")}\n`);
119
+ return { configPath, cleanup: () => fsImpl.rmSync(dir, { recursive: true, force: true }) };
120
+ } catch (e) {
121
+ fsImpl.rmSync(dir, { recursive: true, force: true });
122
+ throw e;
123
+ }
124
+ }
@@ -108,6 +108,9 @@ export function parseToml(text) {
108
108
  if (raw === 'true') return true;
109
109
  if (raw === 'false') return false;
110
110
  if (/^[+-]?\d[\d_]*(\.\d+)?$/.test(raw)) return Number(raw.replace(/_/g, ''));
111
+ if (/^0x[0-9a-fA-F_]+$/.test(raw)) return parseInt(raw.slice(2).replace(/_/g, ''), 16);
112
+ if (/^0o[0-7_]+$/.test(raw)) return parseInt(raw.slice(2).replace(/_/g, ''), 8);
113
+ if (/^0b[01_]+$/.test(raw)) return parseInt(raw.slice(2).replace(/_/g, ''), 2);
111
114
  return fail(`unsupported value ${raw}`);
112
115
  };
113
116
  for (;;) {
@@ -224,14 +227,14 @@ function dateProblem(entryLabel, value, now) {
224
227
  return null;
225
228
  }
226
229
 
227
- export function checkAdvisoryExceptions({ root = process.cwd(), now = new Date(), readFile, allowPending = false } = {}) {
230
+ export function checkAdvisoryExceptions({ root = process.cwd(), now = new Date(), readFile, allowPending = false, exceptionsFile = EXCEPTIONS_FILE } = {}) {
228
231
  const read = readFile ?? (p => fs.readFileSync(path.join(root, p), 'utf8'));
229
232
  const problems = [];
230
233
  const load = (file, parse) => {
231
234
  try { return parse(read(file)); } catch (err) { problems.push(`${file}: cannot read or parse (${err.message})`); return null; }
232
235
  };
233
236
 
234
- const doc = load(EXCEPTIONS_FILE, JSON.parse);
237
+ const doc = load(exceptionsFile, JSON.parse);
235
238
  const deny = load(DENY_FILE, parseToml);
236
239
  const audit = load(AUDIT_FILE, parseToml);
237
240
  const lockPair = load(LOCK_FILE, t => ({ versions: lockedVersions(t), ...lockedGraph(t) }));
@@ -240,9 +243,9 @@ export function checkAdvisoryExceptions({ root = process.cwd(), now = new Date()
240
243
  const { graph } = lockPair;
241
244
  for (const u of lockPair.unresolved) problems.push(`${LOCK_FILE}: dependency ${u} does not resolve to exactly one locked package`);
242
245
 
243
- if (doc.schema !== 1) problems.push(`${EXCEPTIONS_FILE}: schema must be 1`);
246
+ if (doc.schema !== 1) problems.push(`${exceptionsFile}: schema must be 1`);
244
247
  if (!Array.isArray(doc.exceptions)) {
245
- problems.push(`${EXCEPTIONS_FILE}: exceptions must be an array`);
248
+ problems.push(`${exceptionsFile}: exceptions must be an array`);
246
249
  return problems;
247
250
  }
248
251
 
@@ -250,7 +253,7 @@ export function checkAdvisoryExceptions({ root = process.cwd(), now = new Date()
250
253
  const byId = new Map();
251
254
  const valid = [];
252
255
  doc.exceptions.forEach((e, idx) => {
253
- const label = `${EXCEPTIONS_FILE} entry ${e?.id ?? `#${idx}`}`;
256
+ const label = `${exceptionsFile} entry ${e?.id ?? `#${idx}`}`;
254
257
  if (!e || typeof e !== 'object' || Array.isArray(e)) { problems.push(`${label}: not an object`); return; }
255
258
  let ok = true;
256
259
  for (const f of REQUIRED) {
@@ -329,10 +332,10 @@ export function checkAdvisoryExceptions({ root = process.cwd(), now = new Date()
329
332
  if (seen.has(id)) problems.push(`${DENY_FILE}: duplicate ignore ${id}`);
330
333
  seen.add(id);
331
334
  const entry = byId.get(id);
332
- if (!entry) { problems.push(`${DENY_FILE}: ignore ${id} has no entry in ${EXCEPTIONS_FILE}`); continue; }
333
- if (reason !== entry.rationale) problems.push(`${DENY_FILE}: ignore ${id} reason differs from the rationale in ${EXCEPTIONS_FILE}`);
335
+ if (!entry) { problems.push(`${DENY_FILE}: ignore ${id} has no entry in ${exceptionsFile}`); continue; }
336
+ if (reason !== entry.rationale) problems.push(`${DENY_FILE}: ignore ${id} reason differs from the rationale in ${exceptionsFile}`);
334
337
  }
335
- for (const id of byId.keys()) if (!seen.has(id)) problems.push(`${EXCEPTIONS_FILE}: entry ${id} has no ignore in ${DENY_FILE}`);
338
+ for (const id of byId.keys()) if (!seen.has(id)) problems.push(`${exceptionsFile}: entry ${id} has no ignore in ${DENY_FILE}`);
336
339
  }
337
340
  if (adv) {
338
341
  if (adv.yanked !== 'deny') problems.push(`${DENY_FILE}: [advisories] yanked must be "deny"`);
@@ -348,9 +351,9 @@ export function checkAdvisoryExceptions({ root = process.cwd(), now = new Date()
348
351
  for (const id of auditIgnore) {
349
352
  if (set.has(id)) problems.push(`${AUDIT_FILE}: duplicate ignore ${id}`);
350
353
  set.add(id);
351
- if (!byId.has(id)) problems.push(`${AUDIT_FILE}: ignore ${id} has no entry in ${EXCEPTIONS_FILE}`);
354
+ if (!byId.has(id)) problems.push(`${AUDIT_FILE}: ignore ${id} has no entry in ${exceptionsFile}`);
352
355
  }
353
- for (const id of byId.keys()) if (!set.has(id)) problems.push(`${AUDIT_FILE}: entry ${id} from ${EXCEPTIONS_FILE} is not ignored here (sets differ)`);
356
+ for (const id of byId.keys()) if (!set.has(id)) problems.push(`${AUDIT_FILE}: entry ${id} from ${exceptionsFile} is not ignored here (sets differ)`);
354
357
  }
355
358
 
356
359
  return problems;