@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.
- package/README.md +122 -9
- package/admission-files.mjs +3 -2
- package/advisory-db.json +4 -0
- package/advisory-db.mjs +124 -0
- package/advisory-exceptions.mjs +13 -10
- package/app-check.mjs +257 -38
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-agent-hook.mjs +9 -0
- package/bin/rightkit-gate.mjs +495 -344
- package/build-release.mjs +13 -6
- package/cli/right-release.mjs +29 -6
- package/compile-inputs.mjs +66 -0
- package/consumer-contracts.json +1044 -0
- package/consumer-contracts.mjs +627 -0
- package/consumer-input-contracts.mjs +422 -0
- package/consumer-policy.mjs +588 -0
- package/consumer-source-contracts.mjs +469 -0
- package/deny-default.toml +42 -0
- package/deterministic-scans.mjs +240 -29
- package/disk-command.mjs +270 -30
- package/disk-policy.json +50 -10
- package/disk-policy.mjs +482 -35
- package/disk-report.mjs +129 -0
- package/door-policy.mjs +228 -0
- package/gate-executor.mjs +30 -0
- package/gate-stages.mjs +110 -0
- package/github-updater-release.mjs +48 -8
- package/init-command.mjs +41 -0
- package/legion-semantic-contracts.mjs +428 -0
- package/ownership-class-rules.mjs +277 -0
- package/ownership-key-authority.mjs +3 -1
- package/ownership-mandatory-set.mjs +3 -2
- package/package.json +10 -3
- package/publish-cargo.mjs +5 -2
- package/publish-update.mjs +14 -11
- package/release-config.mjs +51 -6
- package/rightkit-versions.json +172 -167
- package/shell-contract-bridge.json +251 -0
- package/shell-contract.mjs +1069 -0
- package/sign-updater.mjs +24 -4
- package/source-closure-rules.mjs +204 -0
- package/stage-sidecar-command.mjs +2 -1
- package/stage-sidecar.mjs +437 -114
- package/suite-api.generated.mjs +96 -6
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- **`
|
|
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
|
|
package/admission-files.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/advisory-db.json
ADDED
package/advisory-db.mjs
ADDED
|
@@ -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
|
+
}
|
package/advisory-exceptions.mjs
CHANGED
|
@@ -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(
|
|
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(`${
|
|
246
|
+
if (doc.schema !== 1) problems.push(`${exceptionsFile}: schema must be 1`);
|
|
244
247
|
if (!Array.isArray(doc.exceptions)) {
|
|
245
|
-
problems.push(`${
|
|
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 = `${
|
|
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 ${
|
|
333
|
-
if (reason !== entry.rationale) problems.push(`${DENY_FILE}: ignore ${id} reason differs from the rationale in ${
|
|
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(`${
|
|
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 ${
|
|
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 ${
|
|
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;
|