@rightkit/release 0.6.5 → 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 +196 -11
- 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/android-play-upload.mjs +4 -4
- package/app-check.mjs +702 -26
- package/apple-profile-create.mjs +4 -3
- package/apple-profile-renew.mjs +5 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-agent-hook.mjs +9 -0
- package/bin/rightkit-gate.mjs +671 -133
- package/binary-license-notices.mjs +2 -1
- package/build-release.mjs +20 -12
- package/cache-command.mjs +4 -3
- package/cargo-license-notices.mjs +2 -1
- package/cargo-manifest-text.mjs +25 -0
- package/cargo-profile-contract.mjs +26 -13
- package/cli/right-release.mjs +37 -7
- package/command-spec.mjs +4 -5
- 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/deps.mjs +5 -4
- package/deterministic-scans.mjs +513 -54
- package/development.mjs +5 -5
- 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-release.mjs +5 -3
- package/github-updater-release.mjs +48 -8
- package/github-upload-gate.mjs +6 -6
- package/init-command.mjs +175 -8
- package/ios-upload.mjs +4 -4
- package/legion-semantic-contracts.mjs +428 -0
- package/mirror-root-artifact.mjs +3 -4
- package/npm-license-notices.mjs +2 -1
- package/outdated-command.mjs +91 -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 +46 -3
- package/pipeline-admission.mjs +12 -0
- package/publish-cargo.mjs +219 -4
- package/publish-update.mjs +19 -14
- package/release-config.mjs +87 -11
- package/release-dependencies-command.mjs +4 -3
- package/release-qa-audit.mjs +12 -10
- package/release-qa-command.mjs +42 -7
- package/release-qa-hardening.mjs +10 -3
- package/release-qa-policy.mjs +214 -47
- package/release.mjs +4 -4
- package/rightkit-registry-resolver.mjs +5 -2
- package/rightkit-versions.json +178 -170
- 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 +7 -6
- package/stage-sidecar.mjs +437 -114
- package/suite-api.generated.mjs +96 -6
- package/suite-trust-roots.mjs +198 -0
- package/test-command.mjs +3 -2
- package/upload-release.mjs +3 -2
- package/web-build.mjs +2 -1
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).
|
|
@@ -86,10 +92,11 @@ On desktop (non-iOS) targets the source walk covers the whole app root, so app t
|
|
|
86
92
|
| Candidate signing | `sign-candidate --platform win\|mac --artifact <dir> --candidate-receipt <receipt.json> --out <binding.json> [--manifest <sign.json>] [--publisher <subject>] [--dry-run]`: signs the exact unsigned candidate bytes in place instead of rebuilding from source. The candidate receipt (`{ schema: 1, files: [{ path, sha256 }] }`) is checked first and any mismatch refuses; Windows signs through Azure Artifact Signing (`--publisher` pins the MSIX signer subject), macOS through Developer ID with fixed identifiers from `--manifest`. Writes a binding receipt (`rebuilt: false`, candidate sha -> signed sha per file). Never builds, notarizes or publishes |
|
|
87
93
|
| Dev artifact signing | `sign-dev-artifact --artifact <dir> --manifest <sign.json> [--metadata <payload.json>] [--evidence <path>] [--dry-run]`: Developer ID signs an existing CI dev artifact per file with fixed identifiers (macOS). It verifies the recorded hashes in the payload metadata (default `payload.json`, `{ schema: 1, files: [{ path, sha256 }] }`) first, signs each sign-manifest entry (`file` relative to the artifact, `identifier` required, nested code before its bundle) through the dev-signing identity (missing Developer ID: `DEV_MAC_SIGNING_DEVELOPER_ID_REQUIRED`, no ad hoc fallback), verifies with `codesign --verify --strict`, reads back Identifier and TeamIdentifier, refreshes the metadata hashes, and writes evidence JSON. Never builds, notarizes or publishes. The payload metadata must sit outside every signed entry (`SIGN_DEV_ARTIFACT_METADATA_IN_SIGNED_ENTRY`). An optional declarative `refresh` in the sign manifest recomputes digests that other JSON files record about signed bytes: `{ "refreshRoot": "<dir inside the artifact>", "refresh": [{ "file", "pointer" (JSON pointer to an existing string), "compute": "sha256-file" \| "sha256-tree-v1" \| "verify-sha256-file", "of", "format"?, "currentMatches"? }] }` (`file` and `of` relative to `refreshRoot`; `sha256-tree-v1` = sha256 over the sorted relative paths, each as path, NUL, 8-byte big-endian length, bytes; `verify-sha256-file` writes nothing and refuses a digest that changed; `format` embeds the digest in a locator string through one `{value}`; `currentMatches` refuses to overwrite a value of another kind, such as a signed attestation locator). Order is enforced: operations run after the entries they read are signed and before the first entry that contains a refreshed file, so that bundle (the wrapper) signs last over the final bytes; an operation may not read what a later one writes. JSON is rewritten in place with key order kept, and a file the writer would have to reformat is refused. Evidence gains a `refresh` list |
|
|
88
94
|
| Checks | `deps`, `hardening`, `harden`, `hardeningscan`, `cargo-target` |
|
|
95
|
+
| Update | `update check\|apply\|gate\|land\|discard\|run\|schedule [--repo <path>]`: runs the `right-update` bin of the `@rightkit/outdated` this package pins (an exact dependency), because pnpm links only an app's direct dependencies' bins and no app has `right-update` on its path. It reads `.rightkit/update.json`; `pnpm exec right-release update run` is what a stale-pin refusal tells an app to run. A missing updater exits 2 |
|
|
89
96
|
| Ownership | `ownership`, `ownership-approval`, `ownership-report`, `ownership-mandatory`, `adoption-evidence` |
|
|
90
97
|
| Test | `test` (the app's declared suites) |
|
|
91
98
|
| Legal | `license-notices npm`, `license-notices cargo`, `license-notices binaries` |
|
|
92
|
-
| 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` |
|
|
93
100
|
|
|
94
101
|
## The app check: deterministic first, no flag skips it
|
|
95
102
|
|
|
@@ -113,6 +120,79 @@ Each of those is listed with its reason in `UNCHECKED_COMMANDS` (`app-check.mjs`
|
|
|
113
120
|
neither list. `dmg` takes `--app`/`--out` paths, compiles nothing and has no repository; `addon` is checked only as `addon build` (it
|
|
114
121
|
runs the add-on's own build command), its `doctor`, `upload` and `adopt` forms compile nothing.
|
|
115
122
|
|
|
123
|
+
The rules (`right-release check --list` prints their ids and gaps; `scripts/preflight.mjs` adds RightKit's own):
|
|
124
|
+
|
|
125
|
+
| Rule | Gap | Finds |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `eol-policy` | G04 | a committed blob with CRLF where `.gitattributes` says `eol=lf` |
|
|
128
|
+
| `file-url` | G12 | the `.pathname` of a URL built from `import.meta.url` used as a path (wrong on Windows; use `fileURLToPath`) |
|
|
129
|
+
| `retired-alias` | P11 | an invocation of `rightkit`, the retired name of the RustRight client, outside history |
|
|
130
|
+
| `qa-platform` | G13 | a Rust `"platform:<x>"` requirement that is not `darwin` or `windows` (the test skips forever) |
|
|
131
|
+
| `test-harness` | G14 | a Cargo `[[test]]` whose file defines `fn main(` but lacks `harness = false` (libtest runs 0 tests) |
|
|
132
|
+
| `scenario-skip` | G15 | a `.scenario(` test that prints SKIP and returns (a skip that reports green) |
|
|
133
|
+
| `release-config` | G16 | `right-release.config.json`, read as data (or a legacy `right-release.config.mjs`): a provider `publish-update.mjs` refuses, an updater `keyFile`, an undefined Cargo profile; JSON that `right-release` cannot load is a finding |
|
|
134
|
+
| `skip-checks` | G17 | a `package.json` script that runs `right-release` with `--skip-checks` |
|
|
135
|
+
| `qa-dev-server` | G18 | a `package.json` `qa*` script that runs `tauri dev` (QA would test the dev server, not the app) |
|
|
136
|
+
| `ffmpeg-release-escape` | G19 | `allow_in_release_builds` in non-test Rust outside `crates/rightkit-ffmpeg` |
|
|
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`) |
|
|
138
|
+
| `speech-link` | G21 | `import Speech` under `swift/RightKitSwift/Sources` or `Tests` |
|
|
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.
|
|
180
|
+
|
|
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
|
|
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
|
|
183
|
+
a driver module of it (`harness`, `control`, `suite`, `scenario`, `engine`, `in_app`, `interactive`, `page`, `browser`, `android`); a name
|
|
184
|
+
like `*-e2e` is not evidence. A command selects it by `-p`/`--package`, `--manifest-path`, `--workspace`/`--all` (the members of the
|
|
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,
|
|
188
|
+
PowerShell, batch, Node and Python scripts, Makefile and justfile recipes, workflow `run:` steps, git hooks, and code in `README.md`,
|
|
189
|
+
`AGENTS.md`, `CLAUDE.md` and `docs/` (history documents excluded). `--no-run`, `rightkit-qa run-tests`, `rightkit-qa journeys run` and
|
|
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`.
|
|
195
|
+
|
|
116
196
|
Known findings are tolerated only through a baseline: `--baseline <file>`, else the checked tree's `.rightkit/app-check-baseline.json`,
|
|
117
197
|
else (from a RightKit checkout only) `ci/app-check-baseline.json`. The app's own file is always `{ "schema": 1, "known": [{ "id", "issue",
|
|
118
198
|
"added", "sunset" }] }`; the central `{ "known": { "<repo>": [...] } }` shape in an app's file is refused (exit 2). Every app entry needs
|
|
@@ -121,6 +201,54 @@ it. Past its sunset an entry stops tolerating its finding (the finding fails wit
|
|
|
121
201
|
`EXPIRED`; a listed identity that no finding has any more is `STALE` and fails, so the list only shrinks. A rule the build refuses
|
|
122
202
|
whatever a baseline says (`switches-inventory`) fails even when listed.
|
|
123
203
|
|
|
204
|
+
## The pre-push gate on a host without the broker: the Rust gate is deferred, the HOST RECEIPT gates the merge
|
|
205
|
+
|
|
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.
|
|
220
|
+
|
|
221
|
+
## The rustfmt scan reads each file's edition from its crate
|
|
222
|
+
|
|
223
|
+
The class A `rustfmt:<edition>` scans group the changed `.rs` files by the edition of the crate they belong to: the nearest `Cargo.toml` at or above the file decides. A literal `edition = "NNNN"` in its `[package]` table is that edition. `edition.workspace = true` (or `edition = { workspace = true }`) takes the `[workspace.package] edition` of the owning workspace root, the nearest `Cargo.toml` at or above the crate that declares `[workspace]` (the crate's own manifest, for a root package that is also the workspace). When the inheritance cannot be followed (no workspace root, a root with no literal `[workspace.package] edition`, a manifest in the chain that cannot be read, or an `edition` entry in a spelling the scan does not recognise such as `edition = 2021`) the gate fails with a `path-policy:*` finding that names the manifest and the reason, once per manifest, and those files are not checked under a guessed edition. Fix the manifest (declare `[workspace.package] edition`, or write the edition in the crate). A manifest that names no edition at all, and a `.rs` file below no manifest, are checked as 2021.
|
|
224
|
+
|
|
225
|
+
## The repository secret scan: reviewed records, not guesses
|
|
226
|
+
|
|
227
|
+
The class A `secret-scan` scan reads every file of each changed crate and package. Crate packaging, the package archive and the local-publish batch scan are strict: every `.env*` file, key file (`*.pem`, `*.p8`, `*.p12`, `*.key`) and env template is refused by name, and content that matches the credential shapes (`SECRET_PATTERNS`: a PEM private key header, `AKIA...`, `gh[pousr]_...`, `npm_...`) is refused. The repository gate (`scanCrateForSecrets(dir, { envTemplates: true, repoRoot })`) is the only mode that reads the repository's records, `.rightkit/secret-scan-records.json` of the gated commit:
|
|
228
|
+
|
|
229
|
+
```json
|
|
230
|
+
{ "schema": 1, "records": [
|
|
231
|
+
{ "path": ".env.example", "sha256": "<hex of the bytes, CRLF read as LF>", "kind": "env-template", "note": "local development defaults only, no credential" },
|
|
232
|
+
{ "path": "engine/src/redaction.rs", "sha256": "<hex>", "kind": "fixture", "note": "redaction test vectors, fake values" }
|
|
233
|
+
] }
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
| Finding | Admitted by | Never admitted |
|
|
237
|
+
|---|---|---|
|
|
238
|
+
| An env template (`.env.example`, `.env.<name>.example`, `.env.sample`, `.env.template`) | a record of kind `env-template` whose `sha256` is the file's | a template that is not UTF-8 (UTF-16 byte-order mark, NUL bytes, invalid sequence); one that holds a PEM private key header, `github_pat_...`, `sk_live_...`, `xox[abprs]-...`, `AIza...`, a Slack webhook URL or a `SECRET_PATTERNS` shape; one inside a publishable crate (its `[package]` does not say `publish = false`) or package (`package.json` not `"private": true`) |
|
|
239
|
+
| Any other file whose content matches `SECRET_PATTERNS` (a redaction or screening test vector) | a record of kind `fixture` whose `sha256` is the file's | |
|
|
240
|
+
| `.env`, `.env.local`, any other `.env.*` that is not a template, `*.pem`, `*.p8`, `*.p12`, `*.key` | nothing: refused by name, and a record naming one is itself a violation | any |
|
|
241
|
+
|
|
242
|
+
A violation reads `<path>[:<line>]: <reason>`. For a template or a fixture it carries the file's sha256 and the exact record to add, with a placeholder note: review the file, write the note (what it holds and why it is safe; the placeholder is refused), and commit the record. Any edit to a recorded file changes its sha256, so the file fails again until its record is replaced, and the change of the record is the review in the pull request diff. A violation names the line and the kind of shape a hit matched, never the matched text. A malformed records file is a violation: bad JSON, a schema other than 1, an unknown key or kind, a missing, empty or placeholder note, a `sha256` that is not 64 hex characters, a `path` that is not a normalised repository-relative path, a duplicate path, a kind that does not fit the path (`env-template` for a file that is not a template, `fixture` for a template). A record with a problem admits nothing, and the file must be a regular file inside the repository (a symbolic link is refused). Records are keyed by the repository path, so a moved file needs a new record, and the records file is scanned like any other file, so a note must not quote a credential.
|
|
243
|
+
|
|
244
|
+
## The npm scans the gate plans
|
|
245
|
+
|
|
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.
|
|
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
|
+
|
|
124
252
|
## Declared test suites, web build, npm notices and artifact mirroring
|
|
125
253
|
|
|
126
254
|
These replace per-app wrapper scripts. Each is declared in `right-release.config.mjs` and runs without a shell from the app
|
|
@@ -235,17 +363,24 @@ app in `notarize.app`. Declare the file in `updater.artifacts` as before. See `d
|
|
|
235
363
|
|
|
236
364
|
```json
|
|
237
365
|
{ "schema": 1,
|
|
238
|
-
"apps": {
|
|
239
|
-
|
|
366
|
+
"apps": {
|
|
367
|
+
"postright": { "mode": "refuse", "since": "2026-11-02", "set_by": "adrian" },
|
|
368
|
+
"genright": { "mode": "warn", "since": "2026-10-24", "set_by": "adrian",
|
|
369
|
+
"stages": { "source": { "mode": "refuse", "since": "2026-11-09", "set_by": "adrian" } } } },
|
|
370
|
+
"approvedRunners": [ { "sha256": "<64 hex>", "version": "0.9.0", "platform": "mac", "set_by": "adrian", "since": "2026-10-24" } ] }
|
|
240
371
|
```
|
|
241
372
|
|
|
373
|
+
**Per-stage modes.** An app entry is one mode for both stages, or carries `stages: { source?, installed? }` entries that override it for that stage (`source` is the build; `installed` is upload, publish-update and github). The `installed` stage cannot pass until the installed lane exists, so move an app to `refuse` for `source` first: `qa policy set --app <app> --stage source --mode refuse --set-by <name>`. Without `--stage` one mode governs both stages and any `stages` object is dropped. A stage that was `refuse` and is no longer (removed, or `warn`) needs `set_by: "adrian"`, checked per stage on write and on load.
|
|
374
|
+
|
|
242
375
|
- **`warn` (default; an app with no entry).** A missing or invalid receipt does not block. `build`, `upload`, `publish-update` and `github` print `[right-release] QA warning: ...` to stderr, continue, and record the outcome (below).
|
|
243
|
-
- **`refuse`.** The same refusal throws `QA admission refused for <app> (policy refuse since ... by ...)`; nothing is sealed, uploaded or published. Switch an app on with `
|
|
376
|
+
- **`refuse`.** The same refusal throws `QA admission refused for <app> (policy refuse since ... by ...)`; nothing is sealed, uploaded or published. Switch an app on with `right-release qa policy set --app <app> --mode refuse --set-by <name> [--stage source|installed]` (or `node release-qa-policy.mjs set ...`). Moving an app back to `warn` (or deleting its entry) is refused unless `set_by` is `adrian`; a hand edit that does so is caught on every load by comparing with the copy committed at `RIGHT_RELEASE_QA_POLICY_BASELINE_REF` (default `HEAD`; CI should use `origin/main`). `set_by` is a recorded claim, not an authenticated identity: the file must be owner-protected in the repository (CODEOWNERS and branch protection), and a downgrade that was already committed becomes the new baseline. An unreadable or malformed policy file is a hard error, never a fall back to warn. When a baseline ref is requested (`RIGHT_RELEASE_QA_POLICY_BASELINE_REF` or the `baselineRef` argument) and git cannot show the policy at that ref, the load fails closed instead of running without a baseline; with nothing requested a checkout without that history (an app's `node_modules`) still loads with no baseline.
|
|
377
|
+
|
|
378
|
+
**Approving a runner.** `right-release qa policy approve-runner --runner <file> --version <v> --platform mac|win --set-by adrian [--note <text>]` digests the file, runs `<file> --version` (the output must be exactly `rightkit-qa <v>`), requires `<v>` to be the published `rightkit-qa` in `rightkit-versions.json` (`registry.cargo`), re-digests the file to catch a swap while it ran, and appends `{ sha256, version, platform, set_by, since, note? }`. Only `adrian` may approve (a runner entry is new admission authority; a hand-added entry by anyone else is refused on load against the baseline). An approval is per platform because a runner's digest differs per host and OS; an entry with no `platform` (older files) approves the digest on either. Running the approved binary is the caller's job: set `qa.runner` in the release config to the managed path, since a bare `which rightkit-qa` can resolve to a different build.
|
|
244
379
|
|
|
245
380
|
Hardening, applied in both modes (a failure is a warning in `warn` and a refusal in `refuse`):
|
|
246
381
|
|
|
247
382
|
1. **Platform and release id.** The sealed receipt must be for the platform and the release id being sealed or published.
|
|
248
|
-
2. **Approved runner.** The `rightkit-qa` executable's sha256 recorded in the receipt must be listed in `approvedRunners
|
|
383
|
+
2. **Approved runner.** The `rightkit-qa` executable's sha256 recorded in the receipt must be listed in `approvedRunners` for the platform being sealed or published (an entry without `platform` approves either). An empty list approves nothing. The binary is digested before the run and again after it; a swap during the run is refused.
|
|
249
384
|
3. **Every release-critical journey.** With `qa.journeys` in the release config (`{ manifest?: "journeys.toml", receipt?: "<path>", args?: { source?, installed? } }`) `right-release qa` runs `rightkit-qa journeys run` (or reads `receipt`), validates the `rightkit.journeys.receipt/1` through `rightkit-qa journeys validate-receipt --manifest .. --tree ..` (exit code 0 and a final `ACCEPTED` line), and seals a `journeys` summary. `release-qa-floor.json` maps the `journey` floor id to "all release-critical journeys passed": at admission the required set is recomputed from the `journeys.toml` committed at the receipt's commit (every `release_critical` journey that lists the platform), and each must be `passed`. Zero critical journeys is never a pass. One matching check no longer satisfies the floor.
|
|
250
385
|
4. **Signing outside test execution.** Checked: the receipt records the test processes' pids and the window they ran in; admission refuses a receipt sealed before that window ended, signing that began before it ended (`signingStartedAt`), a sealing or admitting process that is a test process, `RIGHT_RELEASE_QA_STAGE` set at admission (admission or signing inside a QA run), and `right-release qa` itself refuses to seal inside a QA run. Not checkable here: that test processes cannot reach the signing key (OS credential store and signing agent ACL) and that CI runs QA and signing as separate jobs (the carried receipt, rightkit#183, is the hook).
|
|
251
386
|
|
|
@@ -338,12 +473,15 @@ enforcement. D4 is ratified; pending activation does not mean pending ratificati
|
|
|
338
473
|
|
|
339
474
|
## Entry points
|
|
340
475
|
|
|
341
|
-
|
|
476
|
+
Bins: `right-release` (`cli/right-release.mjs`) and `rightkit-gate` (`bin/rightkit-gate.mjs`). The CLI is the supported surface.
|
|
477
|
+
|
|
478
|
+
`package.json` carries an explicit `exports` map (plan item 2.10, C4-16): one `"./<file>"` entry per public file, no `"."` index, no wildcard. It lists the two bin files, `./package.json`, the modules this README documents as SDK (`./release-dependencies.mjs`, `./engine-locator.mjs`, `./pe-content.mjs`, `./dmg-build.mjs`) and the modules that product repositories and RightKit's own tooling import by deep path today. Every other file is private: `import "@rightkit/release/release.mjs"` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`, and so does `import "@rightkit/release"`. `docs/RELEASE-EXPORTS.md` in the RightKit repository is the inventory (every consumer import with file:line) and `release-exports.test.mjs` fails when the map, that inventory or a checked-out product repository disagree. A new public subpath needs an inventory row or a README SDK section in the same commit; the signing, notarization and packaging modules in the map are a shrinking baseline, not an invitation. A file read by path (`node_modules/@rightkit/release/<file>`) is outside the map and is listed in the inventory too.
|
|
342
479
|
|
|
343
480
|
## Dependencies
|
|
344
481
|
|
|
345
482
|
| Dependency | Range | Note |
|
|
346
483
|
|---|---|---|
|
|
484
|
+
| `@rightkit/outdated` | `0.2.0` (exact) | The updater behind `right-release update` and the stale-pin refusals (plan item 2.10, C4-17). Kept in step with the workspace version of `packages/outdated` by `rightkit-registry-resolver.test.mjs` |
|
|
347
485
|
| `smol-toml` | `1.9.1` (exact) | Used to parse lock and manifest files |
|
|
348
486
|
|
|
349
487
|
Peer dependencies: none.
|
|
@@ -431,13 +569,13 @@ Supported: `title`, `icon` (`.icns` volume icon), `background` (PNG or TIFF), `i
|
|
|
431
569
|
|
|
432
570
|
## GitHub Releases updater lane (`publish-update`, `github-releases`)
|
|
433
571
|
|
|
434
|
-
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:
|
|
435
573
|
|
|
436
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>`;
|
|
437
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;
|
|
438
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.
|
|
439
577
|
|
|
440
|
-
|
|
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.
|
|
441
579
|
|
|
442
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:
|
|
443
581
|
|
|
@@ -457,6 +595,17 @@ targets: {
|
|
|
457
595
|
|
|
458
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.
|
|
459
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
|
+
|
|
460
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).
|
|
461
610
|
|
|
462
611
|
## Android (AAB build, Play internal track, dev install)
|
|
@@ -529,7 +678,9 @@ Any app that ships more than the ffmpeg sidecar (MailRight: `mailright-engine`,
|
|
|
529
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`.
|
|
530
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`.
|
|
531
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.
|
|
532
|
-
- **`
|
|
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.
|
|
533
684
|
|
|
534
685
|
## Declared Rust audit, dist-scan and the Windows Swift toolchain
|
|
535
686
|
|
|
@@ -560,6 +711,38 @@ No agent may write to the internal drive (C: on Windows, the internal disk on ma
|
|
|
560
711
|
|
|
561
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`.
|
|
562
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
|
+
|
|
563
746
|
## RightKit pins are proven against the registries (`rightkit-registry-resolver.mjs`)
|
|
564
747
|
|
|
565
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.
|
|
@@ -567,7 +750,7 @@ At build admission the controller resolves the app's EXACT locked RightKit versi
|
|
|
567
750
|
What the digest comparison is: the lock digest and the registry digest agree (metadata agreement). It is NOT proof of the bytes a build consumes; this tool does not materialize or hash the installed packages, so no byte-level claim is made.
|
|
568
751
|
|
|
569
752
|
- **Coverage.** Every RightKit dependency a manifest declares (`Cargo.toml`, `package.json`, including the directory of every canonical lock outside the app root) needs a lock entry for that package name somewhere in the canonical lock inventory; a missing or incomplete lock refuses admission, with no bundled-only fallback. This is a name-level check, not importer-specific lock proof. Lock inputs come from the build's canonical input inventory (`requiredInputs`, `pinnedCargoLocks`, native-iOS locks), not a separately built list.
|
|
570
|
-
- **Freshness is a build-admission gate.** A fresh `right-release build` reads each package's latest live (crates.io `max_stable_version`, npm `dist-tags.latest`) and REFUSES every pin behind it (`pinned X -> latest Y: run right-update run
|
|
753
|
+
- **Freshness is a build-admission gate.** A fresh `right-release build` reads each package's latest live (crates.io `max_stable_version`, npm `dist-tags.latest`) and REFUSES every pin behind it (`pinned X -> latest Y: run \`pnpm exec right-release update run\` ...`), held or not: a `.rightkit/update.json` hold is a migration lane task, reported with the refusal, never an exception. If latest cannot be read live the failure is RECOVERABLE (a cached latest is only last-observed). The latest row is always the live registry state. The cache keeps a separate high-water mark as downgrade protection for derived answers only: a lower crates.io answer while the higher version is still published is a recoverable `eligibility-conflict`; a withdrawn high-water version resolves itself; an npm `latest` tag moved lower on purpose is followed.
|
|
571
754
|
- **Reuse is trusted-local, not authenticated.** The admitted snapshot (`<state root>/rightkit-registry-snapshot.json`), the admission record (`rightkit-admission.json`: release id, commit, platform, snapshot digest, hash of `release-inputs.lock.json`) and the lock payload are plain files in the release state root. The release worker (`RIGHT_RELEASE_RIGHTKIT_SNAPSHOT`), `--resume-from`, the seal and `upload` check that they agree with each other, with this release's identity and with the current locks, and refuse a missing, foreign, inconsistent or stale set without resolving again. That catches mix-ups and edits that leave the files inconsistent. It does NOT authenticate them: anyone who can write the state root can forge a consistent set, and a resume proves only that the recorded admission is consistent with this release and the current locks, not that the registry still agrees or that the controller wrote it. There is no signing layer, and no existing broker attestation is reused here. A publication after admission changes nothing and freshness is not re-checked.
|
|
572
755
|
- **Sealed evidence.** The sealed `release-manifest.json` carries `rightReleaseVersion`, `rightkitRegistryEvidence: 1` and `rightkitRegistry` (`digest`, `snapshot`). A manifest without the evidence is refused on every seal and upload path. The only exemption is an explicitly identified pre-0.6.0 release: its manifest records no tool version (or one below 0.6.0) and its release id is named in `RIGHT_RELEASE_LEGACY_REGISTRY_RELEASES`. A stripped seal that records 0.6.0 or later is refused whatever is set.
|
|
573
756
|
- `right-release doctor` resolves live and lists newer versions as informational; `right-release doctor --admission` applies the admission refusals.
|
|
@@ -658,7 +841,9 @@ files, base Tauri config content & app-specific default-feature restrictions rem
|
|
|
658
841
|
|
|
659
842
|
## Template rename, PE digest, icon packing
|
|
660
843
|
|
|
661
|
-
`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.
|
|
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).
|
|
845
|
+
|
|
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.
|
|
662
847
|
|
|
663
848
|
`right-release pe digest <file> [--algorithm sha256|sha384|sha512] [--json]` prints the signature-independent PE content digest (Authenticode image hash: CheckSum, certificate table entry and certificate data excluded, so signing, re-signing and timestamping do not change it). `right-release pe imports <file> [--allow dll,...] [--allow-file <path>] [--json]` lists regular and delay-load imports and exits 1 when any is outside the allow list (case-insensitive; a trailing `*` allows a prefix such as `api-ms-win-*`; no allow list refuses every import). SDK: `peContentDigest(bytes)` and `verifyImportClosure(bytes, allowedDlls)` in `pe-content.mjs`.
|
|
664
849
|
|
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
|
+
}
|