@rightkit/release 0.6.6 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -9
- package/admission-files.mjs +3 -2
- package/advisory-db.json +4 -0
- package/advisory-db.mjs +124 -0
- package/advisory-exceptions.mjs +13 -10
- package/app-check.mjs +257 -38
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-agent-hook.mjs +9 -0
- package/bin/rightkit-gate.mjs +495 -344
- package/build-release.mjs +13 -6
- package/cli/right-release.mjs +29 -6
- package/compile-inputs.mjs +66 -0
- package/consumer-contracts.json +1044 -0
- package/consumer-contracts.mjs +627 -0
- package/consumer-input-contracts.mjs +422 -0
- package/consumer-policy.mjs +588 -0
- package/consumer-source-contracts.mjs +469 -0
- package/deny-default.toml +42 -0
- package/deterministic-scans.mjs +240 -29
- package/disk-command.mjs +270 -30
- package/disk-policy.json +50 -10
- package/disk-policy.mjs +482 -35
- package/disk-report.mjs +129 -0
- package/door-policy.mjs +228 -0
- package/gate-executor.mjs +30 -0
- package/gate-stages.mjs +110 -0
- package/github-updater-release.mjs +48 -8
- package/init-command.mjs +41 -0
- package/legion-semantic-contracts.mjs +428 -0
- package/ownership-class-rules.mjs +277 -0
- package/ownership-key-authority.mjs +3 -1
- package/ownership-mandatory-set.mjs +3 -2
- package/package.json +10 -3
- package/publish-cargo.mjs +5 -2
- package/publish-update.mjs +14 -11
- package/release-config.mjs +51 -6
- package/rightkit-versions.json +172 -167
- package/shell-contract-bridge.json +251 -0
- package/shell-contract.mjs +1069 -0
- package/sign-updater.mjs +24 -4
- package/source-closure-rules.mjs +204 -0
- package/stage-sidecar-command.mjs +2 -1
- package/stage-sidecar.mjs +437 -114
- package/suite-api.generated.mjs +96 -6
- package/suite-trust-roots.mjs +198 -0
package/disk-report.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { lstatSync, readdirSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathSize, platformKey, scratchOwner, scratchRoots } from "./disk-policy.mjs";
|
|
5
|
+
|
|
6
|
+
// The daily `disk.report` line of disk-hygiene.jsonl. Pulse's Storage card reads it: the field names below are a contract
|
|
7
|
+
// (frozen in disk-report.test.mjs). Pure apart from `measureScratchUnits`.
|
|
8
|
+
|
|
9
|
+
const UNIT_LIMIT = 50;
|
|
10
|
+
const GROWER_LIMIT = 10;
|
|
11
|
+
|
|
12
|
+
export { scratchOwner };
|
|
13
|
+
|
|
14
|
+
/** True when the last report is from another UTC day than `now` (or there is none). */
|
|
15
|
+
export function reportDue({ lastReportTs, now }) {
|
|
16
|
+
const last = Date.parse(lastReportTs ?? "");
|
|
17
|
+
if (!Number.isFinite(last)) return true;
|
|
18
|
+
return new Date(last).toISOString().slice(0, 10) !== new Date(now).toISOString().slice(0, 10);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One du per child of every scratch root (daily only: the hourly pass never sizes anything). */
|
|
22
|
+
export function measureScratchUnits({ policy, platform = process.platform, env = process.env, home = os.homedir(), size = (p) => pathSize(p, { platform }) } = {}) {
|
|
23
|
+
const units = [];
|
|
24
|
+
for (const root of scratchRoots(policy, { platform, env, home })) {
|
|
25
|
+
let names = [];
|
|
26
|
+
try { names = readdirSync(root); } catch { continue; }
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
const full = path.join(root, name);
|
|
29
|
+
try { if (lstatSync(full).isSymbolicLink()) continue; } catch { continue; }
|
|
30
|
+
units.push({ name, path: full, bytes: size(full) });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return units;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const round = (n, places = 2) => Math.round(n * 10 ** places) / 10 ** places;
|
|
37
|
+
|
|
38
|
+
// bytes per category: the outermost writers on the agent volume (a writer nested in another counts once) plus what no writer
|
|
39
|
+
// holds. The identity is sum(categories) - overlapBytes = usedBytes: du sums allocated bytes per writer, so APFS clones and hard links
|
|
40
|
+
// can make the writers add up to MORE than the volume's used bytes. Then `overlapBytes` = writers - used (the double-counted bytes),
|
|
41
|
+
// `unmanaged` is 0 and no category is scaled; otherwise `overlapBytes` is 0 and `unmanaged` = used - writers. The change in used
|
|
42
|
+
// bytes between two reports is therefore sum(delta.categories) - delta.overlapBytes.
|
|
43
|
+
function categoriesOf(check, agentUnmanaged, usedBytes) {
|
|
44
|
+
const categories = {};
|
|
45
|
+
const add = (name, bytes) => { categories[name] = (categories[name] ?? 0) + bytes; };
|
|
46
|
+
for (const w of agentUnmanaged ? agentUnmanaged.writers ?? [] : check.writers ?? []) add(w.category ?? w.id, w.bytes ?? 0);
|
|
47
|
+
const writerBytes = Object.values(categories).reduce((a, b) => a + b, 0);
|
|
48
|
+
categories.unmanaged = Math.max(0, usedBytes - writerBytes);
|
|
49
|
+
return { categories, overlapBytes: Math.max(0, writerBytes - usedBytes) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sumDelta = (now, before) => {
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const name of new Set([...Object.keys(now), ...Object.keys(before)])) out[name] = (now[name] ?? 0) - (before[name] ?? 0);
|
|
55
|
+
return out;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** The report line for a finished full pass. `check` is the `checkDisk` result after the pass, `previous` the last
|
|
59
|
+
* `disk.report` event (or null), `scratchUnits` the result of `measureScratchUnits`. */
|
|
60
|
+
export function buildDiskReport({ check, previous = null, now, policy, scratchUnits = [], moved = 0, purged = 0, host = os.hostname() }) {
|
|
61
|
+
const floors = check.floors ?? [check.floor];
|
|
62
|
+
// The report describes the agent volume. When one is flagged and cannot be read there is no lead: never another volume's numbers
|
|
63
|
+
// under its name (a day of internal-drive bytes next to a day of D: bytes makes a delta of terabytes). Policies that flag no
|
|
64
|
+
// agent volume at all report their first readable floor.
|
|
65
|
+
const lead = floors.some((f) => f.agentVolume) ? floors.find((f) => f.agentVolume && !f.absent) ?? null : floors.find((f) => !f.absent) ?? null;
|
|
66
|
+
const agentUnmanaged = lead ? (check.unmanaged ?? []).find((u) => u.floor === (lead.id ?? lead.label)) ?? (check.unmanaged ?? [])[0] ?? null : null;
|
|
67
|
+
const usedBytes = lead ? lead.totalBytes - lead.freeBytes : null;
|
|
68
|
+
const { categories, overlapBytes } = lead ? categoriesOf(check, agentUnmanaged, usedBytes) : { categories: null, overlapBytes: null };
|
|
69
|
+
|
|
70
|
+
const units = {};
|
|
71
|
+
const owners = {};
|
|
72
|
+
for (const w of check.writers ?? []) if (w.bytes > 0) units[w.id] = (units[w.id] ?? 0) + w.bytes;
|
|
73
|
+
const scratchByOwner = {};
|
|
74
|
+
for (const u of scratchUnits) {
|
|
75
|
+
units[`scratch/${u.name}`] = u.bytes;
|
|
76
|
+
owners[`scratch/${u.name}`] = scratchOwner(u.name);
|
|
77
|
+
scratchByOwner[scratchOwner(u.name)] = (scratchByOwner[scratchOwner(u.name)] ?? 0) + u.bytes;
|
|
78
|
+
}
|
|
79
|
+
const kept = Object.entries(units).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, UNIT_LIMIT);
|
|
80
|
+
const compact = Object.fromEntries(kept);
|
|
81
|
+
|
|
82
|
+
let delta = null;
|
|
83
|
+
let growers = [];
|
|
84
|
+
const hours = previous ? Math.max(0, (now - Date.parse(previous.ts)) / 3600_000) : 0;
|
|
85
|
+
// No delta unless both reports describe the agent volume: a report without one (null sizes) is no baseline, and neither is this one.
|
|
86
|
+
const comparable = lead && previous && Number.isFinite(previous.usedBytes) && previous.categories && typeof previous.categories === "object";
|
|
87
|
+
if (comparable && Number.isFinite(hours)) {
|
|
88
|
+
delta = { hours: round(hours), usedBytes: usedBytes - previous.usedBytes, categories: sumDelta(categories, previous.categories), overlapBytes: overlapBytes - (previous.overlapBytes ?? 0) };
|
|
89
|
+
growers = Object.entries(compact)
|
|
90
|
+
.map(([id, bytes]) => ({ id, bytes, deltaBytes: bytes - (previous.units?.[id] ?? 0), owner: owners[id] ?? null }))
|
|
91
|
+
.filter((g) => g.deltaBytes > 0)
|
|
92
|
+
.sort((a, b) => b.deltaBytes - a.deltaBytes || a.id.localeCompare(b.id))
|
|
93
|
+
.slice(0, GROWER_LIMIT);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const budgetsOver = (check.writers ?? []).filter((w) => w.over).map((w) => ({ id: w.id, bytes: w.bytes, capBytes: w.capBytes }));
|
|
97
|
+
for (const [prefix, capBytes] of Object.entries(policy.scratchPrefixCaps ?? {})) {
|
|
98
|
+
if ((scratchByOwner[prefix] ?? 0) > capBytes) budgetsOver.push({ id: `scratch:${prefix}`, bytes: scratchByOwner[prefix], capBytes });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// What the data in flight occupies at steady state: a day's growth lives as scratch, then in holding, then the daily pass
|
|
102
|
+
// that finally purges it.
|
|
103
|
+
const growthBytesPerDay = delta ? Math.round((Math.max(0, delta.usedBytes) / Math.max(delta.hours, 1)) * 24) : 0;
|
|
104
|
+
const daysParked = (policy.scratchStaleDays ?? policy.unknownStaleDays ?? 3) + (policy.holdingDays ?? 3) + 1;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
ts: new Date(now).toISOString(),
|
|
108
|
+
event: "disk.report",
|
|
109
|
+
schema: 1,
|
|
110
|
+
platform: check.platform ?? platformKey(),
|
|
111
|
+
host,
|
|
112
|
+
freeBytes: lead?.freeBytes ?? null,
|
|
113
|
+
usedBytes,
|
|
114
|
+
totalBytes: lead?.totalBytes ?? null,
|
|
115
|
+
floors: floors.map((f) => ({ id: f.id ?? f.label, freeBytes: f.absent ? null : f.freeBytes, softBytes: f.absent ? null : f.thresholdBytes, hardBytes: f.hardFreeBytes ?? null, warn: f.warn })),
|
|
116
|
+
categories,
|
|
117
|
+
delta,
|
|
118
|
+
units: compact,
|
|
119
|
+
growers,
|
|
120
|
+
budgetsOver,
|
|
121
|
+
unmanagedBytes: categories ? categories.unmanaged : null,
|
|
122
|
+
overlapBytes,
|
|
123
|
+
uncoveredTopLevel: (agentUnmanaged?.uncoveredTopLevel ?? []).map((e) => e.name),
|
|
124
|
+
scratchByOwner,
|
|
125
|
+
steadyState: { growthBytesPerDay, daysParked, estimateBytes: growthBytesPerDay * daysParked },
|
|
126
|
+
movedBytes: moved,
|
|
127
|
+
purgedBytes: purged,
|
|
128
|
+
};
|
|
129
|
+
}
|
package/door-policy.mjs
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// The one-door policy for agents (Adrian, 2026-10-25): one way to build, through rightkit-gate. An agent's shell command that builds,
|
|
2
|
+
// checks, tests, packages or releases an app any other way is denied with the door to use instead. Deterministic and offline: it reads
|
|
3
|
+
// only the command text. `rustright cargo ...` is allowed (the broker admits a compile only after class A); `rightkit-gate ...` is the
|
|
4
|
+
// door. The same policy runs on every host: through the repository's .claude/settings.json (rightkit-gate install-hook), so a cloud
|
|
5
|
+
// session without the broker is held to it too.
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
const CARGO_COMPILES = new Set(["build", "b", "check", "c", "clippy", "test", "t", "run", "r", "bench", "doc", "d", "rustc", "install", "tauri"]);
|
|
9
|
+
const TAURI_BUILDS = new Set(["build", "dev", "bundle", "android", "ios"]);
|
|
10
|
+
// right-release commands that build, package, sign, qualify or ship (CHECKED_COMMANDS "commit" in app-check.mjs).
|
|
11
|
+
const RELEASE_COMMANDS = new Set(["dev", "test", "stage-sidecar", "release", "build", "package", "qa", "upload", "publish", "publish-update", "github", "sign-windows", "sign-updater", "create-mac-updater", "web-build", "swift-package", "recipe-build", "sign-candidate"]);
|
|
12
|
+
const RUNNERS = new Set(["pnpm", "npm", "npx", "yarn", "bunx", "pnpx"]);
|
|
13
|
+
const SHELLS = new Set(["bash", "sh", "zsh", "dash", "fish", "pwsh", "powershell", "cmd"]);
|
|
14
|
+
// cargo's global options that take a value (`cargo -C dir build`, `cargo --config k=v test`): the value is not the subcommand.
|
|
15
|
+
const CARGO_VALUE_OPTIONS = new Set(["-C", "--config", "--color", "-Z", "--manifest-path"]);
|
|
16
|
+
const WRAPPERS = new Set(["env", "command", "exec", "time", "nice", "nohup", "sudo", "timeout"]);
|
|
17
|
+
// Shell reserved words and grouping that can precede a command in the same simple command (`if true; then cargo build; fi`,
|
|
18
|
+
// `{ cargo build; }`, `f() { cargo build; }`, `! cargo test`): they are skipped, never taken for the command.
|
|
19
|
+
const KEYWORDS = new Set(["if", "then", "elif", "else", "fi", "do", "done", "while", "until", "{", "}", "!", "function"]);
|
|
20
|
+
const DOOR_TOOLS = new Set(["cargo", "tauri", "right-release"]);
|
|
21
|
+
|
|
22
|
+
/** Splits a shell command into simple commands (on ; && || | and newlines), each an argv of words. Quotes are respected. */
|
|
23
|
+
export function simpleCommands(command) {
|
|
24
|
+
return pipedCommands(command).map((c) => c.argv);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* simpleCommands, each with `piped` (the argv whose output it reads through a single `|`, or null), `pipesOut` (its output goes
|
|
29
|
+
* through `|`), `after` (the separator before it: "start", ";", "&&", "||", "|", "&") and `scope` (the nesting of subshells and
|
|
30
|
+
* if/while/until/for/case/{ } blocks it stands in). An assignment reaches a later command for certain only in the same scope.
|
|
31
|
+
*/
|
|
32
|
+
function pipedCommands(command) {
|
|
33
|
+
const out = []; let words = []; let word = ""; let quote = null; let has = false; let pipe = false; let sep = "start";
|
|
34
|
+
const stack = []; let next = 0;
|
|
35
|
+
const OPEN = { if: "fi", while: "done", until: "done", for: "done", case: "esac", "{": "}" };
|
|
36
|
+
const flushWord = () => { if (has) { words.push(word); word = ""; has = false; } };
|
|
37
|
+
const flushCmd = (nextSep = ";") => {
|
|
38
|
+
flushWord();
|
|
39
|
+
if (words.length) {
|
|
40
|
+
// Block words change the scope: a closing word belongs to the inner scope it closes, an opening word to the one it opens.
|
|
41
|
+
let w = 0;
|
|
42
|
+
while (w < words.length && ["fi", "done", "esac", "}"].includes(words[w]) && stack.length) { stack.pop(); w++; }
|
|
43
|
+
const scope = stack.map((b) => b.id).join("/");
|
|
44
|
+
if (pipe && out.length) out[out.length - 1].pipesOut = true;
|
|
45
|
+
out.push({ argv: words, piped: pipe && out.length ? out[out.length - 1].argv : null, pipesOut: false, after: sep, scope });
|
|
46
|
+
for (; w < words.length; w++) if (OPEN[words[w]] && (w === 0 || ["then", "do", "else", "elif", "{", "!", "time"].includes(words[w - 1]) || OPEN[words[w - 1]])) stack.push({ id: next++, kind: words[w] });
|
|
47
|
+
for (const x of words) if (["fi", "done", "esac", "}"].includes(x) && x !== words[0] && stack.length) stack.pop();
|
|
48
|
+
}
|
|
49
|
+
words = []; pipe = nextSep === "|"; sep = nextSep;
|
|
50
|
+
};
|
|
51
|
+
for (let i = 0; i < command.length; i++) {
|
|
52
|
+
const ch = command[i];
|
|
53
|
+
if (quote) { if (ch === quote) quote = null; else if (ch === "\\" && quote === '"' && i + 1 < command.length) { word += command[++i]; } else word += ch; has = true; continue; }
|
|
54
|
+
if (ch === "'" || ch === '"') { quote = ch; has = true; continue; }
|
|
55
|
+
if (ch === "\\" && i + 1 < command.length) { word += command[++i]; has = true; continue; }
|
|
56
|
+
if (ch === "|" && command[i + 1] === "|") { flushCmd("||"); i++; continue; }
|
|
57
|
+
if (ch === "&" && command[i + 1] === "&") { flushCmd("&&"); i++; continue; }
|
|
58
|
+
if (ch === "|") { flushCmd("|"); continue; }
|
|
59
|
+
if (ch === "(") { flushCmd(";"); stack.push({ id: next++, kind: "(" }); continue; }
|
|
60
|
+
// In a case block, `)` ends a pattern; otherwise it closes a subshell.
|
|
61
|
+
if (ch === ")") { const top = stack[stack.length - 1]; flushCmd(";"); if (top && top.kind !== "case" && stack.length) stack.pop(); continue; }
|
|
62
|
+
if (ch === ";" || ch === "\n" || ch === "&") { flushCmd(ch === "&" ? "&" : ";"); continue; }
|
|
63
|
+
if (/\s/.test(ch)) { flushWord(); continue; }
|
|
64
|
+
word += ch; has = true;
|
|
65
|
+
}
|
|
66
|
+
flushCmd();
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const base = (word) => path.basename(String(word ?? "")).replace(/\.(exe|cmd|bat|mjs|js)$/i, "");
|
|
71
|
+
|
|
72
|
+
function stripWrappers(argv) {
|
|
73
|
+
let a = [...argv];
|
|
74
|
+
for (;;) {
|
|
75
|
+
while (a.length && KEYWORDS.has(a[0])) a = a.slice(1);
|
|
76
|
+
while (a.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(a[0])) a = a.slice(1);
|
|
77
|
+
if (a.length && KEYWORDS.has(a[0])) continue;
|
|
78
|
+
if (a.length && WRAPPERS.has(base(a[0]))) { const w = base(a[0]); a = a.slice(1); while (a.length && a[0].startsWith("-")) a = a.slice(a[0] === "-u" || a[0] === "-n" ? 2 : 1); if (w === "timeout" && a.length && /^\d/.test(a[0])) a = a.slice(1); continue; }
|
|
79
|
+
return a;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The violation a single argv is, or null. */
|
|
84
|
+
/** argv without its redirections (`>/dev/null`, `2>&1`, `> out.txt`, `<in`): they are not arguments of the command. */
|
|
85
|
+
function withoutRedirects(argv) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (let i = 0; i < argv.length; i++) {
|
|
88
|
+
if (/^(\d*|&)(>>?|<)(&\d*)?$/.test(argv[i])) { i++; continue; }
|
|
89
|
+
if (/^(\d*|&)(>>?|<)/.test(argv[i])) continue;
|
|
90
|
+
out.push(argv[i]);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function argvViolation(argv, depth = 0, stdin = null) {
|
|
96
|
+
let a = stripWrappers(withoutRedirects(argv));
|
|
97
|
+
if (!a.length) return null;
|
|
98
|
+
let tool = base(a[0]);
|
|
99
|
+
// A command string handed to another shell (`bash -c "cargo build"`, `eval "..."`) is judged as the command it is.
|
|
100
|
+
if (depth < 4 && (SHELLS.has(tool.toLowerCase()) || tool === "eval")) {
|
|
101
|
+
const flag = a.findIndex((w, i) => i > 0 && /^(-c|\/c|-Command|-command)$/.test(w));
|
|
102
|
+
const script = tool === "eval" ? a.slice(1).join(" ") : flag > 0 ? a.slice(flag + 1).join(" ") : null;
|
|
103
|
+
return script ? doorViolation(script, depth + 1) : null;
|
|
104
|
+
}
|
|
105
|
+
if (tool === "xargs" && a.length > 1) {
|
|
106
|
+
const inner = a.slice(1).filter((w) => !w.startsWith("-"));
|
|
107
|
+
// xargs appends what it reads to the command. Read from a literal `echo`/`printf`, the words are known and judged with it;
|
|
108
|
+
// read from anything else, a door tool, a package runner or node is denied, since the text cannot say what it will run.
|
|
109
|
+
const echoed = stdin && ["echo", "printf"].includes(base(stdin[0])) ? stdin.slice(base(stdin[0]) === "printf" ? 2 : 1).filter((w) => !/^-[neE]+$/.test(w)).flatMap((w) => w.split(/\s+/).filter(Boolean)) : null;
|
|
110
|
+
if (echoed) return argvViolation([...inner, ...echoed], depth + 1);
|
|
111
|
+
if (DOOR_TOOLS.has(base(inner[0])) || RUNNERS.has(base(inner[0])) || base(inner[0]) === "node") { const v = argvViolation(inner, depth + 1); return v ?? `${base(inner[0])} (arguments from xargs)`; }
|
|
112
|
+
return argvViolation(inner, depth + 1);
|
|
113
|
+
}
|
|
114
|
+
// pnpm/npx/yarn [exec|dlx] <tool> ...: judge the tool it runs.
|
|
115
|
+
if (RUNNERS.has(tool)) {
|
|
116
|
+
let rest = a.slice(1).filter((w, i, all) => !(w.startsWith("-") || (i > 0 && ["--filter", "-F", "--dir", "-C", "--prefix"].includes(all[i - 1]))));
|
|
117
|
+
if (["exec", "dlx", "x"].includes(rest[0])) rest = rest.slice(1);
|
|
118
|
+
if (rest[0] === "run" || rest[0] === "run-script") return null; // the apps have no scripts; a script name is not a tool
|
|
119
|
+
if (!rest.length) return null;
|
|
120
|
+
a = rest; tool = base(a[0]);
|
|
121
|
+
}
|
|
122
|
+
if (tool === "rightkit-gate" || tool === "rustright") return null;
|
|
123
|
+
if (tool === "tauri" && TAURI_BUILDS.has(a[1])) return `tauri ${a[1]}`;
|
|
124
|
+
if (tool === "cargo") {
|
|
125
|
+
let sub = null;
|
|
126
|
+
for (let i = 1; i < a.length; i++) {
|
|
127
|
+
if (CARGO_VALUE_OPTIONS.has(a[i])) { i++; continue; }
|
|
128
|
+
if (!a[i].startsWith("-") && !a[i].startsWith("+")) { sub = a[i]; break; }
|
|
129
|
+
}
|
|
130
|
+
if (sub === "tauri" && TAURI_BUILDS.has(a[a.indexOf("tauri") + 1])) return `cargo tauri ${a[a.indexOf("tauri") + 1]}`;
|
|
131
|
+
if (sub && CARGO_COMPILES.has(sub) && sub !== "tauri") return `cargo ${sub}`;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const releaseArgs = tool === "right-release" ? a.slice(1) : tool === "node" && a[1] && /right-release(\.mjs)?$/.test(a[1]) ? a.slice(2) : null;
|
|
135
|
+
if (releaseArgs) {
|
|
136
|
+
// A flag first (`right-release --platform mac`) is `build`; `cargo-target` and `dmg build` compile too.
|
|
137
|
+
if (releaseArgs[0]?.startsWith("-") && !["-h", "--help", "-v", "--version"].includes(releaseArgs[0])) return "right-release build";
|
|
138
|
+
if (RELEASE_COMMANDS.has(releaseArgs[0])) return `right-release ${releaseArgs[0]}`;
|
|
139
|
+
if (releaseArgs[0] === "cargo-target") return "right-release cargo-target";
|
|
140
|
+
if (["dmg", "addon"].includes(releaseArgs[0]) && releaseArgs[1] === "build") return `right-release ${releaseArgs[0]} build`;
|
|
141
|
+
}
|
|
142
|
+
// The build engine called directly.
|
|
143
|
+
if (tool === "node" && a[1] && /(^|[\/])(build-release|release|upload-release|publish-update)\.mjs$/.test(a[1])) return "right-release build";
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The bodies of `$(...)` and backtick substitutions in `command`, outside single quotes (they run inside double quotes too). */
|
|
148
|
+
export function substitutions(command) {
|
|
149
|
+
return splitSubstitutions(command).bodies;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** { bodies, rest }: the substitution bodies, and `command` with each substitution replaced by a placeholder word. */
|
|
153
|
+
function splitSubstitutions(command) {
|
|
154
|
+
const out = []; let quote = null; let rest = ""; let from = 0;
|
|
155
|
+
const cut = (start, end, body) => { out.push(body); rest += `${command.slice(from, start)}_SUBST_`; from = end; };
|
|
156
|
+
for (let i = 0; i < command.length; i++) {
|
|
157
|
+
const ch = command[i];
|
|
158
|
+
if (quote === "'") { if (ch === "'") quote = null; continue; }
|
|
159
|
+
if (ch === "\\") { i++; continue; }
|
|
160
|
+
if (ch === "'" && quote !== '"') { quote = "'"; continue; }
|
|
161
|
+
if (ch === '"') { quote = quote === '"' ? null : '"'; continue; }
|
|
162
|
+
if (ch === "`") { const end = command.indexOf("`", i + 1); if (end < 0) { cut(i, command.length, command.slice(i + 1)); break; } cut(i, end + 1, command.slice(i + 1, end)); i = end; continue; }
|
|
163
|
+
if (ch === "$" && command[i + 1] === "(" && command[i + 2] !== "(") {
|
|
164
|
+
let depth = 0; let j = i + 1;
|
|
165
|
+
// A `case` arm's pattern ends in an unmatched `)` (`$(case x in x) cargo build;; esac)`): it does not close the substitution.
|
|
166
|
+
const openCases = (to) => (command.slice(i + 2, to).match(/\bcase\b/g) ?? []).length - (command.slice(i + 2, to).match(/\besac\b/g) ?? []).length;
|
|
167
|
+
for (; j < command.length; j++) { if (command[j] === "(") depth++; else if (command[j] === ")" && depth === 1 && openCases(j) > 0) continue; else if (command[j] === ")" && --depth === 0) break; }
|
|
168
|
+
cut(i, j + 1, command.slice(i + 2, j)); i = j;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { bodies: out, rest: rest + command.slice(from) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The first way `command` builds outside the door, or null. */
|
|
175
|
+
export function doorViolation(command, depth = 0) {
|
|
176
|
+
if (typeof command !== "string" || !command.trim()) return null;
|
|
177
|
+
const { bodies, rest } = splitSubstitutions(command);
|
|
178
|
+
if (depth < 4) for (const inner of bodies) { const v = doorViolation(inner, depth + 1); if (v) return v; }
|
|
179
|
+
// Variables set in the command (`T=cargo; $T build`, `export T=cargo`) are substituted where they stand in command position; a
|
|
180
|
+
// command word that is a variable the command does not set cannot be read from the text, so it is denied rather than guessed.
|
|
181
|
+
const vars = {};
|
|
182
|
+
const commands = pipedCommands(rest);
|
|
183
|
+
for (const [index, { argv, piped, pipesOut, after, scope }] of commands.entries()) {
|
|
184
|
+
let a = argv;
|
|
185
|
+
// `time` is a reserved word in bash too: `time T=cargo` assigns.
|
|
186
|
+
while (a.length && (KEYWORDS.has(a[0]) || a[0] === "time" || (a[0] === "-p" && argv[0] === "time"))) a = a.slice(1);
|
|
187
|
+
while (a.length && ["export", "declare", "local", "readonly", "typeset"].includes(a[0])) a = a.slice(1).filter((w) => !w.startsWith("-"));
|
|
188
|
+
const assigns = []; while (a.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(a[0])) { assigns.push(a[0]); a = a.slice(1); }
|
|
189
|
+
// Certain only when it runs in this shell unconditionally: not a pipeline element, not after && or ||, not backgrounded.
|
|
190
|
+
// (`true && T=x` and `false || T=x` always run it; any other condition cannot be decided from the text.)
|
|
191
|
+
const prev = commands[index - 1]?.argv;
|
|
192
|
+
const decided = prev?.length === 1 && ((after === "&&" && prev[0] === "true") || (after === "||" && prev[0] === "false"));
|
|
193
|
+
const sure = !piped && !pipesOut && (after === "start" || after === ";" || decided);
|
|
194
|
+
if (!a.length) { for (const w of assigns) { const k = w.slice(0, w.indexOf("=")); vars[k] = { value: w.slice(k.length + 1), sure, scope }; } continue; }
|
|
195
|
+
const head = stripWrappers(a);
|
|
196
|
+
const ref = /^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/.exec(head[0] ?? "");
|
|
197
|
+
if (ref) {
|
|
198
|
+
const name = ref[1] ?? ref[2];
|
|
199
|
+
if (!(name in vars)) return `\$${name} (a command the text does not name)`;
|
|
200
|
+
// An assignment that may not have reached this command (a pipeline element, a subshell, another block, after && or ||) may
|
|
201
|
+
// leave the variable empty, and then its arguments are the command: both readings are judged.
|
|
202
|
+
const set = vars[name];
|
|
203
|
+
if (!set.sure || !(scope === set.scope || scope.startsWith(`${set.scope}/`) || set.scope === "")) {
|
|
204
|
+
const empty = argvViolation(head.slice(1), 0, piped ? stripWrappers(withoutRedirects(piped)) : null);
|
|
205
|
+
if (empty) return empty;
|
|
206
|
+
}
|
|
207
|
+
a = [...set.value.split(/\s+/).filter(Boolean), ...head.slice(1)];
|
|
208
|
+
}
|
|
209
|
+
const v = argvViolation(a, 0, piped ? stripWrappers(withoutRedirects(piped)) : null);
|
|
210
|
+
if (v) return v;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function doorDenial(violation) {
|
|
216
|
+
const stage = /^right-release (dev|test)$/.test(violation) ? violation.slice("right-release ".length) : /^(cargo )?tauri dev$/.test(violation) ? "dev"
|
|
217
|
+
: /^right-release (upload|publish|publish-update|github)$/.test(violation) ? "release" : /^right-release qa$/.test(violation) ? "qualify" : /^right-release /.test(violation) || /^(cargo )?tauri (build|bundle|android|ios)$/.test(violation) ? "package" : "compile";
|
|
218
|
+
return `[HOOK:one-door:block]\nWhy: \`${violation}\` builds outside the one door. Every check, compile, test, package and release goes through rightkit-gate, class A first.\nRequired: rightkit-gate --to ${stage}${stage === "package" || stage === "qualify" || stage === "release" ? " --platform mac|win" : ""}${stage === "dev" || stage === "test" ? " -- <arguments>" : ""}${stage === "release" ? " --tier patch|update" : ""} (on a machine with the RustRight broker, \`rustright cargo ...\` is also admitted: the broker runs class A first).`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Claude Code PreToolUse decision for one hook input. */
|
|
222
|
+
export function claudeDoorDecision(input) {
|
|
223
|
+
const command = input?.tool_input?.command ?? input?.tool_input?.cmd ?? input?.command ?? null;
|
|
224
|
+
const violation = doorViolation(typeof command === "string" ? command : "");
|
|
225
|
+
return violation
|
|
226
|
+
? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: doorDenial(violation) } }
|
|
227
|
+
: {};
|
|
228
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The executor behind the one door (Adrian, 2026-10-25): rightkit-gate chooses it, never the caller.
|
|
2
|
+
// broker - `rustright` is on PATH, or the first `cargo` is the broker's managed shim: every cargo call goes through the broker.
|
|
3
|
+
// refuse - a broker-managed machine (Mac/Dell markers, broker socket, managed agent-bin) whose broker cannot be reached from here:
|
|
4
|
+
// the gate never compiles around it.
|
|
5
|
+
// direct - any other host (a throwaway cloud container, a CI runner): cargo runs directly, through the same gates and stages.
|
|
6
|
+
import { brokerManagedHost, managedCargoShimOnPath } from "./cargo-contract.mjs";
|
|
7
|
+
|
|
8
|
+
export const EXECUTOR_KINDS = Object.freeze(["broker", "refuse", "direct"]);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {{ env?: object, platform?: string, exists?: (p: string) => boolean, which: (cmd: string) => string[], readFile: (p: string) => string }} o
|
|
12
|
+
* @returns {{ kind: "broker" | "refuse" | "direct", cargo: string[] | null, problem?: string }}
|
|
13
|
+
*/
|
|
14
|
+
export function resolveExecutor({ env = process.env, platform = process.platform, exists, which, readFile }) {
|
|
15
|
+
if (which("rustright").length) return { kind: "broker", cargo: ["rustright", "cargo"] };
|
|
16
|
+
const first = which("cargo")[0];
|
|
17
|
+
if (first) {
|
|
18
|
+
try { if (readFile(first).includes("cargo-guard.mjs")) return { kind: "broker", cargo: ["cargo"] }; } catch { /* unreadable: not the shim */ }
|
|
19
|
+
}
|
|
20
|
+
// RustRight's own agent-bin `cargo` shim first on PATH: plain `cargo` already goes through the broker.
|
|
21
|
+
if (managedCargoShimOnPath(env, platform, exists)) return { kind: "broker", cargo: ["cargo"] };
|
|
22
|
+
if (brokerManagedHost(env, platform, exists)) {
|
|
23
|
+
return {
|
|
24
|
+
kind: "refuse",
|
|
25
|
+
cargo: null,
|
|
26
|
+
problem: "this machine is broker-managed (RustRight) but `rustright` is not reachable from this shell; start or repair the broker, then run again. The gate never compiles around the broker",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return { kind: "direct", cargo: ["cargo"] };
|
|
30
|
+
}
|
package/gate-stages.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The stages behind the one door (Adrian, 2026-10-25): rightkit-gate --to <stage> runs every stage up to <stage>, in order, for the
|
|
2
|
+
// commit at HEAD, and stops at the first that fails. Class A always runs first. The stages reuse the existing owners instead of
|
|
3
|
+
// reimplementing them: the gate's own scans for check/compile, `right-release` for package (source QA, then the build that seals the
|
|
4
|
+
// exact artifact set), installed QA on that sealed set, and upload of that same sealed set. Those right-release commands refuse to
|
|
5
|
+
// run unless a live door run launched them (requireDoorRun), so there is no side door into a stage.
|
|
6
|
+
//
|
|
7
|
+
// Run token: <git common dir>/rightkit-gate/runs/<nonce>.json { pid, commit, tree, stage } while a door run is active; the child gets
|
|
8
|
+
// RK_GATE_RUN=<nonce>. Receipt: <git common dir>/rightkit-gate/receipts/<commit>.json, machine-owned, binding the commit and tree to
|
|
9
|
+
// the executor, toolchain, lockfile digests, gate digest, each stage's result and the sealed manifest digest (the bytes that were
|
|
10
|
+
// packaged, qualified and released). Nobody edits these by hand; the door writes them and reads them.
|
|
11
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
export const STAGES = Object.freeze(["check", "compile", "package", "qualify", "release"]);
|
|
16
|
+
export const RECEIPT_SCHEMA = "rightkit.gate-receipt/1";
|
|
17
|
+
export const RUN_ENV = "RK_GATE_RUN";
|
|
18
|
+
|
|
19
|
+
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
20
|
+
|
|
21
|
+
/** The stages a run to `target` covers, in order; null when `target` is not a stage. */
|
|
22
|
+
export function stagesTo(target) {
|
|
23
|
+
const i = STAGES.indexOf(target);
|
|
24
|
+
return i < 0 ? null : STAGES.slice(0, i + 1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The door's state directory under the repository's git common dir. */
|
|
28
|
+
export function gateStateDir(commonDir) {
|
|
29
|
+
return path.join(commonDir, "rightkit-gate");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Starts a door run: writes the run file and returns { nonce, file, env }. The caller removes it with endRun. */
|
|
33
|
+
export function startRun({ commonDir, commit, tree, stage, pid = process.pid, write = fs.writeFileSync, mkdir = fs.mkdirSync }) {
|
|
34
|
+
const nonce = randomBytes(16).toString("hex");
|
|
35
|
+
const dir = path.join(gateStateDir(commonDir), "runs");
|
|
36
|
+
mkdir(dir, { recursive: true });
|
|
37
|
+
const file = path.join(dir, `${nonce}.json`);
|
|
38
|
+
write(file, `${JSON.stringify({ pid, commit, tree, stage, startedAt: new Date().toISOString() })}\n`);
|
|
39
|
+
return { nonce, file, env: { [RUN_ENV]: nonce } };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function endRun(run, rm = fs.rmSync) {
|
|
43
|
+
if (run?.file) rm(run.file, { force: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether this process was launched by a live door run for `commit`. Returns null when it was, else the refusal message. A run file
|
|
48
|
+
* whose pid is gone, or that names another commit, is no run.
|
|
49
|
+
*/
|
|
50
|
+
export function requireDoorRun({ env = process.env, commonDir, commit, readFile = (f) => fs.readFileSync(f, "utf8"), alive = pidAlive, command = "this command" }) {
|
|
51
|
+
const nonce = env[RUN_ENV];
|
|
52
|
+
const refusal = `${command} runs only through the one door: rightkit-gate --to <check|compile|package|qualify|release> (it runs class A first, then every stage in order)`;
|
|
53
|
+
if (!nonce || !/^[0-9a-f]{32}$/.test(nonce)) return refusal;
|
|
54
|
+
let run;
|
|
55
|
+
try { run = JSON.parse(readFile(path.join(gateStateDir(commonDir), "runs", `${nonce}.json`))); } catch { return `${refusal}; RK_GATE_RUN names no live door run`; }
|
|
56
|
+
if (!alive(run.pid)) return `${refusal}; the door run that launched it has ended`;
|
|
57
|
+
if (commit && run.commit !== commit) return `${refusal}; the door run is for ${String(run.commit).slice(0, 12)}, HEAD is ${commit.slice(0, 12)}`;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pidAlive(pid) {
|
|
62
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
63
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Reads the receipt for `commit`, or a fresh one bound to it. */
|
|
67
|
+
export function readReceipt({ commonDir, commit, readFile = (f) => fs.readFileSync(f, "utf8") }) {
|
|
68
|
+
try {
|
|
69
|
+
const r = JSON.parse(readFile(path.join(gateStateDir(commonDir), "receipts", `${commit}.json`)));
|
|
70
|
+
if (r.schema === RECEIPT_SCHEMA && r.commit === commit) return r;
|
|
71
|
+
} catch { /* none yet */ }
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function writeReceipt({ commonDir, receipt, write = fs.writeFileSync, mkdir = fs.mkdirSync }) {
|
|
76
|
+
const dir = path.join(gateStateDir(commonDir), "receipts");
|
|
77
|
+
mkdir(dir, { recursive: true });
|
|
78
|
+
write(path.join(dir, `${receipt.commit}.json`), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The inputs a receipt binds besides the commit: the tree id, each lockfile's digest, the gate's own digest and the toolchain versions.
|
|
83
|
+
* A receipt from an earlier run counts for a stage only when every one of these is unchanged.
|
|
84
|
+
*/
|
|
85
|
+
export function bindingOf({ tree, lockFiles = {}, gateFile, readFile = (f) => fs.readFileSync(f), toolchain = {}, executor }) {
|
|
86
|
+
let gate = null;
|
|
87
|
+
try { gate = sha256(readFile(gateFile)); } catch { /* unreadable: no binding, nothing reused */ }
|
|
88
|
+
return { tree, locks: Object.fromEntries(Object.entries(lockFiles).map(([k, v]) => [k, sha256(v)]).sort(([a], [b]) => a.localeCompare(b))), gate, toolchain, executor };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function sameBinding(a, b) {
|
|
92
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Same source: tree, lockfiles and gate digest equal (the host's toolchain and executor may differ). */
|
|
96
|
+
export function sameSource(a, b) {
|
|
97
|
+
return Boolean(a && b) && a.tree === b.tree && a.gate === b.gate && JSON.stringify(a.locks) === JSON.stringify(b.locks);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The sealed release directory build-release wrote for `commit` on `platform`: { id, manifest, digest } or null. */
|
|
101
|
+
export function sealedFor({ root, commit, platform, readdir = fs.readdirSync, readFile = (f) => fs.readFileSync(f) }) {
|
|
102
|
+
const base = path.join(root, ".right-release", "sealed");
|
|
103
|
+
let ids = [];
|
|
104
|
+
try { ids = readdir(base).filter((id) => id.endsWith(`-${commit.slice(0, 8)}`)); } catch { return null; }
|
|
105
|
+
for (const id of ids.sort()) {
|
|
106
|
+
const file = path.join(base, id, platform, "release-manifest.json");
|
|
107
|
+
try { const bytes = readFile(file); return { id, manifest: file, digest: sha256(bytes) }; } catch { /* not this platform */ }
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
// key file), sealed, and re-verified by `verifyPublicationRelease`. This module only turns the same
|
|
6
6
|
// `platforms` map the r2 lane builds ({ <platform>: { signature, url } }) into a Tauri v2 static `latest.json`
|
|
7
7
|
// and uploads it, with the bundles and their `.sig` files, as assets of the GitHub release `v<version>`, so
|
|
8
|
-
// `https://github.com/<owner>/<repo>/releases/latest/download/latest.json` resolves. It adds no key and no trust root
|
|
8
|
+
// `https://github.com/<owner>/<repo>/releases/latest/download/latest.json` resolves. It adds no key and no trust root:
|
|
9
|
+
// a Right Suite app's updater keys come from the Right Suite contract (`resolveUpdaterTrust`), never from the app's config.
|
|
9
10
|
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
10
11
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
12
|
import path from "node:path";
|
|
12
13
|
import { PublicationError, digestBytes, readVerifiedArtifact, verifyArtifactFile } from "./publication-artifacts.mjs";
|
|
13
14
|
import { inspectRemoteAsset, runCommand } from "./github-release.mjs";
|
|
15
|
+
import { RIGHT_SUITE_APP_KEYS, RIGHT_SUITE_UPDATER_PUBLIC_KEYS, RIGHT_SUITE_UPDATER_SIGNING_KEY_ID } from "./suite-api.generated.mjs";
|
|
14
16
|
|
|
15
17
|
const REPO_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
16
18
|
const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
|
|
@@ -18,7 +20,7 @@ const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z
|
|
|
18
20
|
const ASSET_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,199}$/;
|
|
19
21
|
// Tauri v2 updater platform keys: <os>-<arch>, optionally <os>-<arch>-<installer>.
|
|
20
22
|
const PLATFORM_RE = /^(?:windows|darwin|linux)-(?:x86_64|aarch64|i686|armv7)(?:-(?:nsis|msi|app|appimage|deb|rpm))?$/;
|
|
21
|
-
const
|
|
23
|
+
export const UPDATER_KEY_ID_RE = /^[0-9A-F]{16}$/;
|
|
22
24
|
|
|
23
25
|
export const LATEST_JSON_NAME = "latest.json";
|
|
24
26
|
|
|
@@ -72,7 +74,7 @@ function parseMinisignSignature(signatureText) {
|
|
|
72
74
|
if (bytes.toString("base64") !== text) throw new Error("invalid base64");
|
|
73
75
|
return bytes;
|
|
74
76
|
};
|
|
75
|
-
const envelope = decode(String(signatureText).
|
|
77
|
+
const envelope = decode(String(signatureText).replace(/^[ \t\n\f\r]+|[ \t\n\f\r]+$/g, ""));
|
|
76
78
|
const text = new TextDecoder("utf-8", { fatal: true }).decode(envelope);
|
|
77
79
|
const lines = text.split(/\r?\n/);
|
|
78
80
|
if (lines.at(-1) === "") lines.pop();
|
|
@@ -122,8 +124,8 @@ export function verifyMinisignSignature(bytes, signatureText, publicKeyText) {
|
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
/**
|
|
125
|
-
* Require a cryptographically valid minisign signature over `bytes` under
|
|
126
|
-
* The key-id check (`assertUpdaterKeyId`)
|
|
127
|
+
* Require a cryptographically valid minisign signature over `bytes` under `publicKeyText` (for a suite app, the
|
|
128
|
+
* contract key `resolveUpdaterTrust` returns). The key-id check (`assertUpdaterKeyId`) names the wrong key; this is the seal/upload gate.
|
|
127
129
|
*/
|
|
128
130
|
export function assertUpdaterSignature(bytes, signatureText, publicKeyText, label = "updater signature") {
|
|
129
131
|
if (!verifyMinisignSignature(bytes, signatureText, publicKeyText)) {
|
|
@@ -131,10 +133,48 @@ export function assertUpdaterSignature(bytes, signatureText, publicKeyText, labe
|
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
135
|
|
|
134
|
-
|
|
135
|
-
export
|
|
136
|
+
// Case-insensitively: release config accepts app names in any case, and a different spelling must not move the trust root out of the contract.
|
|
137
|
+
export const isSuiteUpdaterApp = (app) => typeof app === "string" && RIGHT_SUITE_APP_KEYS.includes(app.toLowerCase());
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The updater trust a sealed signature is checked against: `{ keyId, publicKey, suite }`. `now` is unix seconds and `keys` the
|
|
141
|
+
* accepted-key list, both injectable for tests.
|
|
142
|
+
*
|
|
143
|
+
* A Right Suite app (RIGHT_SUITE_APP_KEYS) takes its key from the contract so a config cannot swap the trust root: the key id is
|
|
144
|
+
* the configured `distribution.updaterKeyId` or else the contract's signing key, it must be a listed, unexpired key, and a declared
|
|
145
|
+
* public key must be that key's exact contract text. Any other app (Ember has its own updater key) keeps the declared key as the
|
|
146
|
+
* only trust root and the key id stays optional.
|
|
147
|
+
*/
|
|
148
|
+
export function resolveUpdaterTrust({ app, configuredPublicKey, configuredKeyId, now = Math.floor(Date.now() / 1000), keys = RIGHT_SUITE_UPDATER_PUBLIC_KEYS } = {}) {
|
|
149
|
+
if (!isSuiteUpdaterApp(app)) {
|
|
150
|
+
if (typeof configuredPublicKey !== "string" || !configuredPublicKey.trim()) {
|
|
151
|
+
throw new PublicationError("PUBLICATION_SIGNATURE_UNVERIFIABLE", `updater public key is required to verify updater artifacts: ${app ?? "this app"} is not a Right Suite app and declares no updater public key (distribution.updaterPublicKey); refusing an unverified signature`);
|
|
152
|
+
}
|
|
153
|
+
return { keyId: configuredKeyId, publicKey: configuredPublicKey, suite: false };
|
|
154
|
+
}
|
|
155
|
+
const keyId = configuredKeyId ?? RIGHT_SUITE_UPDATER_SIGNING_KEY_ID;
|
|
156
|
+
const accepted = UPDATER_KEY_ID_RE.test(keyId) ? keys.find((key) => key.kid === keyId) : undefined;
|
|
157
|
+
if (!accepted) {
|
|
158
|
+
throw new PublicationError("PUBLICATION_SIGNATURE_KEY_MISMATCH", `updater key ${JSON.stringify(keyId)} is not an updater key in the Right Suite contract (key ids are 16 upper-case hex digits; accepted: ${keys.map((key) => key.kid).join(", ")}); omit distribution.updaterKeyId to use the signing key`);
|
|
159
|
+
}
|
|
160
|
+
if (accepted.notAfterUnix != null && accepted.notAfterUnix <= now) {
|
|
161
|
+
throw new PublicationError("PUBLICATION_SIGNATURE_KEY_MISMATCH", `updater key ${keyId} ended${accepted.notAfter ? ` (notAfter ${accepted.notAfter})` : ""} in the Right Suite contract and is no longer accepted`);
|
|
162
|
+
}
|
|
163
|
+
const declared = configuredPublicKey !== undefined && configuredPublicKey !== null && !(typeof configuredPublicKey === "string" && !configuredPublicKey.trim());
|
|
164
|
+
if (declared && configuredPublicKey !== accepted.publicKey) {
|
|
165
|
+
throw new PublicationError("PUBLICATION_SIGNATURE_KEY_MISMATCH", `the declared updater public key is not the Right Suite contract key for ${keyId}; suite apps do not declare their own`);
|
|
166
|
+
}
|
|
167
|
+
return { keyId, publicKey: accepted.publicKey, suite: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Refuses a signature made by any key other than `expectedKeyId`. Pass `trust.keyId` from `resolveUpdaterTrust`. An undefined id
|
|
172
|
+
* is a no-op only for an app outside the Right Suite; with `app` set to a suite app the id is resolved and enforced even when omitted.
|
|
173
|
+
*/
|
|
174
|
+
export function assertUpdaterKeyId(signatureText, expectedKeyId, label = "updater signature", { app, now, keys } = {}) {
|
|
175
|
+
if (isSuiteUpdaterApp(app)) expectedKeyId = resolveUpdaterTrust({ app, configuredKeyId: expectedKeyId, now, keys }).keyId;
|
|
136
176
|
if (expectedKeyId === undefined) return;
|
|
137
|
-
if (!
|
|
177
|
+
if (!UPDATER_KEY_ID_RE.test(expectedKeyId)) throw new PublicationError("PUBLICATION_SIGNATURE_KEY_MISMATCH", "distribution.updaterKeyId must be 16 upper-case hex digits");
|
|
138
178
|
const actual = updaterSignatureKeyId(signatureText);
|
|
139
179
|
if (actual !== expectedKeyId) throw new PublicationError("PUBLICATION_SIGNATURE_KEY_MISMATCH", `${label} was not made by updater key ${expectedKeyId} (found ${actual ?? "unreadable"})`);
|
|
140
180
|
}
|