@openthink/stamp 2.0.2 → 2.1.1
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/dist/{chunk-4PFD2DSY.js → chunk-MJULVH4B.js} +2 -2
- package/dist/{chunk-4PFD2DSY.js.map → chunk-MJULVH4B.js.map} +1 -1
- package/dist/hooks/post-receive.cjs +10 -6
- package/dist/hooks/post-receive.cjs.map +1 -1
- package/dist/hooks/pre-receive.cjs +25 -48
- package/dist/hooks/pre-receive.cjs.map +1 -1
- package/dist/index.js +1204 -234
- package/dist/index.js.map +1 -1
- package/dist/server/stamp-review.cjs +8399 -7460
- package/dist/server/stamp-review.cjs.map +1 -1
- package/dist/{ui-P5DRAT3P.js → ui-67BDQPER.js} +2 -2
- package/package.json +1 -1
- /package/dist/{ui-P5DRAT3P.js.map → ui-67BDQPER.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
userKeysDir,
|
|
53
53
|
userServerConfigPath,
|
|
54
54
|
verifyBytes
|
|
55
|
-
} from "./chunk-
|
|
55
|
+
} from "./chunk-MJULVH4B.js";
|
|
56
56
|
|
|
57
57
|
// src/index.ts
|
|
58
58
|
import { Command } from "commander";
|
|
@@ -541,8 +541,12 @@ function validateConfig(input) {
|
|
|
541
541
|
throw new Error(`config.reviewers.${name} must be an object`);
|
|
542
542
|
}
|
|
543
543
|
const d = def;
|
|
544
|
-
|
|
545
|
-
|
|
544
|
+
let prompt2;
|
|
545
|
+
if (d.prompt !== void 0) {
|
|
546
|
+
if (typeof d.prompt !== "string") {
|
|
547
|
+
throw new Error(`config.reviewers.${name}.prompt must be a string`);
|
|
548
|
+
}
|
|
549
|
+
prompt2 = d.prompt;
|
|
546
550
|
}
|
|
547
551
|
const tools = parseTools(d.tools, name);
|
|
548
552
|
const mcp_servers = parseMcpServers(d.mcp_servers, name);
|
|
@@ -564,7 +568,7 @@ function validateConfig(input) {
|
|
|
564
568
|
`config.reviewers.${name}.timeout_ms`
|
|
565
569
|
);
|
|
566
570
|
reviewers2[name] = {
|
|
567
|
-
prompt:
|
|
571
|
+
...prompt2 !== void 0 ? { prompt: prompt2 } : {},
|
|
568
572
|
...tools ? { tools } : {},
|
|
569
573
|
...mcp_servers ? { mcp_servers } : {},
|
|
570
574
|
...enforce_reads_on_dotstamp !== void 0 ? { enforce_reads_on_dotstamp } : {},
|
|
@@ -1412,8 +1416,8 @@ import { createHash as createHash3, createPublicKey, verify } from "crypto";
|
|
|
1412
1416
|
import { spawn } from "child_process";
|
|
1413
1417
|
|
|
1414
1418
|
// src/lib/attestationV4.ts
|
|
1415
|
-
var CURRENT_V4_SCHEMA_VERSION =
|
|
1416
|
-
var MIN_ACCEPTED_V4_SCHEMA_VERSION =
|
|
1419
|
+
var CURRENT_V4_SCHEMA_VERSION = 5;
|
|
1420
|
+
var MIN_ACCEPTED_V4_SCHEMA_VERSION = 5;
|
|
1417
1421
|
var MAX_V4_ENVELOPE_BYTES = 64 * 1024;
|
|
1418
1422
|
function sortKeysDeep(value) {
|
|
1419
1423
|
if (value === null || typeof value !== "object") return value;
|
|
@@ -1547,7 +1551,6 @@ function parseResponseJson(raw) {
|
|
|
1547
1551
|
"diff_sha256",
|
|
1548
1552
|
"base_sha",
|
|
1549
1553
|
"head_sha",
|
|
1550
|
-
"trusted_keys_snapshot_sha256",
|
|
1551
1554
|
"issued_at",
|
|
1552
1555
|
"server_key_id"
|
|
1553
1556
|
]) {
|
|
@@ -1945,6 +1948,7 @@ function buildTrustAnchorPayload(input) {
|
|
|
1945
1948
|
head_sha: input.headSha,
|
|
1946
1949
|
target_branch: input.targetBranch,
|
|
1947
1950
|
diff_sha256: input.diffSha256,
|
|
1951
|
+
manifest_snapshot_sha256: input.manifestSnapshotSha256,
|
|
1948
1952
|
approvals: input.approvals,
|
|
1949
1953
|
checks: input.checks,
|
|
1950
1954
|
trust_anchor_signatures: [],
|
|
@@ -2027,6 +2031,11 @@ var COMMIT_PHASES_V4 = [
|
|
|
2027
2031
|
{ name: "verifyV4TargetBranch", fn: verifyV4TargetBranch },
|
|
2028
2032
|
{ name: "verifyV4SignerTrust", fn: verifyV4SignerTrust },
|
|
2029
2033
|
{ name: "verifyV4OuterSignature", fn: verifyV4OuterSignature },
|
|
2034
|
+
// AGT-370: envelope-level manifest snapshot binding (lifted from
|
|
2035
|
+
// the per-approval slot in v4). Runs once before the per-approval
|
|
2036
|
+
// loop in verifyV4ApprovalSignatures — a single check replaces the
|
|
2037
|
+
// N-checks per envelope the v4 verifier did.
|
|
2038
|
+
{ name: "verifyV4ManifestSnapshot", fn: verifyV4ManifestSnapshot },
|
|
2030
2039
|
{ name: "verifyV4Approvals", fn: verifyV4Approvals },
|
|
2031
2040
|
{ name: "verifyV4DiffHash", fn: verifyV4DiffHash },
|
|
2032
2041
|
{ name: "verifyV4ApprovalSignatures", fn: verifyV4ApprovalSignatures },
|
|
@@ -2159,10 +2168,19 @@ function verifyV4DiffHash(input) {
|
|
|
2159
2168
|
}
|
|
2160
2169
|
return { ok: true };
|
|
2161
2170
|
}
|
|
2171
|
+
function verifyV4ManifestSnapshot(input) {
|
|
2172
|
+
const { sha, payload, manifest } = input;
|
|
2173
|
+
const computed = snapshotSha256(manifest);
|
|
2174
|
+
if (payload.manifest_snapshot_sha256 !== computed) {
|
|
2175
|
+
return {
|
|
2176
|
+
ok: false,
|
|
2177
|
+
reason: `commit ${sha.slice(0, 8)}: v4 manifest_snapshot_sha256 (${payload.manifest_snapshot_sha256.slice(0, 16)}\u2026) does not match the manifest at base ${payload.base_sha.slice(0, 8)} (${computed.slice(0, 16)}\u2026). The envelope was signed against a different snapshot of the trust set than the one committed at the merge base. Re-run \`stamp merge\` (or \`stamp attest\`) so the outer signature binds to the current manifest.`
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
return { ok: true };
|
|
2181
|
+
}
|
|
2162
2182
|
function verifyV4ApprovalSignatures(input) {
|
|
2163
2183
|
const { sha, payload, manifest, pubkeyByFingerprint } = input;
|
|
2164
|
-
const manifestSnapshot = snapshotSha256(manifest);
|
|
2165
|
-
const reviewerDefs = readReviewerDefsAtRef(payload.base_sha);
|
|
2166
2184
|
for (const entry of payload.approvals) {
|
|
2167
2185
|
const a = entry.approval;
|
|
2168
2186
|
const reviewerLabel = `"${a.reviewer}"`;
|
|
@@ -2184,12 +2202,6 @@ function verifyV4ApprovalSignatures(input) {
|
|
|
2184
2202
|
reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: server_attestation.server_key_id (${entry.server_attestation.server_key_id}) does not match inner approval.server_key_id (${a.server_key_id}). The inner signed payload is authoritative; one of the two was tampered with after signing.`
|
|
2185
2203
|
};
|
|
2186
2204
|
}
|
|
2187
|
-
if (a.trusted_keys_snapshot_sha256 !== manifestSnapshot) {
|
|
2188
|
-
return {
|
|
2189
|
-
ok: false,
|
|
2190
|
-
reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: trusted_keys_snapshot_sha256 (${a.trusted_keys_snapshot_sha256.slice(0, 16)}\u2026) does not match the manifest at base ${payload.base_sha.slice(0, 8)} (${manifestSnapshot.slice(0, 16)}\u2026). The server signed against a different snapshot of the trust set than the one committed at the merge base.`
|
|
2191
|
-
};
|
|
2192
|
-
}
|
|
2193
2205
|
const caps = resolveCapability(manifest, a.server_key_id);
|
|
2194
2206
|
if (caps === null) {
|
|
2195
2207
|
return {
|
|
@@ -2229,29 +2241,6 @@ function verifyV4ApprovalSignatures(input) {
|
|
|
2229
2241
|
reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: server signature does not verify against ${a.server_key_id} over canonical approval bytes`
|
|
2230
2242
|
};
|
|
2231
2243
|
}
|
|
2232
|
-
const def = reviewerDefs[a.reviewer];
|
|
2233
|
-
if (!def) {
|
|
2234
|
-
return {
|
|
2235
|
-
ok: false,
|
|
2236
|
-
reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: reviewer is not defined in .stamp/config.yml at base ${payload.base_sha.slice(0, 8)}`
|
|
2237
|
-
};
|
|
2238
|
-
}
|
|
2239
|
-
let promptText;
|
|
2240
|
-
try {
|
|
2241
|
-
promptText = run(["show", `${payload.base_sha}:${def.prompt}`]);
|
|
2242
|
-
} catch {
|
|
2243
|
-
return {
|
|
2244
|
-
ok: false,
|
|
2245
|
-
reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: prompt "${def.prompt}" is unreadable at base ${payload.base_sha.slice(0, 8)}`
|
|
2246
|
-
};
|
|
2247
|
-
}
|
|
2248
|
-
const recomputedPromptSha = hashPromptBytes(Buffer.from(promptText, "utf8"));
|
|
2249
|
-
if (recomputedPromptSha !== a.prompt_sha256) {
|
|
2250
|
-
return {
|
|
2251
|
-
ok: false,
|
|
2252
|
-
reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: prompt_sha256 mismatch \u2014 server signed ${a.prompt_sha256.slice(0, 12)}\u2026 but prompt file at base hashes to ${recomputedPromptSha.slice(0, 12)}\u2026. The reviewer prompt the server reviewed differs from the one in the merge-base tree.`
|
|
2253
|
-
};
|
|
2254
|
-
}
|
|
2255
2244
|
}
|
|
2256
2245
|
return { ok: true };
|
|
2257
2246
|
}
|
|
@@ -2417,22 +2406,6 @@ function readPubkeyMapAt(ref) {
|
|
|
2417
2406
|
}
|
|
2418
2407
|
return buildPubkeyMap(names, (relPath) => run(["show", `${ref}:${relPath}`]));
|
|
2419
2408
|
}
|
|
2420
|
-
function readReviewerDefsAtRef(ref) {
|
|
2421
|
-
let yaml;
|
|
2422
|
-
try {
|
|
2423
|
-
yaml = run(["show", `${ref}:.stamp/config.yml`]);
|
|
2424
|
-
} catch {
|
|
2425
|
-
return {};
|
|
2426
|
-
}
|
|
2427
|
-
const defs = readReviewersFromYaml(yaml);
|
|
2428
|
-
const out = {};
|
|
2429
|
-
for (const [name, def] of Object.entries(defs)) {
|
|
2430
|
-
if (def && typeof def.prompt === "string") {
|
|
2431
|
-
out[name] = { prompt: def.prompt };
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
return out;
|
|
2435
|
-
}
|
|
2436
2409
|
function readChangedFilesAtRef(baseSha, headSha) {
|
|
2437
2410
|
let out;
|
|
2438
2411
|
try {
|
|
@@ -2820,6 +2793,12 @@ function verifyReviewerHashesAtMergeBase(input) {
|
|
|
2820
2793
|
reason: `${prefix} reviewer "${approval.reviewer}" not defined in .stamp/config.yml at merge-base`
|
|
2821
2794
|
};
|
|
2822
2795
|
}
|
|
2796
|
+
if (def.prompt === void 0) {
|
|
2797
|
+
return {
|
|
2798
|
+
ok: false,
|
|
2799
|
+
reason: `${prefix} reviewer "${approval.reviewer}" has no \`prompt:\` in .stamp/config.yml at merge-base; v3 attestation references prompt_sha256 but the producer flow for server-bundled prompts is v4 (server-attested). The attestation envelope and the config shape are inconsistent.`
|
|
2800
|
+
};
|
|
2801
|
+
}
|
|
2823
2802
|
let promptBytes;
|
|
2824
2803
|
try {
|
|
2825
2804
|
promptBytes = run2(["show", `${baseSha}:${def.prompt}`]);
|
|
@@ -3403,6 +3382,11 @@ function buildV3Trailers(input) {
|
|
|
3403
3382
|
`reviewer "${a.reviewer}" approved the diff but is not defined in .stamp/config.yml at base ${input.baseSha.slice(0, 8)}. This shouldn't happen \u2014 runReview reads from the same base. File a bug at https://github.com/OpenThinkAi/stamp-cli/issues. Merge rolled back.`
|
|
3404
3383
|
);
|
|
3405
3384
|
}
|
|
3385
|
+
if (def.prompt === void 0) {
|
|
3386
|
+
throw new Error(
|
|
3387
|
+
`reviewer "${a.reviewer}": no \`prompt:\` configured and no \`review_server:\` on branch rule \u2014 set \`reviewers.${a.reviewer}.prompt\` in .stamp/config.yml or configure a \`review_server:\` for server-attested mode. Merge rolled back.`
|
|
3388
|
+
);
|
|
3389
|
+
}
|
|
3406
3390
|
const promptText = showAtRef(input.baseSha, def.prompt, input.repoRoot);
|
|
3407
3391
|
const source = readReviewerSource(a.reviewer, input.repoRoot);
|
|
3408
3392
|
return {
|
|
@@ -3489,7 +3473,6 @@ function buildV4Trailers(input) {
|
|
|
3489
3473
|
"diff_sha256",
|
|
3490
3474
|
"base_sha",
|
|
3491
3475
|
"head_sha",
|
|
3492
|
-
"trusted_keys_snapshot_sha256",
|
|
3493
3476
|
"issued_at",
|
|
3494
3477
|
"server_key_id"
|
|
3495
3478
|
]) {
|
|
@@ -3569,12 +3552,14 @@ function buildV4Trailers(input) {
|
|
|
3569
3552
|
exit_code: c.exit_code,
|
|
3570
3553
|
output_sha: c.output_sha
|
|
3571
3554
|
}));
|
|
3555
|
+
const manifestSnapshot = snapshotSha256(manifest);
|
|
3572
3556
|
const trustAnchorSigs = collectTrustAnchorSignatures({
|
|
3573
3557
|
repoRoot: input.repoRoot,
|
|
3574
3558
|
baseSha: input.baseSha,
|
|
3575
3559
|
headSha: input.headSha,
|
|
3576
3560
|
targetBranch: input.targetBranch,
|
|
3577
3561
|
diffSha256,
|
|
3562
|
+
manifestSnapshotSha256: manifestSnapshot,
|
|
3578
3563
|
approvals: entries,
|
|
3579
3564
|
checks: v4Checks,
|
|
3580
3565
|
operatorFingerprint: input.operatorFingerprint,
|
|
@@ -3587,6 +3572,7 @@ function buildV4Trailers(input) {
|
|
|
3587
3572
|
head_sha: input.headSha,
|
|
3588
3573
|
target_branch: input.targetBranch,
|
|
3589
3574
|
diff_sha256: diffSha256,
|
|
3575
|
+
manifest_snapshot_sha256: manifestSnapshot,
|
|
3590
3576
|
approvals: entries,
|
|
3591
3577
|
checks: v4Checks,
|
|
3592
3578
|
trust_anchor_signatures: trustAnchorSigs,
|
|
@@ -3638,6 +3624,7 @@ function collectTrustAnchorSignatures(input) {
|
|
|
3638
3624
|
headSha: input.headSha,
|
|
3639
3625
|
targetBranch: input.targetBranch,
|
|
3640
3626
|
diffSha256: input.diffSha256,
|
|
3627
|
+
manifestSnapshotSha256: input.manifestSnapshotSha256,
|
|
3641
3628
|
approvals: input.approvals,
|
|
3642
3629
|
checks: input.checks,
|
|
3643
3630
|
signerKeyId: input.operatorFingerprint
|
|
@@ -4731,6 +4718,11 @@ function buildReviewPlan(opts) {
|
|
|
4731
4718
|
const reviewers2 = [];
|
|
4732
4719
|
for (const name of reviewerNames) {
|
|
4733
4720
|
const def = config2.reviewers[name];
|
|
4721
|
+
if (def.prompt === void 0) {
|
|
4722
|
+
throw new Error(
|
|
4723
|
+
`reviewer "${name}": no \`prompt:\` configured and no \`review_server:\` on branch rule \u2014 set \`reviewers.${name}.prompt\` in .stamp/config.yml or configure a \`review_server:\` for server-attested mode.`
|
|
4724
|
+
);
|
|
4725
|
+
}
|
|
4734
4726
|
let prompt2;
|
|
4735
4727
|
try {
|
|
4736
4728
|
prompt2 = showAtRef(resolved.base_sha, def.prompt, opts.repoRoot);
|
|
@@ -5340,19 +5332,6 @@ async function runReview(opts) {
|
|
|
5340
5332
|
`no reviewers to run at base ${resolved.base_sha.slice(0, 8)} (config there has ${Object.keys(config2.reviewers).length} configured). If this branch ADDS a new reviewer, the new reviewer cannot review its own introduction \u2014 that's a deliberate security boundary. Land the reviewer in a separate PR first, then it can review subsequent diffs.`
|
|
5341
5333
|
);
|
|
5342
5334
|
}
|
|
5343
|
-
const promptBytesByReviewer = /* @__PURE__ */ new Map();
|
|
5344
|
-
for (const name of reviewerNames) {
|
|
5345
|
-
const def = config2.reviewers[name];
|
|
5346
|
-
let bytes;
|
|
5347
|
-
try {
|
|
5348
|
-
bytes = showAtRef(resolved.base_sha, def.prompt, repoRoot);
|
|
5349
|
-
} catch (err) {
|
|
5350
|
-
throw new Error(
|
|
5351
|
-
`failed to read prompt for reviewer "${name}" from base ${resolved.base_sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}. (The reviewer is configured at the base but its prompt file is missing there.)`
|
|
5352
|
-
);
|
|
5353
|
-
}
|
|
5354
|
-
promptBytesByReviewer.set(name, bytes);
|
|
5355
|
-
}
|
|
5356
5335
|
const targetBranch = opts.into ?? inferTargetBranch(opts.diff);
|
|
5357
5336
|
const branchRule = targetBranch ? findBranchRule(config2.branches, targetBranch) : void 0;
|
|
5358
5337
|
if (branchRule?.review_server) {
|
|
@@ -5360,7 +5339,7 @@ async function runReview(opts) {
|
|
|
5360
5339
|
opts,
|
|
5361
5340
|
config: config2,
|
|
5362
5341
|
reviewerNames,
|
|
5363
|
-
promptBytesByReviewer,
|
|
5342
|
+
promptBytesByReviewer: /* @__PURE__ */ new Map(),
|
|
5364
5343
|
resolved,
|
|
5365
5344
|
repoRoot,
|
|
5366
5345
|
reviewServerUrl: branchRule.review_server,
|
|
@@ -5368,6 +5347,24 @@ async function runReview(opts) {
|
|
|
5368
5347
|
});
|
|
5369
5348
|
return;
|
|
5370
5349
|
}
|
|
5350
|
+
const promptBytesByReviewer = /* @__PURE__ */ new Map();
|
|
5351
|
+
for (const name of reviewerNames) {
|
|
5352
|
+
const def = config2.reviewers[name];
|
|
5353
|
+
if (def.prompt === void 0) {
|
|
5354
|
+
throw new Error(
|
|
5355
|
+
`reviewer "${name}": no \`prompt:\` configured and no \`review_server:\` on branch rule \u2014 set \`reviewers.${name}.prompt\` in .stamp/config.yml or configure a \`review_server:\` for server-attested mode.`
|
|
5356
|
+
);
|
|
5357
|
+
}
|
|
5358
|
+
let bytes;
|
|
5359
|
+
try {
|
|
5360
|
+
bytes = showAtRef(resolved.base_sha, def.prompt, repoRoot);
|
|
5361
|
+
} catch (err) {
|
|
5362
|
+
throw new Error(
|
|
5363
|
+
`failed to read prompt for reviewer "${name}" from base ${resolved.base_sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}. (The reviewer is configured at the base but its prompt file is missing there.)`
|
|
5364
|
+
);
|
|
5365
|
+
}
|
|
5366
|
+
promptBytesByReviewer.set(name, bytes);
|
|
5367
|
+
}
|
|
5371
5368
|
maybePrintLlmNotice(repoRoot);
|
|
5372
5369
|
const userCfg = loadOrCreateUserConfig();
|
|
5373
5370
|
if (userCfg.created) {
|
|
@@ -5896,6 +5893,7 @@ function buildPlan(current, targetBranch, targetRule, opts) {
|
|
|
5896
5893
|
}
|
|
5897
5894
|
newReviewersConfig = seed.config.reviewers;
|
|
5898
5895
|
for (const [name, def] of Object.entries(seed.config.reviewers)) {
|
|
5896
|
+
if (def.prompt === void 0) continue;
|
|
5899
5897
|
const promptBody = seed.reviewerFiles.get(def.prompt);
|
|
5900
5898
|
if (promptBody === void 0) {
|
|
5901
5899
|
throw new Error(
|
|
@@ -9487,11 +9485,13 @@ function signPending(repoRoot, rawSha, opts) {
|
|
|
9487
9485
|
}
|
|
9488
9486
|
predictedSigner = opts.signerKeyId;
|
|
9489
9487
|
}
|
|
9488
|
+
const manifestSnapshotSha256 = snapshotSha256(manifest);
|
|
9490
9489
|
const signingBytes = trustAnchorSigningBytes({
|
|
9491
9490
|
baseSha,
|
|
9492
9491
|
headSha,
|
|
9493
9492
|
targetBranch,
|
|
9494
9493
|
diffSha256,
|
|
9494
|
+
manifestSnapshotSha256,
|
|
9495
9495
|
approvals,
|
|
9496
9496
|
checks: [],
|
|
9497
9497
|
// see trustAnchorPayload.ts "Operational caveat"
|
|
@@ -10140,8 +10140,9 @@ function printReviewHistory(repoRoot, limit, diff) {
|
|
|
10140
10140
|
}
|
|
10141
10141
|
|
|
10142
10142
|
// src/commands/attest.ts
|
|
10143
|
-
import { spawnSync as
|
|
10143
|
+
import { spawnSync as spawnSync15 } from "child_process";
|
|
10144
10144
|
import { createHash as createHash12 } from "crypto";
|
|
10145
|
+
import { parse as parseYaml11 } from "yaml";
|
|
10145
10146
|
|
|
10146
10147
|
// src/lib/patchId.ts
|
|
10147
10148
|
import { spawnSync as spawnSync12 } from "child_process";
|
|
@@ -10215,8 +10216,18 @@ function parseEnvelope(bytes) {
|
|
|
10215
10216
|
return null;
|
|
10216
10217
|
}
|
|
10217
10218
|
if (typeof p.diff_sha256 !== "string") return null;
|
|
10219
|
+
if (typeof p.manifest_snapshot_sha256 !== "string") return null;
|
|
10218
10220
|
if (!Array.isArray(p.trust_anchor_signatures)) return null;
|
|
10219
10221
|
if (typeof p.target_branch_tip_sha !== "string") return null;
|
|
10222
|
+
if (p.migration_bootstrap !== void 0) {
|
|
10223
|
+
const m = p.migration_bootstrap;
|
|
10224
|
+
if (!m || typeof m !== "object" || Array.isArray(m)) return null;
|
|
10225
|
+
if (!Array.isArray(m.activated_paths)) return null;
|
|
10226
|
+
if (m.activated_paths.length === 0) return null;
|
|
10227
|
+
for (const ap of m.activated_paths) {
|
|
10228
|
+
if (typeof ap !== "string" || ap.length === 0) return null;
|
|
10229
|
+
}
|
|
10230
|
+
}
|
|
10220
10231
|
return env;
|
|
10221
10232
|
}
|
|
10222
10233
|
function peekSchemaVersion(bytes) {
|
|
@@ -10289,154 +10300,572 @@ function readAttestationBlobBytes(patch_id, repoRoot) {
|
|
|
10289
10300
|
return cat.stdout;
|
|
10290
10301
|
}
|
|
10291
10302
|
|
|
10292
|
-
// src/
|
|
10293
|
-
|
|
10294
|
-
|
|
10295
|
-
|
|
10296
|
-
const
|
|
10297
|
-
|
|
10298
|
-
|
|
10299
|
-
|
|
10300
|
-
|
|
10301
|
-
}
|
|
10302
|
-
const branchRef = opts.branch ?? "HEAD";
|
|
10303
|
-
const revspec = `${opts.into}..${branchRef}`;
|
|
10304
|
-
const resolved = resolveDiff(revspec, repoRoot);
|
|
10305
|
-
const patch_id = patchIdForSpan(resolved.base_sha, resolved.head_sha, repoRoot);
|
|
10306
|
-
const target_branch_tip_sha = runGit(
|
|
10307
|
-
["rev-parse", `${opts.into}^{commit}`],
|
|
10308
|
-
repoRoot
|
|
10309
|
-
).trim();
|
|
10310
|
-
const { keypair } = ensureUserKeypair();
|
|
10311
|
-
const result = rule.review_server ? buildV3Envelope({
|
|
10312
|
-
repoRoot,
|
|
10313
|
-
revspec,
|
|
10314
|
-
baseSha: resolved.base_sha,
|
|
10315
|
-
headSha: resolved.head_sha,
|
|
10316
|
-
diff: resolved.diff,
|
|
10317
|
-
targetBranch: opts.into,
|
|
10318
|
-
targetBranchTipSha: target_branch_tip_sha,
|
|
10319
|
-
patchId: patch_id,
|
|
10320
|
-
requiredReviewers: rule.required,
|
|
10321
|
-
operatorPrivateKeyPem: keypair.privateKeyPem,
|
|
10322
|
-
operatorFingerprint: keypair.fingerprint
|
|
10323
|
-
}) : buildV2Envelope({
|
|
10324
|
-
repoRoot,
|
|
10325
|
-
revspec,
|
|
10326
|
-
baseSha: resolved.base_sha,
|
|
10327
|
-
headSha: resolved.head_sha,
|
|
10328
|
-
targetBranch: opts.into,
|
|
10329
|
-
targetBranchTipSha: target_branch_tip_sha,
|
|
10330
|
-
patchId: patch_id,
|
|
10331
|
-
requiredReviewers: rule.required,
|
|
10332
|
-
operatorPrivateKeyPem: keypair.privateKeyPem,
|
|
10333
|
-
operatorFingerprint: keypair.fingerprint
|
|
10334
|
-
});
|
|
10335
|
-
const { ref, blob_sha } = writeAttestationRef(
|
|
10336
|
-
{ payload: result.payload, signature: result.signature },
|
|
10337
|
-
repoRoot
|
|
10338
|
-
);
|
|
10339
|
-
const bar = "\u2500".repeat(72);
|
|
10340
|
-
console.log(bar);
|
|
10341
|
-
console.log(`attested ${branchRef} for merge into '${opts.into}'`);
|
|
10342
|
-
console.log(bar);
|
|
10343
|
-
console.log(` patch-id: ${patch_id}`);
|
|
10344
|
-
console.log(
|
|
10345
|
-
` base\u2192head: ${resolved.base_sha.slice(0, 8)} \u2192 ${resolved.head_sha.slice(0, 8)}`
|
|
10346
|
-
);
|
|
10347
|
-
console.log(` signed by: ${keypair.fingerprint}`);
|
|
10348
|
-
console.log(` approvals: ${result.reviewerNames.join(", ")}`);
|
|
10349
|
-
console.log(
|
|
10350
|
-
` schema: v${result.payload.schema_version}${result.payload.schema_version === PR_ATTESTATION_SCHEMA_VERSION ? " (server-attested)" : " (legacy)"}`
|
|
10303
|
+
// src/lib/migrationBootstrap.ts
|
|
10304
|
+
import { spawnSync as spawnSync14 } from "child_process";
|
|
10305
|
+
import { parse as parseYaml10 } from "yaml";
|
|
10306
|
+
function validateShape4ActivationDiff(input) {
|
|
10307
|
+
const { repoRoot, baseSha, headSha } = input;
|
|
10308
|
+
const changedFilesResult = spawnSync14(
|
|
10309
|
+
"git",
|
|
10310
|
+
["diff", "-z", "--name-status", `${baseSha}...${headSha}`],
|
|
10311
|
+
{ cwd: repoRoot, encoding: "utf8" }
|
|
10351
10312
|
);
|
|
10352
|
-
|
|
10353
|
-
|
|
10354
|
-
|
|
10355
|
-
|
|
10356
|
-
|
|
10357
|
-
console.log(
|
|
10358
|
-
`
|
|
10359
|
-
\u2713 pushed branch + attestation ref to ${opts.pushTo}. Open the PR; stamp/verify-attestation@v1 will look up refs/stamp/attestations/<patch-id> from your head SHA's diff against the base.`
|
|
10360
|
-
);
|
|
10361
|
-
} else {
|
|
10362
|
-
console.log(
|
|
10363
|
-
`
|
|
10364
|
-
Next: push the branch + attestation ref to your remote, open a PR, and let stamp/verify-attestation@v1 (the GH Action) confirm it. To do both pushes in one shot:
|
|
10365
|
-
|
|
10366
|
-
git push <remote> HEAD ${ref}
|
|
10367
|
-
|
|
10368
|
-
Or re-run with --push <remote> next time.`
|
|
10369
|
-
);
|
|
10313
|
+
if (changedFilesResult.status !== 0) {
|
|
10314
|
+
return {
|
|
10315
|
+
ok: false,
|
|
10316
|
+
reason: `could not enumerate diff between ${baseSha.slice(0, 8)} and ${headSha.slice(0, 8)}: ${(changedFilesResult.stderr ?? "").trim()}`
|
|
10317
|
+
};
|
|
10370
10318
|
}
|
|
10371
|
-
|
|
10372
|
-
|
|
10373
|
-
|
|
10374
|
-
|
|
10375
|
-
|
|
10376
|
-
|
|
10377
|
-
|
|
10378
|
-
|
|
10379
|
-
|
|
10380
|
-
|
|
10381
|
-
|
|
10382
|
-
|
|
10383
|
-
|
|
10384
|
-
|
|
10385
|
-
|
|
10319
|
+
const tokens = (changedFilesResult.stdout ?? "").split("\0").filter((s) => s.length > 0);
|
|
10320
|
+
const entries = [];
|
|
10321
|
+
for (let i = 0; i < tokens.length; ) {
|
|
10322
|
+
const status = tokens[i];
|
|
10323
|
+
if (/^[RC]\d*$/.test(status)) {
|
|
10324
|
+
return {
|
|
10325
|
+
ok: false,
|
|
10326
|
+
reason: `bootstrap diff contains a rename/copy change (status "${status}") \u2014 only add/modify is permitted in a Shape 4 activation`
|
|
10327
|
+
};
|
|
10328
|
+
}
|
|
10329
|
+
const path2 = tokens[i + 1];
|
|
10330
|
+
if (path2 === void 0) {
|
|
10331
|
+
return {
|
|
10332
|
+
ok: false,
|
|
10333
|
+
reason: `bootstrap diff: malformed git output (status "${status}" with no path)`
|
|
10334
|
+
};
|
|
10335
|
+
}
|
|
10336
|
+
entries.push({ status, path: path2 });
|
|
10337
|
+
i += 2;
|
|
10386
10338
|
}
|
|
10387
|
-
|
|
10388
|
-
|
|
10389
|
-
|
|
10390
|
-
|
|
10391
|
-
|
|
10339
|
+
if (entries.length === 0) {
|
|
10340
|
+
return {
|
|
10341
|
+
ok: false,
|
|
10342
|
+
reason: "bootstrap diff is empty \u2014 nothing to activate"
|
|
10343
|
+
};
|
|
10392
10344
|
}
|
|
10393
|
-
const
|
|
10394
|
-
|
|
10395
|
-
|
|
10396
|
-
|
|
10397
|
-
|
|
10398
|
-
|
|
10399
|
-
|
|
10400
|
-
|
|
10401
|
-
|
|
10402
|
-
const
|
|
10403
|
-
|
|
10404
|
-
|
|
10405
|
-
|
|
10406
|
-
|
|
10407
|
-
|
|
10408
|
-
|
|
10409
|
-
if (!row) {
|
|
10410
|
-
throw new Error(
|
|
10411
|
-
`missing server signature for reviewer "${reviewerName}" at base\u2192head ${input.baseSha.slice(0, 8)}\u2192${input.headSha.slice(0, 8)}. Server-attested PR mode requires every required reviewer to have a stamp-server-signed approval in the local DB. Possible causes:
|
|
10412
|
-
\u2022 the stamp-server is older than 2.0.1 and doesn't produce PR-attestation v3 payloads (upgrade the server)
|
|
10413
|
-
\u2022 \`stamp review --diff ${input.revspec}\` hasn't been run against this exact (base, head) pair yet
|
|
10414
|
-
\u2022 the review was run in local-mode (no \`review_server\` configured at review time)
|
|
10415
|
-
Run \`stamp review --diff ${input.revspec}\` to populate the signed row, then re-run \`stamp attest\`.`
|
|
10416
|
-
);
|
|
10345
|
+
for (const e of entries) {
|
|
10346
|
+
if (!e.path.startsWith(".stamp/")) {
|
|
10347
|
+
return {
|
|
10348
|
+
ok: false,
|
|
10349
|
+
reason: `bootstrap diff touches "${e.path}" outside .stamp/ \u2014 the bootstrap flag accepts only Shape-4-activation changes under .stamp/**. Use the normal flow for non-bootstrap changes.`
|
|
10350
|
+
};
|
|
10351
|
+
}
|
|
10352
|
+
}
|
|
10353
|
+
let sawReviewServerAdd = false;
|
|
10354
|
+
for (const e of entries) {
|
|
10355
|
+
if (e.path === ".stamp/config.yml") {
|
|
10356
|
+
if (e.status !== "M" && e.status !== "A") {
|
|
10357
|
+
return {
|
|
10358
|
+
ok: false,
|
|
10359
|
+
reason: `bootstrap diff has unexpected status "${e.status}" on .stamp/config.yml \u2014 only modification (M) or initial add (A) is permitted`
|
|
10360
|
+
};
|
|
10417
10361
|
}
|
|
10418
|
-
|
|
10419
|
-
|
|
10420
|
-
|
|
10421
|
-
|
|
10422
|
-
|
|
10423
|
-
|
|
10424
|
-
|
|
10362
|
+
const cfgResult = validateConfigYamlChange({
|
|
10363
|
+
repoRoot,
|
|
10364
|
+
baseSha,
|
|
10365
|
+
headSha,
|
|
10366
|
+
statusAdd: e.status === "A"
|
|
10367
|
+
});
|
|
10368
|
+
if (!cfgResult.ok) return cfgResult;
|
|
10369
|
+
sawReviewServerAdd = sawReviewServerAdd || cfgResult.activatesReviewServer;
|
|
10370
|
+
continue;
|
|
10371
|
+
}
|
|
10372
|
+
if (e.path === ".stamp/trusted-keys/manifest.yml") {
|
|
10373
|
+
if (e.status !== "M" && e.status !== "A") {
|
|
10374
|
+
return {
|
|
10375
|
+
ok: false,
|
|
10376
|
+
reason: `bootstrap diff has unexpected status "${e.status}" on .stamp/trusted-keys/manifest.yml \u2014 only modification (M) or initial add (A) is permitted`
|
|
10377
|
+
};
|
|
10425
10378
|
}
|
|
10426
|
-
|
|
10427
|
-
|
|
10428
|
-
|
|
10429
|
-
|
|
10379
|
+
const manifestResult = validateManifestChange({
|
|
10380
|
+
repoRoot,
|
|
10381
|
+
baseSha,
|
|
10382
|
+
headSha,
|
|
10383
|
+
statusAdd: e.status === "A"
|
|
10384
|
+
});
|
|
10385
|
+
if (!manifestResult.ok) return manifestResult;
|
|
10386
|
+
continue;
|
|
10387
|
+
}
|
|
10388
|
+
if (e.path.startsWith(".stamp/trusted-keys/") && e.path.endsWith(".pub")) {
|
|
10389
|
+
if (e.status !== "A") {
|
|
10390
|
+
return {
|
|
10391
|
+
ok: false,
|
|
10392
|
+
reason: `bootstrap diff modifies or removes "${e.path}" (status "${e.status}") \u2014 the bootstrap flag only permits adding new .pub files, never modifying or removing existing ones`
|
|
10393
|
+
};
|
|
10430
10394
|
}
|
|
10431
|
-
|
|
10432
|
-
|
|
10433
|
-
|
|
10434
|
-
|
|
10435
|
-
|
|
10436
|
-
|
|
10437
|
-
|
|
10438
|
-
|
|
10439
|
-
|
|
10395
|
+
continue;
|
|
10396
|
+
}
|
|
10397
|
+
return {
|
|
10398
|
+
ok: false,
|
|
10399
|
+
reason: `bootstrap diff touches "${e.path}" (status "${e.status}") which is not in the Shape-4-activation whitelist (.stamp/config.yml, .stamp/trusted-keys/manifest.yml, or new .stamp/trusted-keys/*.pub files)`
|
|
10400
|
+
};
|
|
10401
|
+
}
|
|
10402
|
+
if (!sawReviewServerAdd) {
|
|
10403
|
+
return {
|
|
10404
|
+
ok: false,
|
|
10405
|
+
reason: `bootstrap diff does not add review_server: to any branch rule in .stamp/config.yml \u2014 a Shape 4 activation must add review_server. If your diff only adds trust-anchor keys, use the normal admin-sign flow.`
|
|
10406
|
+
};
|
|
10407
|
+
}
|
|
10408
|
+
const activatedPaths = entries.map((e) => e.path).sort();
|
|
10409
|
+
return { ok: true, activatedPaths };
|
|
10410
|
+
}
|
|
10411
|
+
function validateConfigYamlChange(args) {
|
|
10412
|
+
if (args.statusAdd) {
|
|
10413
|
+
return {
|
|
10414
|
+
ok: false,
|
|
10415
|
+
reason: `.stamp/config.yml does not exist at base ${args.baseSha.slice(0, 8)} \u2014 the bootstrap flag is for activating Shape 4 on an existing stamp-gated repo, not for first-time stamp init`,
|
|
10416
|
+
activatesReviewServer: false
|
|
10417
|
+
};
|
|
10418
|
+
}
|
|
10419
|
+
const baseYaml = readAtRef(args.repoRoot, args.baseSha, ".stamp/config.yml");
|
|
10420
|
+
const headYaml = readAtRef(args.repoRoot, args.headSha, ".stamp/config.yml");
|
|
10421
|
+
if (baseYaml === null || headYaml === null) {
|
|
10422
|
+
return {
|
|
10423
|
+
ok: false,
|
|
10424
|
+
reason: `.stamp/config.yml is unreadable at base ${args.baseSha.slice(0, 8)} or head ${args.headSha.slice(0, 8)}`,
|
|
10425
|
+
activatesReviewServer: false
|
|
10426
|
+
};
|
|
10427
|
+
}
|
|
10428
|
+
let baseParsed;
|
|
10429
|
+
let headParsed;
|
|
10430
|
+
try {
|
|
10431
|
+
baseParsed = parseYaml10(baseYaml);
|
|
10432
|
+
headParsed = parseYaml10(headYaml);
|
|
10433
|
+
} catch (err) {
|
|
10434
|
+
return {
|
|
10435
|
+
ok: false,
|
|
10436
|
+
reason: `.stamp/config.yml parse error: ${err instanceof Error ? err.message : String(err)}`,
|
|
10437
|
+
activatesReviewServer: false
|
|
10438
|
+
};
|
|
10439
|
+
}
|
|
10440
|
+
if (!baseParsed || typeof baseParsed !== "object" || Array.isArray(baseParsed) || !headParsed || typeof headParsed !== "object" || Array.isArray(headParsed)) {
|
|
10441
|
+
return {
|
|
10442
|
+
ok: false,
|
|
10443
|
+
reason: `.stamp/config.yml at base or head did not parse to an object`,
|
|
10444
|
+
activatesReviewServer: false
|
|
10445
|
+
};
|
|
10446
|
+
}
|
|
10447
|
+
const baseObj = baseParsed;
|
|
10448
|
+
const headObj = headParsed;
|
|
10449
|
+
const allowedDiffKeys = /* @__PURE__ */ new Set(["branches", "reviewers"]);
|
|
10450
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(baseObj), ...Object.keys(headObj)]);
|
|
10451
|
+
for (const k of allKeys) {
|
|
10452
|
+
if (allowedDiffKeys.has(k)) continue;
|
|
10453
|
+
if (!deepEqual(baseObj[k], headObj[k])) {
|
|
10454
|
+
return {
|
|
10455
|
+
ok: false,
|
|
10456
|
+
reason: `.stamp/config.yml: bootstrap diff modifies "${k}" outside the allowed (branches, reviewers) set \u2014 refused`,
|
|
10457
|
+
activatesReviewServer: false
|
|
10458
|
+
};
|
|
10459
|
+
}
|
|
10460
|
+
}
|
|
10461
|
+
const baseBranches = baseObj.branches ?? {};
|
|
10462
|
+
const headBranches = headObj.branches ?? {};
|
|
10463
|
+
if (typeof baseBranches !== "object" || baseBranches === null || Array.isArray(baseBranches) || typeof headBranches !== "object" || headBranches === null || Array.isArray(headBranches)) {
|
|
10464
|
+
return {
|
|
10465
|
+
ok: false,
|
|
10466
|
+
reason: `.stamp/config.yml: branches must be objects at both base and head`,
|
|
10467
|
+
activatesReviewServer: false
|
|
10468
|
+
};
|
|
10469
|
+
}
|
|
10470
|
+
const baseBranchKeys = Object.keys(baseBranches);
|
|
10471
|
+
const headBranchKeys = Object.keys(headBranches);
|
|
10472
|
+
if (baseBranchKeys.length !== headBranchKeys.length || !baseBranchKeys.every((k) => headBranchKeys.includes(k))) {
|
|
10473
|
+
return {
|
|
10474
|
+
ok: false,
|
|
10475
|
+
reason: `.stamp/config.yml: bootstrap diff adds or removes branch entries (base: [${baseBranchKeys.join(", ")}], head: [${headBranchKeys.join(", ")}]) \u2014 only review_server addition on existing branches is permitted`,
|
|
10476
|
+
activatesReviewServer: false
|
|
10477
|
+
};
|
|
10478
|
+
}
|
|
10479
|
+
let anyBranchActivatesReviewServer = false;
|
|
10480
|
+
for (const name of baseBranchKeys) {
|
|
10481
|
+
const baseRule = baseBranches[name];
|
|
10482
|
+
const headRule = headBranches[name];
|
|
10483
|
+
if (!baseRule || !headRule) {
|
|
10484
|
+
return {
|
|
10485
|
+
ok: false,
|
|
10486
|
+
reason: `.stamp/config.yml: branch "${name}" is missing at base or head`,
|
|
10487
|
+
activatesReviewServer: false
|
|
10488
|
+
};
|
|
10489
|
+
}
|
|
10490
|
+
if (Array.isArray(baseRule) || Array.isArray(headRule)) {
|
|
10491
|
+
return {
|
|
10492
|
+
ok: false,
|
|
10493
|
+
reason: `.stamp/config.yml: branch "${name}" must be an object`,
|
|
10494
|
+
activatesReviewServer: false
|
|
10495
|
+
};
|
|
10496
|
+
}
|
|
10497
|
+
const baseKeys = Object.keys(baseRule);
|
|
10498
|
+
const headKeys = Object.keys(headRule);
|
|
10499
|
+
for (const k of baseKeys) {
|
|
10500
|
+
if (!(k in headRule)) {
|
|
10501
|
+
return {
|
|
10502
|
+
ok: false,
|
|
10503
|
+
reason: `.stamp/config.yml: branch "${name}" removes field "${k}" \u2014 bootstrap diff cannot remove branch-rule fields`,
|
|
10504
|
+
activatesReviewServer: false
|
|
10505
|
+
};
|
|
10506
|
+
}
|
|
10507
|
+
if (!deepEqual(baseRule[k], headRule[k])) {
|
|
10508
|
+
return {
|
|
10509
|
+
ok: false,
|
|
10510
|
+
reason: `.stamp/config.yml: branch "${name}" modifies field "${k}" \u2014 bootstrap diff can only ADD review_server, not modify existing fields`,
|
|
10511
|
+
activatesReviewServer: false
|
|
10512
|
+
};
|
|
10513
|
+
}
|
|
10514
|
+
}
|
|
10515
|
+
for (const k of headKeys) {
|
|
10516
|
+
if (k in baseRule) continue;
|
|
10517
|
+
if (k !== "review_server") {
|
|
10518
|
+
return {
|
|
10519
|
+
ok: false,
|
|
10520
|
+
reason: `.stamp/config.yml: branch "${name}" adds field "${k}" \u2014 bootstrap diff can only add "review_server"`,
|
|
10521
|
+
activatesReviewServer: false
|
|
10522
|
+
};
|
|
10523
|
+
}
|
|
10524
|
+
if (typeof headRule.review_server !== "string" || !headRule.review_server) {
|
|
10525
|
+
return {
|
|
10526
|
+
ok: false,
|
|
10527
|
+
reason: `.stamp/config.yml: branch "${name}".review_server must be a non-empty string`,
|
|
10528
|
+
activatesReviewServer: false
|
|
10529
|
+
};
|
|
10530
|
+
}
|
|
10531
|
+
anyBranchActivatesReviewServer = true;
|
|
10532
|
+
}
|
|
10533
|
+
}
|
|
10534
|
+
const baseReviewers = baseObj.reviewers ?? {};
|
|
10535
|
+
const headReviewers = headObj.reviewers ?? {};
|
|
10536
|
+
if (typeof baseReviewers !== "object" || baseReviewers === null || Array.isArray(baseReviewers) || typeof headReviewers !== "object" || headReviewers === null || Array.isArray(headReviewers)) {
|
|
10537
|
+
return {
|
|
10538
|
+
ok: false,
|
|
10539
|
+
reason: `.stamp/config.yml: reviewers must be objects at both base and head`,
|
|
10540
|
+
activatesReviewServer: false
|
|
10541
|
+
};
|
|
10542
|
+
}
|
|
10543
|
+
const baseRevKeys = Object.keys(baseReviewers).sort();
|
|
10544
|
+
const headRevKeys = Object.keys(headReviewers).sort();
|
|
10545
|
+
if (baseRevKeys.length !== headRevKeys.length || !baseRevKeys.every((k, i) => k === headRevKeys[i])) {
|
|
10546
|
+
return {
|
|
10547
|
+
ok: false,
|
|
10548
|
+
reason: `.stamp/config.yml: bootstrap diff adds or removes reviewer entries (base: [${baseRevKeys.join(", ")}], head: [${headRevKeys.join(", ")}]) \u2014 Shape 4 activation may only cleanup prompt paths, not add or remove reviewers`,
|
|
10549
|
+
activatesReviewServer: false
|
|
10550
|
+
};
|
|
10551
|
+
}
|
|
10552
|
+
for (const name of baseRevKeys) {
|
|
10553
|
+
const baseRev = baseReviewers[name];
|
|
10554
|
+
const headRev = headReviewers[name];
|
|
10555
|
+
if (deepEqual(baseRev, headRev)) continue;
|
|
10556
|
+
if (headRev !== null && typeof headRev === "object" && !Array.isArray(headRev) && Object.keys(headRev).length === 0) {
|
|
10557
|
+
continue;
|
|
10558
|
+
}
|
|
10559
|
+
return {
|
|
10560
|
+
ok: false,
|
|
10561
|
+
reason: `.stamp/config.yml: reviewer "${name}" modified outside the Shape-4 cleanup pattern (HEAD must equal BASE or be {}); refused`,
|
|
10562
|
+
activatesReviewServer: false
|
|
10563
|
+
};
|
|
10564
|
+
}
|
|
10565
|
+
return { ok: true, activatedPaths: [], activatesReviewServer: anyBranchActivatesReviewServer };
|
|
10566
|
+
}
|
|
10567
|
+
function validateManifestChange(args) {
|
|
10568
|
+
const headYaml = readAtRef(args.repoRoot, args.headSha, ".stamp/trusted-keys/manifest.yml");
|
|
10569
|
+
if (headYaml === null) {
|
|
10570
|
+
return {
|
|
10571
|
+
ok: false,
|
|
10572
|
+
reason: `.stamp/trusted-keys/manifest.yml unreadable at head ${args.headSha.slice(0, 8)}`
|
|
10573
|
+
};
|
|
10574
|
+
}
|
|
10575
|
+
let headParsed;
|
|
10576
|
+
try {
|
|
10577
|
+
headParsed = parseYaml10(headYaml);
|
|
10578
|
+
} catch (err) {
|
|
10579
|
+
return {
|
|
10580
|
+
ok: false,
|
|
10581
|
+
reason: `.stamp/trusted-keys/manifest.yml parse error at head: ${err instanceof Error ? err.message : String(err)}`
|
|
10582
|
+
};
|
|
10583
|
+
}
|
|
10584
|
+
if (!headParsed || typeof headParsed !== "object" || Array.isArray(headParsed) || !headParsed.keys || typeof headParsed.keys !== "object") {
|
|
10585
|
+
return {
|
|
10586
|
+
ok: false,
|
|
10587
|
+
reason: `.stamp/trusted-keys/manifest.yml: missing or malformed top-level "keys" map at head`
|
|
10588
|
+
};
|
|
10589
|
+
}
|
|
10590
|
+
const headKeys = headParsed.keys;
|
|
10591
|
+
let baseKeys = {};
|
|
10592
|
+
if (!args.statusAdd) {
|
|
10593
|
+
const baseYaml = readAtRef(args.repoRoot, args.baseSha, ".stamp/trusted-keys/manifest.yml");
|
|
10594
|
+
if (baseYaml === null) {
|
|
10595
|
+
return {
|
|
10596
|
+
ok: false,
|
|
10597
|
+
reason: `.stamp/trusted-keys/manifest.yml unreadable at base ${args.baseSha.slice(0, 8)}`
|
|
10598
|
+
};
|
|
10599
|
+
}
|
|
10600
|
+
let baseParsed;
|
|
10601
|
+
try {
|
|
10602
|
+
baseParsed = parseYaml10(baseYaml);
|
|
10603
|
+
} catch (err) {
|
|
10604
|
+
return {
|
|
10605
|
+
ok: false,
|
|
10606
|
+
reason: `.stamp/trusted-keys/manifest.yml parse error at base: ${err instanceof Error ? err.message : String(err)}`
|
|
10607
|
+
};
|
|
10608
|
+
}
|
|
10609
|
+
if (!baseParsed || typeof baseParsed !== "object" || Array.isArray(baseParsed) || !baseParsed.keys || typeof baseParsed.keys !== "object") {
|
|
10610
|
+
return {
|
|
10611
|
+
ok: false,
|
|
10612
|
+
reason: `.stamp/trusted-keys/manifest.yml: missing or malformed top-level "keys" map at base`
|
|
10613
|
+
};
|
|
10614
|
+
}
|
|
10615
|
+
baseKeys = baseParsed.keys;
|
|
10616
|
+
}
|
|
10617
|
+
for (const name of Object.keys(baseKeys)) {
|
|
10618
|
+
if (!(name in headKeys)) {
|
|
10619
|
+
return {
|
|
10620
|
+
ok: false,
|
|
10621
|
+
reason: `.stamp/trusted-keys/manifest.yml: bootstrap diff removes entry "${name}" \u2014 refused (only additions of [server]+role_source:server entries are permitted)`
|
|
10622
|
+
};
|
|
10623
|
+
}
|
|
10624
|
+
if (!deepEqual(baseKeys[name], headKeys[name])) {
|
|
10625
|
+
return {
|
|
10626
|
+
ok: false,
|
|
10627
|
+
reason: `.stamp/trusted-keys/manifest.yml: bootstrap diff modifies existing entry "${name}" \u2014 refused (only additions are permitted)`
|
|
10628
|
+
};
|
|
10629
|
+
}
|
|
10630
|
+
}
|
|
10631
|
+
for (const name of Object.keys(headKeys)) {
|
|
10632
|
+
if (name in baseKeys) continue;
|
|
10633
|
+
const entry = headKeys[name];
|
|
10634
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
10635
|
+
return {
|
|
10636
|
+
ok: false,
|
|
10637
|
+
reason: `.stamp/trusted-keys/manifest.yml: new entry "${name}" is not a YAML object`
|
|
10638
|
+
};
|
|
10639
|
+
}
|
|
10640
|
+
const e = entry;
|
|
10641
|
+
if (!Array.isArray(e.capabilities)) {
|
|
10642
|
+
return {
|
|
10643
|
+
ok: false,
|
|
10644
|
+
reason: `.stamp/trusted-keys/manifest.yml: new entry "${name}" missing capabilities array`
|
|
10645
|
+
};
|
|
10646
|
+
}
|
|
10647
|
+
const caps = e.capabilities.filter((c) => typeof c === "string");
|
|
10648
|
+
if (caps.length !== 1 || caps[0] !== "server") {
|
|
10649
|
+
return {
|
|
10650
|
+
ok: false,
|
|
10651
|
+
reason: `.stamp/trusted-keys/manifest.yml: new entry "${name}" has capabilities [${caps.join(", ")}] \u2014 bootstrap diff only permits adding entries with capabilities: [server] exactly`
|
|
10652
|
+
};
|
|
10653
|
+
}
|
|
10654
|
+
if (e.role_source !== "server") {
|
|
10655
|
+
return {
|
|
10656
|
+
ok: false,
|
|
10657
|
+
reason: `.stamp/trusted-keys/manifest.yml: new entry "${name}" must have role_source: server (got ${JSON.stringify(e.role_source)}). This invariant flags entries auto-published by stamp-server.`
|
|
10658
|
+
};
|
|
10659
|
+
}
|
|
10660
|
+
if (typeof e.fingerprint !== "string" || !/^sha256:[0-9a-f]{64}$/.test(e.fingerprint)) {
|
|
10661
|
+
return {
|
|
10662
|
+
ok: false,
|
|
10663
|
+
reason: `.stamp/trusted-keys/manifest.yml: new entry "${name}" has invalid fingerprint`
|
|
10664
|
+
};
|
|
10665
|
+
}
|
|
10666
|
+
}
|
|
10667
|
+
return { ok: true, activatedPaths: [] };
|
|
10668
|
+
}
|
|
10669
|
+
var MIGRATION_BOOTSTRAP_KEY = "migration_bootstrap";
|
|
10670
|
+
function bootstrapAdminSigningBytes(args) {
|
|
10671
|
+
const augmented = {
|
|
10672
|
+
...args.payloadV4,
|
|
10673
|
+
[MIGRATION_BOOTSTRAP_KEY]: args.marker,
|
|
10674
|
+
trust_anchor_signatures: []
|
|
10675
|
+
};
|
|
10676
|
+
return canonicalSerializePayload(augmented);
|
|
10677
|
+
}
|
|
10678
|
+
function readAtRef(repoRoot, ref, relPath) {
|
|
10679
|
+
const result = spawnSync14("git", ["show", `${ref}:${relPath}`], {
|
|
10680
|
+
cwd: repoRoot,
|
|
10681
|
+
encoding: "utf8"
|
|
10682
|
+
});
|
|
10683
|
+
if (result.status !== 0) return null;
|
|
10684
|
+
return result.stdout ?? "";
|
|
10685
|
+
}
|
|
10686
|
+
function deepEqual(a, b) {
|
|
10687
|
+
if (a === b) return true;
|
|
10688
|
+
if (a === null || b === null) return false;
|
|
10689
|
+
if (typeof a !== typeof b) return false;
|
|
10690
|
+
if (typeof a !== "object") return false;
|
|
10691
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
10692
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
10693
|
+
if (a.length !== b.length) return false;
|
|
10694
|
+
for (let i = 0; i < a.length; i++) {
|
|
10695
|
+
if (!deepEqual(a[i], b[i])) return false;
|
|
10696
|
+
}
|
|
10697
|
+
return true;
|
|
10698
|
+
}
|
|
10699
|
+
const ao = a;
|
|
10700
|
+
const bo = b;
|
|
10701
|
+
const aKeys = Object.keys(ao).sort();
|
|
10702
|
+
const bKeys = Object.keys(bo).sort();
|
|
10703
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
10704
|
+
for (let i = 0; i < aKeys.length; i++) {
|
|
10705
|
+
if (aKeys[i] !== bKeys[i]) return false;
|
|
10706
|
+
if (!deepEqual(ao[aKeys[i]], bo[bKeys[i]])) return false;
|
|
10707
|
+
}
|
|
10708
|
+
return true;
|
|
10709
|
+
}
|
|
10710
|
+
|
|
10711
|
+
// src/commands/attest.ts
|
|
10712
|
+
function runAttest(opts) {
|
|
10713
|
+
const repoRoot = findRepoRoot();
|
|
10714
|
+
const config2 = loadConfig(stampConfigFile(repoRoot));
|
|
10715
|
+
const rule = findBranchRule(config2.branches, opts.into);
|
|
10716
|
+
if (!rule) {
|
|
10717
|
+
throw new Error(
|
|
10718
|
+
`no branch rule for "${opts.into}" in .stamp/config.yml. Configured branches: ${Object.keys(config2.branches).join(", ") || "(none)"}.`
|
|
10719
|
+
);
|
|
10720
|
+
}
|
|
10721
|
+
const branchRef = opts.branch ?? "HEAD";
|
|
10722
|
+
const revspec = `${opts.into}..${branchRef}`;
|
|
10723
|
+
const resolved = resolveDiff(revspec, repoRoot);
|
|
10724
|
+
const patch_id = patchIdForSpan(resolved.base_sha, resolved.head_sha, repoRoot);
|
|
10725
|
+
const target_branch_tip_sha = runGit(
|
|
10726
|
+
["rev-parse", `${opts.into}^{commit}`],
|
|
10727
|
+
repoRoot
|
|
10728
|
+
).trim();
|
|
10729
|
+
const { keypair } = ensureUserKeypair();
|
|
10730
|
+
const result = opts.migrateExisting ? buildBootstrapEnvelope({
|
|
10731
|
+
repoRoot,
|
|
10732
|
+
revspec,
|
|
10733
|
+
baseSha: resolved.base_sha,
|
|
10734
|
+
headSha: resolved.head_sha,
|
|
10735
|
+
diff: resolved.diff,
|
|
10736
|
+
targetBranch: opts.into,
|
|
10737
|
+
targetBranchTipSha: target_branch_tip_sha,
|
|
10738
|
+
patchId: patch_id,
|
|
10739
|
+
operatorPrivateKeyPem: keypair.privateKeyPem,
|
|
10740
|
+
operatorPublicKeyPem: keypair.publicKeyPem,
|
|
10741
|
+
operatorFingerprint: keypair.fingerprint
|
|
10742
|
+
}) : rule.review_server ? buildV3Envelope({
|
|
10743
|
+
repoRoot,
|
|
10744
|
+
revspec,
|
|
10745
|
+
baseSha: resolved.base_sha,
|
|
10746
|
+
headSha: resolved.head_sha,
|
|
10747
|
+
diff: resolved.diff,
|
|
10748
|
+
targetBranch: opts.into,
|
|
10749
|
+
targetBranchTipSha: target_branch_tip_sha,
|
|
10750
|
+
patchId: patch_id,
|
|
10751
|
+
requiredReviewers: rule.required,
|
|
10752
|
+
operatorPrivateKeyPem: keypair.privateKeyPem,
|
|
10753
|
+
operatorFingerprint: keypair.fingerprint
|
|
10754
|
+
}) : buildV2Envelope({
|
|
10755
|
+
repoRoot,
|
|
10756
|
+
revspec,
|
|
10757
|
+
baseSha: resolved.base_sha,
|
|
10758
|
+
headSha: resolved.head_sha,
|
|
10759
|
+
targetBranch: opts.into,
|
|
10760
|
+
targetBranchTipSha: target_branch_tip_sha,
|
|
10761
|
+
patchId: patch_id,
|
|
10762
|
+
requiredReviewers: rule.required,
|
|
10763
|
+
operatorPrivateKeyPem: keypair.privateKeyPem,
|
|
10764
|
+
operatorFingerprint: keypair.fingerprint
|
|
10765
|
+
});
|
|
10766
|
+
const { ref, blob_sha } = writeAttestationRef(
|
|
10767
|
+
{ payload: result.payload, signature: result.signature },
|
|
10768
|
+
repoRoot
|
|
10769
|
+
);
|
|
10770
|
+
const bar = "\u2500".repeat(72);
|
|
10771
|
+
console.log(bar);
|
|
10772
|
+
console.log(`attested ${branchRef} for merge into '${opts.into}'`);
|
|
10773
|
+
console.log(bar);
|
|
10774
|
+
console.log(` patch-id: ${patch_id}`);
|
|
10775
|
+
console.log(
|
|
10776
|
+
` base\u2192head: ${resolved.base_sha.slice(0, 8)} \u2192 ${resolved.head_sha.slice(0, 8)}`
|
|
10777
|
+
);
|
|
10778
|
+
console.log(` signed by: ${keypair.fingerprint}`);
|
|
10779
|
+
console.log(` approvals: ${result.reviewerNames.length > 0 ? result.reviewerNames.join(", ") : "(none \u2014 bootstrap envelope)"}`);
|
|
10780
|
+
const schemaLabel = result.payload.migration_bootstrap ? " (Shape 4 migration bootstrap)" : result.payload.schema_version === PR_ATTESTATION_SCHEMA_VERSION ? " (server-attested)" : " (legacy)";
|
|
10781
|
+
console.log(` schema: v${result.payload.schema_version}${schemaLabel}`);
|
|
10782
|
+
console.log(` ref: ${ref}`);
|
|
10783
|
+
console.log(` blob: ${blob_sha.slice(0, 12)}`);
|
|
10784
|
+
console.log(bar);
|
|
10785
|
+
if (opts.pushTo) {
|
|
10786
|
+
pushBranchAndAttestation(opts.pushTo, ref, repoRoot);
|
|
10787
|
+
console.log(
|
|
10788
|
+
`
|
|
10789
|
+
\u2713 pushed branch + attestation ref to ${opts.pushTo}. Open the PR; stamp/verify-attestation@v1 will look up refs/stamp/attestations/<patch-id> from your head SHA's diff against the base.`
|
|
10790
|
+
);
|
|
10791
|
+
} else {
|
|
10792
|
+
console.log(
|
|
10793
|
+
`
|
|
10794
|
+
Next: push the branch + attestation ref to your remote, open a PR, and let stamp/verify-attestation@v1 (the GH Action) confirm it. To do both pushes in one shot:
|
|
10795
|
+
|
|
10796
|
+
git push <remote> HEAD ${ref}
|
|
10797
|
+
|
|
10798
|
+
Or re-run with --push <remote> next time.`
|
|
10799
|
+
);
|
|
10800
|
+
}
|
|
10801
|
+
}
|
|
10802
|
+
function buildV3Envelope(input) {
|
|
10803
|
+
const diffBytes = Buffer.from(input.diff, "utf8");
|
|
10804
|
+
const diffSha256 = createHash12("sha256").update(diffBytes).digest("hex");
|
|
10805
|
+
let manifestYaml;
|
|
10806
|
+
try {
|
|
10807
|
+
manifestYaml = showAtRef(
|
|
10808
|
+
input.baseSha,
|
|
10809
|
+
".stamp/trusted-keys/manifest.yml",
|
|
10810
|
+
input.repoRoot
|
|
10811
|
+
);
|
|
10812
|
+
} catch (err) {
|
|
10813
|
+
throw new Error(
|
|
10814
|
+
`review_server is configured but .stamp/trusted-keys/manifest.yml is missing at base ${input.baseSha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}. Server-attested PR mode requires the manifest in the merge-base tree so each approval's server signature can be checked against the keys the repo trusted at attestation time. Commit a manifest with capabilities: [server] entries for the review server before attesting.`
|
|
10815
|
+
);
|
|
10816
|
+
}
|
|
10817
|
+
const manifest = parseManifest(manifestYaml);
|
|
10818
|
+
if (!manifest) {
|
|
10819
|
+
throw new Error(
|
|
10820
|
+
`.stamp/trusted-keys/manifest.yml at base ${input.baseSha.slice(0, 8)} failed to parse as a valid trusted-keys manifest. Fix the YAML (syntax error, duplicate fingerprint, unknown capability, etc.) and re-run \`stamp attest\`.`
|
|
10821
|
+
);
|
|
10822
|
+
}
|
|
10823
|
+
const pubFilenames = listFilesAtRef(
|
|
10824
|
+
input.baseSha,
|
|
10825
|
+
".stamp/trusted-keys",
|
|
10826
|
+
input.repoRoot
|
|
10827
|
+
);
|
|
10828
|
+
const pubkeyByFingerprint = buildPubkeyMap(
|
|
10829
|
+
pubFilenames,
|
|
10830
|
+
(relPath) => showAtRef(input.baseSha, relPath, input.repoRoot)
|
|
10831
|
+
);
|
|
10832
|
+
const db = openDb(stampStateDbPath(input.repoRoot));
|
|
10833
|
+
let entries;
|
|
10834
|
+
try {
|
|
10835
|
+
const rows = serverApprovalsFor(db, input.baseSha, input.headSha);
|
|
10836
|
+
const byReviewer = new Map(rows.map((r) => [r.reviewer, r]));
|
|
10837
|
+
entries = input.requiredReviewers.map((reviewerName) => {
|
|
10838
|
+
const row = byReviewer.get(reviewerName);
|
|
10839
|
+
if (!row) {
|
|
10840
|
+
throw new Error(
|
|
10841
|
+
`missing server signature for reviewer "${reviewerName}" at base\u2192head ${input.baseSha.slice(0, 8)}\u2192${input.headSha.slice(0, 8)}. Server-attested PR mode requires every required reviewer to have a stamp-server-signed approval in the local DB. Possible causes:
|
|
10842
|
+
\u2022 the stamp-server is older than 2.0.1 and doesn't produce PR-attestation v3 payloads (upgrade the server)
|
|
10843
|
+
\u2022 \`stamp review --diff ${input.revspec}\` hasn't been run against this exact (base, head) pair yet
|
|
10844
|
+
\u2022 the review was run in local-mode (no \`review_server\` configured at review time)
|
|
10845
|
+
Run \`stamp review --diff ${input.revspec}\` to populate the signed row, then re-run \`stamp attest\`.`
|
|
10846
|
+
);
|
|
10847
|
+
}
|
|
10848
|
+
let parsedJson;
|
|
10849
|
+
try {
|
|
10850
|
+
parsedJson = JSON.parse(row.approval_json);
|
|
10851
|
+
} catch (err) {
|
|
10852
|
+
throw new Error(
|
|
10853
|
+
`server approval row for reviewer "${reviewerName}" has malformed JSON in server_approval_json \u2014 DB corruption or a writer-side bug. Re-run \`stamp review --diff ${input.revspec}\` to write a fresh row. (parse error: ${err instanceof Error ? err.message : String(err)})`
|
|
10854
|
+
);
|
|
10855
|
+
}
|
|
10856
|
+
if (!parsedJson || typeof parsedJson !== "object" || Array.isArray(parsedJson)) {
|
|
10857
|
+
throw new Error(
|
|
10858
|
+
`server approval row for reviewer "${reviewerName}" parsed to a non-object value \u2014 DB corruption or a writer-side bug. Re-run \`stamp review --diff ${input.revspec}\` to write a fresh row.`
|
|
10859
|
+
);
|
|
10860
|
+
}
|
|
10861
|
+
const obj = parsedJson;
|
|
10862
|
+
for (const field of [
|
|
10863
|
+
"reviewer",
|
|
10864
|
+
"verdict",
|
|
10865
|
+
"prompt_sha256",
|
|
10866
|
+
"diff_sha256",
|
|
10867
|
+
"base_sha",
|
|
10868
|
+
"head_sha",
|
|
10440
10869
|
"issued_at",
|
|
10441
10870
|
"server_key_id"
|
|
10442
10871
|
]) {
|
|
@@ -10511,6 +10940,7 @@ Run \`stamp review --diff ${input.revspec}\` to populate the signed row, then re
|
|
|
10511
10940
|
db.close();
|
|
10512
10941
|
}
|
|
10513
10942
|
const trustAnchorSignatures = [];
|
|
10943
|
+
const manifestSnapshot = snapshotSha256(manifest);
|
|
10514
10944
|
const payload = {
|
|
10515
10945
|
schema_version: PR_ATTESTATION_SCHEMA_VERSION,
|
|
10516
10946
|
patch_id: input.patchId,
|
|
@@ -10519,6 +10949,7 @@ Run \`stamp review --diff ${input.revspec}\` to populate the signed row, then re
|
|
|
10519
10949
|
target_branch: input.targetBranch,
|
|
10520
10950
|
target_branch_tip_sha: input.targetBranchTipSha,
|
|
10521
10951
|
diff_sha256: diffSha256,
|
|
10952
|
+
manifest_snapshot_sha256: manifestSnapshot,
|
|
10522
10953
|
approvals: entries,
|
|
10523
10954
|
checks: [],
|
|
10524
10955
|
// Phase-1 deliberate omission — see file-level comment.
|
|
@@ -10579,6 +11010,11 @@ function buildV2Envelope(input) {
|
|
|
10579
11010
|
`reviewer "${a.reviewer}" approved the diff but is not defined in .stamp/config.yml at base ${input.baseSha.slice(0, 8)}. This shouldn't happen \u2014 runReview reads from the same base. File a bug at https://github.com/OpenThinkAi/stamp-cli/issues.`
|
|
10580
11011
|
);
|
|
10581
11012
|
}
|
|
11013
|
+
if (def.prompt === void 0) {
|
|
11014
|
+
throw new Error(
|
|
11015
|
+
`reviewer "${a.reviewer}": no \`prompt:\` configured and no \`review_server:\` on branch rule \u2014 set \`reviewers.${a.reviewer}.prompt\` in .stamp/config.yml or configure a \`review_server:\` for server-attested PR mode.`
|
|
11016
|
+
);
|
|
11017
|
+
}
|
|
10582
11018
|
const promptText = showAtRef(input.baseSha, def.prompt, input.repoRoot);
|
|
10583
11019
|
const source = readReviewerSource2(a.reviewer, input.repoRoot);
|
|
10584
11020
|
return {
|
|
@@ -10611,8 +11047,187 @@ function buildV2Envelope(input) {
|
|
|
10611
11047
|
reviewerNames: approvals.map((a) => a.reviewer)
|
|
10612
11048
|
};
|
|
10613
11049
|
}
|
|
11050
|
+
function buildBootstrapEnvelope(input) {
|
|
11051
|
+
const validation = validateShape4ActivationDiff({
|
|
11052
|
+
repoRoot: input.repoRoot,
|
|
11053
|
+
baseSha: input.baseSha,
|
|
11054
|
+
headSha: input.headSha
|
|
11055
|
+
});
|
|
11056
|
+
if (!validation.ok) {
|
|
11057
|
+
throw new Error(
|
|
11058
|
+
`--migrate-existing refused: ${validation.reason}
|
|
11059
|
+
|
|
11060
|
+
The bootstrap flag accepts ONLY a narrow Shape 4 activation diff: adding \`review_server:\` to a branch rule in .stamp/config.yml, adding new [server]+role_source:server entries to .stamp/trusted-keys/manifest.yml, and adding the corresponding new .pub files. Land any unrelated changes through the normal \`stamp attest\` flow AFTER the bootstrap PR merges.`
|
|
11061
|
+
);
|
|
11062
|
+
}
|
|
11063
|
+
const activatedPaths = validation.activatedPaths;
|
|
11064
|
+
const baseConfigYaml = readAtBaseSha(
|
|
11065
|
+
input.repoRoot,
|
|
11066
|
+
input.baseSha,
|
|
11067
|
+
".stamp/config.yml"
|
|
11068
|
+
);
|
|
11069
|
+
if (baseConfigYaml === null) {
|
|
11070
|
+
throw new Error(
|
|
11071
|
+
`--migrate-existing refused: .stamp/config.yml is missing at base ${input.baseSha.slice(0, 8)}. Bootstrap requires the config file in the merge-base tree (the activation diff modifies it).`
|
|
11072
|
+
);
|
|
11073
|
+
}
|
|
11074
|
+
const baseRules = extractPathRulesFromYaml(baseConfigYaml);
|
|
11075
|
+
if (baseRules.length === 0) {
|
|
11076
|
+
throw new Error(
|
|
11077
|
+
`--migrate-existing refused: no \`path_rules\` configured at base ${input.baseSha.slice(0, 8)}. The bootstrap path needs a path_rules entry covering the activated paths with \`bypass_review_cycle: true\` so the verifier knows the reviewer cycle is intentionally skipped for this PR. Add a \`path_rules\` entry for \`.stamp/**\` in a separate prior PR before bootstrapping.`
|
|
11078
|
+
);
|
|
11079
|
+
}
|
|
11080
|
+
const matchedRule = matchAnyCoveringRule(activatedPaths, baseRules);
|
|
11081
|
+
if (!matchedRule) {
|
|
11082
|
+
throw new Error(
|
|
11083
|
+
`--migrate-existing refused: \`path_rules\` at base ${input.baseSha.slice(0, 8)} does not cover every activated path. Activated: ${activatedPaths.join(", ")}. Configured patterns: ${baseRules.map((r) => `"${r.pattern}"`).join(", ")}. Add or widen a path_rules entry that matches these paths (in a separate prior PR \u2014 the bootstrap diff cannot modify path_rules).`
|
|
11084
|
+
);
|
|
11085
|
+
}
|
|
11086
|
+
if (!matchedRule.bypass_review_cycle) {
|
|
11087
|
+
throw new Error(
|
|
11088
|
+
`--migrate-existing refused: matched path_rule "${matchedRule.pattern}" at base has \`bypass_review_cycle: false\` \u2014 bootstrap requires the rule to \`bypass_review_cycle: true\` (otherwise the reviewer cycle is still nominally required and the bootstrap envelope is structurally invalid).`
|
|
11089
|
+
);
|
|
11090
|
+
}
|
|
11091
|
+
if (matchedRule.minimum_signatures > 1) {
|
|
11092
|
+
throw new Error(
|
|
11093
|
+
`--migrate-existing refused: matched path_rule "${matchedRule.pattern}" at base requires \`minimum_signatures: ${matchedRule.minimum_signatures}\` admin signatures, but the bootstrap path only collects a single operator-self admin signature today. Options: (a) temporarily lower the rule to \`minimum_signatures: 1\` in a prior PR; (b) for the migration commit only, use the standard \`stamp admin sign\` flow against the eventual landed commit.`
|
|
11094
|
+
);
|
|
11095
|
+
}
|
|
11096
|
+
const workingTreeManifestYaml = readWorkingTreeManifestYaml(input.repoRoot);
|
|
11097
|
+
if (workingTreeManifestYaml === null) {
|
|
11098
|
+
throw new Error(
|
|
11099
|
+
`--migrate-existing refused: .stamp/trusted-keys/manifest.yml is missing from the working tree. Bootstrap requires the working-tree manifest to bind the operator's local stamp key to the \`admin\` capability.`
|
|
11100
|
+
);
|
|
11101
|
+
}
|
|
11102
|
+
const workingTreeManifest = parseManifest(workingTreeManifestYaml);
|
|
11103
|
+
if (!workingTreeManifest) {
|
|
11104
|
+
throw new Error(
|
|
11105
|
+
`--migrate-existing refused: .stamp/trusted-keys/manifest.yml in the working tree failed to parse (bad YAML, duplicate fingerprint, unknown capability, etc.).`
|
|
11106
|
+
);
|
|
11107
|
+
}
|
|
11108
|
+
const operatorCaps = resolveCapability(workingTreeManifest, input.operatorFingerprint);
|
|
11109
|
+
if (operatorCaps === null) {
|
|
11110
|
+
throw new Error(
|
|
11111
|
+
`--migrate-existing refused: your local stamp key (${input.operatorFingerprint}) is not listed in the working-tree .stamp/trusted-keys/manifest.yml. Add your key with \`capabilities: [admin]\` (in a separate prior PR) before bootstrapping the Shape 4 migration.`
|
|
11112
|
+
);
|
|
11113
|
+
}
|
|
11114
|
+
if (!operatorCaps.includes("admin")) {
|
|
11115
|
+
throw new Error(
|
|
11116
|
+
`--migrate-existing refused: your local stamp key (${input.operatorFingerprint}) has capabilities [${operatorCaps.join(", ")}] in the working-tree manifest \u2014 needs \`admin\` for bootstrap. Either grant your key admin capability (in a separate prior PR) or have a different admin run \`stamp attest --migrate-existing\`.`
|
|
11117
|
+
);
|
|
11118
|
+
}
|
|
11119
|
+
const baseManifestYaml = readAtBaseSha(
|
|
11120
|
+
input.repoRoot,
|
|
11121
|
+
input.baseSha,
|
|
11122
|
+
".stamp/trusted-keys/manifest.yml"
|
|
11123
|
+
);
|
|
11124
|
+
if (baseManifestYaml === null) {
|
|
11125
|
+
throw new Error(
|
|
11126
|
+
`--migrate-existing refused: .stamp/trusted-keys/manifest.yml is missing at base ${input.baseSha.slice(0, 8)}. Bootstrap requires the manifest in the merge-base tree (the operator's outer signature commits to its snapshot hash). Run \`stamp init --migrate-to-server-attested\` first to seed the manifest.`
|
|
11127
|
+
);
|
|
11128
|
+
}
|
|
11129
|
+
const baseManifest = parseManifest(baseManifestYaml);
|
|
11130
|
+
if (!baseManifest) {
|
|
11131
|
+
throw new Error(
|
|
11132
|
+
`--migrate-existing refused: .stamp/trusted-keys/manifest.yml at base ${input.baseSha.slice(0, 8)} failed to parse.`
|
|
11133
|
+
);
|
|
11134
|
+
}
|
|
11135
|
+
const manifestSnapshot = snapshotSha256(baseManifest);
|
|
11136
|
+
const diffBytes = Buffer.from(input.diff, "utf8");
|
|
11137
|
+
const diffSha256 = createHash12("sha256").update(diffBytes).digest("hex");
|
|
11138
|
+
const marker = {
|
|
11139
|
+
activated_paths: activatedPaths
|
|
11140
|
+
};
|
|
11141
|
+
const payloadV4Shape = {
|
|
11142
|
+
schema_version: PR_ATTESTATION_SCHEMA_VERSION,
|
|
11143
|
+
base_sha: input.baseSha,
|
|
11144
|
+
head_sha: input.headSha,
|
|
11145
|
+
target_branch: input.targetBranch,
|
|
11146
|
+
diff_sha256: diffSha256,
|
|
11147
|
+
manifest_snapshot_sha256: manifestSnapshot,
|
|
11148
|
+
approvals: [],
|
|
11149
|
+
// bootstrap: no server signatures
|
|
11150
|
+
checks: [],
|
|
11151
|
+
trust_anchor_signatures: [],
|
|
11152
|
+
signer_key_id: input.operatorFingerprint
|
|
11153
|
+
};
|
|
11154
|
+
const adminSigningBytes = bootstrapAdminSigningBytes({
|
|
11155
|
+
payloadV4: payloadV4Shape,
|
|
11156
|
+
marker
|
|
11157
|
+
});
|
|
11158
|
+
const adminSignatureB64 = signBytes(input.operatorPrivateKeyPem, adminSigningBytes);
|
|
11159
|
+
const selfOk = verifyBytes(
|
|
11160
|
+
input.operatorPublicKeyPem,
|
|
11161
|
+
adminSigningBytes,
|
|
11162
|
+
adminSignatureB64
|
|
11163
|
+
);
|
|
11164
|
+
if (!selfOk) {
|
|
11165
|
+
throw new Error(
|
|
11166
|
+
`internal error: just-produced bootstrap admin signature failed self-verification. Refusing to write a bad envelope. File a bug at https://github.com/OpenThinkAi/stamp-cli/issues.`
|
|
11167
|
+
);
|
|
11168
|
+
}
|
|
11169
|
+
const trustAnchorSignatures = [
|
|
11170
|
+
{
|
|
11171
|
+
signer_key_id: input.operatorFingerprint,
|
|
11172
|
+
signature: adminSignatureB64
|
|
11173
|
+
}
|
|
11174
|
+
];
|
|
11175
|
+
const payload = {
|
|
11176
|
+
schema_version: PR_ATTESTATION_SCHEMA_VERSION,
|
|
11177
|
+
patch_id: input.patchId,
|
|
11178
|
+
base_sha: input.baseSha,
|
|
11179
|
+
head_sha: input.headSha,
|
|
11180
|
+
target_branch: input.targetBranch,
|
|
11181
|
+
target_branch_tip_sha: input.targetBranchTipSha,
|
|
11182
|
+
diff_sha256: diffSha256,
|
|
11183
|
+
manifest_snapshot_sha256: manifestSnapshot,
|
|
11184
|
+
approvals: [],
|
|
11185
|
+
checks: [],
|
|
11186
|
+
trust_anchor_signatures: trustAnchorSignatures,
|
|
11187
|
+
signer_key_id: input.operatorFingerprint,
|
|
11188
|
+
migration_bootstrap: marker
|
|
11189
|
+
};
|
|
11190
|
+
const signature = signBytes(input.operatorPrivateKeyPem, serializePayload2(payload));
|
|
11191
|
+
return {
|
|
11192
|
+
payload,
|
|
11193
|
+
signature,
|
|
11194
|
+
reviewerNames: []
|
|
11195
|
+
};
|
|
11196
|
+
}
|
|
11197
|
+
function readWorkingTreeManifestYaml(repoRoot) {
|
|
11198
|
+
try {
|
|
11199
|
+
return showAtRef("HEAD", ".stamp/trusted-keys/manifest.yml", repoRoot);
|
|
11200
|
+
} catch {
|
|
11201
|
+
return null;
|
|
11202
|
+
}
|
|
11203
|
+
}
|
|
11204
|
+
function readAtBaseSha(repoRoot, baseSha, relPath) {
|
|
11205
|
+
try {
|
|
11206
|
+
return showAtRef(baseSha, relPath, repoRoot);
|
|
11207
|
+
} catch {
|
|
11208
|
+
return null;
|
|
11209
|
+
}
|
|
11210
|
+
}
|
|
11211
|
+
function extractPathRulesFromYaml(yamlText) {
|
|
11212
|
+
let parsed;
|
|
11213
|
+
try {
|
|
11214
|
+
parsed = parseYaml11(yamlText);
|
|
11215
|
+
} catch {
|
|
11216
|
+
return [];
|
|
11217
|
+
}
|
|
11218
|
+
if (!parsed || typeof parsed !== "object") return [];
|
|
11219
|
+
const { rules } = parsePathRules(parsed.path_rules);
|
|
11220
|
+
return rules;
|
|
11221
|
+
}
|
|
11222
|
+
function matchAnyCoveringRule(activatedPaths, rules) {
|
|
11223
|
+
for (const rule of rules) {
|
|
11224
|
+
const allMatch = activatedPaths.every((p) => pathMatchesAny(p, [rule.pattern]));
|
|
11225
|
+
if (allMatch) return rule;
|
|
11226
|
+
}
|
|
11227
|
+
return null;
|
|
11228
|
+
}
|
|
10614
11229
|
function pushBranchAndAttestation(remote, attestationRef, repoRoot) {
|
|
10615
|
-
const result =
|
|
11230
|
+
const result = spawnSync15(
|
|
10616
11231
|
"git",
|
|
10617
11232
|
["push", "--atomic", remote, "HEAD", attestationRef],
|
|
10618
11233
|
{ cwd: repoRoot, stdio: "inherit" }
|
|
@@ -10907,7 +11522,7 @@ function loadOrEmpty() {
|
|
|
10907
11522
|
}
|
|
10908
11523
|
|
|
10909
11524
|
// src/commands/reviewers.ts
|
|
10910
|
-
import { spawnSync as
|
|
11525
|
+
import { spawnSync as spawnSync16 } from "child_process";
|
|
10911
11526
|
import {
|
|
10912
11527
|
existsSync as existsSync21,
|
|
10913
11528
|
readFileSync as readFileSync18,
|
|
@@ -10916,7 +11531,7 @@ import {
|
|
|
10916
11531
|
writeFileSync as writeFileSync15
|
|
10917
11532
|
} from "fs";
|
|
10918
11533
|
import { join as join16, relative as relative3, resolve as resolve2 } from "path";
|
|
10919
|
-
import { parse as
|
|
11534
|
+
import { parse as parseYaml12, stringify as stringifyYaml3 } from "yaml";
|
|
10920
11535
|
|
|
10921
11536
|
// src/lib/reviewerLock.ts
|
|
10922
11537
|
import { existsSync as existsSync20, readFileSync as readFileSync17, writeFileSync as writeFileSync14 } from "fs";
|
|
@@ -10951,6 +11566,11 @@ function checkReviewerDrift(repoRoot, reviewerName, def) {
|
|
|
10951
11566
|
if (!lock) {
|
|
10952
11567
|
return unpinnedResult();
|
|
10953
11568
|
}
|
|
11569
|
+
if (def.prompt === void 0) {
|
|
11570
|
+
throw new Error(
|
|
11571
|
+
`reviewer "${reviewerName}" has a lock file but no \`prompt:\` configured (server-bundled in Shape 4). Lock files pin local prompt bytes and don't apply in server-attested mode. Delete .stamp/reviewers/${reviewerName}.lock.json to un-pin, or set \`reviewers.${reviewerName}.prompt\` in .stamp/config.yml if you intend to author the prompt locally.`
|
|
11572
|
+
);
|
|
11573
|
+
}
|
|
10954
11574
|
const promptPath = join15(repoRoot, def.prompt);
|
|
10955
11575
|
if (!existsSync20(promptPath)) {
|
|
10956
11576
|
throw new Error(
|
|
@@ -11036,6 +11656,10 @@ function reviewersList() {
|
|
|
11036
11656
|
const maxNameLen = Math.max(...names.map((n) => n.length));
|
|
11037
11657
|
for (const name of names) {
|
|
11038
11658
|
const def = config2.reviewers[name];
|
|
11659
|
+
if (def.prompt === void 0) {
|
|
11660
|
+
console.log(` ${name.padEnd(maxNameLen)} (server-bundled \u2014 no local prompt)`);
|
|
11661
|
+
continue;
|
|
11662
|
+
}
|
|
11039
11663
|
const abs = resolve2(repoRoot, def.prompt);
|
|
11040
11664
|
let annotation = "";
|
|
11041
11665
|
if (!existsSync21(abs)) {
|
|
@@ -11062,6 +11686,11 @@ function reviewersEdit(name) {
|
|
|
11062
11686
|
`reviewer "${name}" is not configured. Run \`stamp reviewers list\` to see available reviewers.`
|
|
11063
11687
|
);
|
|
11064
11688
|
}
|
|
11689
|
+
if (def.prompt === void 0) {
|
|
11690
|
+
throw new Error(
|
|
11691
|
+
`reviewer "${name}" has no \`prompt:\` configured (server-bundled in Shape 4). There is no local prompt file to edit. To author the prompt locally, set \`reviewers.${name}.prompt\` to a path under .stamp/reviewers/ in .stamp/config.yml.`
|
|
11692
|
+
);
|
|
11693
|
+
}
|
|
11065
11694
|
const target = resolve2(repoRoot, def.prompt);
|
|
11066
11695
|
launchEditor(target);
|
|
11067
11696
|
}
|
|
@@ -11126,6 +11755,10 @@ function reviewersRemove(name, opts = {}) {
|
|
|
11126
11755
|
delete config2.reviewers[name];
|
|
11127
11756
|
writeFileSync15(configPath, stringifyConfig(config2));
|
|
11128
11757
|
console.log(`reviewer "${name}" removed from .stamp/config.yml`);
|
|
11758
|
+
if (def.prompt === void 0) {
|
|
11759
|
+
console.log(`(no local prompt file \u2014 reviewer was server-bundled)`);
|
|
11760
|
+
return;
|
|
11761
|
+
}
|
|
11129
11762
|
if (opts.deleteFile) {
|
|
11130
11763
|
const promptAbs = resolve2(repoRoot, def.prompt);
|
|
11131
11764
|
if (existsSync21(promptAbs)) {
|
|
@@ -11159,6 +11792,11 @@ async function reviewersTest(name, diff) {
|
|
|
11159
11792
|
console.log(` prompt sourced from working tree (test/iteration use case)`);
|
|
11160
11793
|
console.log();
|
|
11161
11794
|
const def = config2.reviewers[name];
|
|
11795
|
+
if (def.prompt === void 0) {
|
|
11796
|
+
throw new Error(
|
|
11797
|
+
`reviewer "${name}" has no \`prompt:\` configured (server-bundled in Shape 4). \`stamp reviewers test\` is a local prompt-iteration helper and needs a local prompt file to invoke. Set \`reviewers.${name}.prompt\` in .stamp/config.yml to a path under .stamp/reviewers/ to use this command.`
|
|
11798
|
+
);
|
|
11799
|
+
}
|
|
11162
11800
|
const promptPath = join16(repoRoot, def.prompt);
|
|
11163
11801
|
const systemPrompt = readFileSync18(promptPath, "utf8");
|
|
11164
11802
|
const result = await invokeReviewer({
|
|
@@ -11203,7 +11841,9 @@ function reviewersShow(name, opts) {
|
|
|
11203
11841
|
const bar = "\u2500".repeat(72);
|
|
11204
11842
|
console.log(bar);
|
|
11205
11843
|
console.log(`reviewer: ${name}`);
|
|
11206
|
-
console.log(
|
|
11844
|
+
console.log(
|
|
11845
|
+
`prompt: ${config2.reviewers[name].prompt ?? "(server-bundled \u2014 no local prompt)"}`
|
|
11846
|
+
);
|
|
11207
11847
|
console.log(bar);
|
|
11208
11848
|
if (stats.total === 0) {
|
|
11209
11849
|
console.log(" no verdicts recorded yet");
|
|
@@ -11290,7 +11930,7 @@ async function reviewersFetch(reviewerName, opts) {
|
|
|
11290
11930
|
let tools;
|
|
11291
11931
|
let mcpServers;
|
|
11292
11932
|
if (configYaml !== null) {
|
|
11293
|
-
const parsed =
|
|
11933
|
+
const parsed = parseYaml12(configYaml) ?? {};
|
|
11294
11934
|
if (Array.isArray(parsed.tools)) {
|
|
11295
11935
|
tools = parseToolsLoose(parsed.tools);
|
|
11296
11936
|
}
|
|
@@ -11551,7 +12191,7 @@ function buildConfigYamlHint(reviewerName, tools, mcpServers) {
|
|
|
11551
12191
|
}
|
|
11552
12192
|
function launchEditor(path2) {
|
|
11553
12193
|
const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? (process.platform === "win32" ? "notepad" : "vi");
|
|
11554
|
-
const result =
|
|
12194
|
+
const result = spawnSync16(editor, [path2], { stdio: "inherit" });
|
|
11555
12195
|
if (result.error) {
|
|
11556
12196
|
throw new Error(
|
|
11557
12197
|
`failed to launch editor "${editor}": ${result.error.message}`
|
|
@@ -11635,7 +12275,7 @@ function printGate(result, base_sha, head_sha) {
|
|
|
11635
12275
|
}
|
|
11636
12276
|
|
|
11637
12277
|
// src/commands/update.ts
|
|
11638
|
-
import { spawnSync as
|
|
12278
|
+
import { spawnSync as spawnSync17 } from "child_process";
|
|
11639
12279
|
var PKG_NAME = "@openthink/stamp";
|
|
11640
12280
|
function compareSemver(a, b) {
|
|
11641
12281
|
const parse2 = (v) => (v.split("-")[0] ?? "0.0.0").split(".").map((n) => parseInt(n, 10) || 0);
|
|
@@ -11654,7 +12294,7 @@ function runUpdate() {
|
|
|
11654
12294
|
`);
|
|
11655
12295
|
process.stdout.write(`checking npm registry for latest...
|
|
11656
12296
|
`);
|
|
11657
|
-
const viewResult =
|
|
12297
|
+
const viewResult = spawnSync17("npm", ["view", PKG_NAME, "version"], {
|
|
11658
12298
|
encoding: "utf8"
|
|
11659
12299
|
});
|
|
11660
12300
|
if (viewResult.error || viewResult.status !== 0) {
|
|
@@ -11688,7 +12328,7 @@ function runUpdate() {
|
|
|
11688
12328
|
}
|
|
11689
12329
|
process.stdout.write(`installing ${PKG_NAME}@${latest}...
|
|
11690
12330
|
`);
|
|
11691
|
-
const installResult =
|
|
12331
|
+
const installResult = spawnSync17(
|
|
11692
12332
|
"npm",
|
|
11693
12333
|
["install", "-g", `${PKG_NAME}@${latest}`],
|
|
11694
12334
|
{ stdio: "inherit" }
|
|
@@ -11709,9 +12349,9 @@ through that tool instead \u2014 this command only uses 'npm install -g'.`
|
|
|
11709
12349
|
}
|
|
11710
12350
|
|
|
11711
12351
|
// src/commands/verify.ts
|
|
11712
|
-
import { execFileSync as execFileSync7, spawnSync as
|
|
12352
|
+
import { execFileSync as execFileSync7, spawnSync as spawnSync18 } from "child_process";
|
|
11713
12353
|
function loadConfigAtSha(sha, repoRoot) {
|
|
11714
|
-
const result =
|
|
12354
|
+
const result = spawnSync18(
|
|
11715
12355
|
"git",
|
|
11716
12356
|
["show", `${sha}:.stamp/config.yml`],
|
|
11717
12357
|
{ cwd: repoRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }
|
|
@@ -11849,6 +12489,12 @@ function verifyReviewerHashes(sha, payload, repoRoot, config2) {
|
|
|
11849
12489
|
`v2 attestation: reviewer "${approval.reviewer}" is in payload but not defined in config.reviewers at the merge commit`
|
|
11850
12490
|
);
|
|
11851
12491
|
}
|
|
12492
|
+
if (def.prompt === void 0) {
|
|
12493
|
+
fail(
|
|
12494
|
+
sha,
|
|
12495
|
+
`v2 attestation: reviewer "${approval.reviewer}" has no \`prompt:\` in .stamp/config.yml at the merge commit. v2 attestations cite prompt_sha256 over the on-tree prompt file, but the producer flow for server-bundled prompts is v4 (server-attested) \u2014 the envelope and config shape are inconsistent.`
|
|
12496
|
+
);
|
|
12497
|
+
}
|
|
11852
12498
|
const promptBytes = tryGitShow(`${sha}:${def.prompt}`, repoRoot);
|
|
11853
12499
|
if (promptBytes === null) {
|
|
11854
12500
|
fail(
|
|
@@ -11922,8 +12568,9 @@ function git2(args, cwd) {
|
|
|
11922
12568
|
}
|
|
11923
12569
|
|
|
11924
12570
|
// src/commands/verifyPr.ts
|
|
11925
|
-
import { spawnSync as
|
|
11926
|
-
import {
|
|
12571
|
+
import { spawnSync as spawnSync19 } from "child_process";
|
|
12572
|
+
import { createHash as createHash13 } from "crypto";
|
|
12573
|
+
import { parse as parseYaml13 } from "yaml";
|
|
11927
12574
|
function runVerifyPr(opts) {
|
|
11928
12575
|
const repoRoot = opts.repoPath ?? findRepoRoot();
|
|
11929
12576
|
const resolved = resolveDiff(`${opts.base}..${opts.head}`, repoRoot);
|
|
@@ -11964,6 +12611,10 @@ function runVerifyPr(opts) {
|
|
|
11964
12611
|
);
|
|
11965
12612
|
}
|
|
11966
12613
|
if (envelope.payload.schema_version >= MIN_ACCEPTED_PR_ATTESTATION_VERSION) {
|
|
12614
|
+
if (envelope.payload.migration_bootstrap !== void 0) {
|
|
12615
|
+
verifyBootstrapEnvelope(envelope, opts, resolved, patch_id, repoRoot);
|
|
12616
|
+
return;
|
|
12617
|
+
}
|
|
11967
12618
|
verifyV3Envelope(envelope, opts, resolved, patch_id, repoRoot);
|
|
11968
12619
|
return;
|
|
11969
12620
|
}
|
|
@@ -11981,6 +12632,7 @@ function verifyV3Envelope(envelope, opts, resolved, patch_id, repoRoot) {
|
|
|
11981
12632
|
head_sha: envelope.payload.head_sha,
|
|
11982
12633
|
target_branch: envelope.payload.target_branch,
|
|
11983
12634
|
diff_sha256: envelope.payload.diff_sha256,
|
|
12635
|
+
manifest_snapshot_sha256: envelope.payload.manifest_snapshot_sha256,
|
|
11984
12636
|
approvals: envelope.payload.approvals,
|
|
11985
12637
|
checks: envelope.payload.checks,
|
|
11986
12638
|
trust_anchor_signatures: envelope.payload.trust_anchor_signatures,
|
|
@@ -12091,6 +12743,310 @@ function verifyV3Envelope(envelope, opts, resolved, patch_id, repoRoot) {
|
|
|
12091
12743
|
schema_version: envelope.payload.schema_version
|
|
12092
12744
|
});
|
|
12093
12745
|
}
|
|
12746
|
+
function verifyBootstrapEnvelope(envelope, opts, resolved, patch_id, repoRoot) {
|
|
12747
|
+
const payload = envelope.payload;
|
|
12748
|
+
const marker = payload.migration_bootstrap;
|
|
12749
|
+
if (!marker) {
|
|
12750
|
+
fail2(
|
|
12751
|
+
`internal error: bootstrap dispatch with no marker (should be unreachable)`,
|
|
12752
|
+
patch_id,
|
|
12753
|
+
resolved.base_sha,
|
|
12754
|
+
resolved.head_sha
|
|
12755
|
+
);
|
|
12756
|
+
}
|
|
12757
|
+
if (payload.approvals.length !== 0) {
|
|
12758
|
+
fail2(
|
|
12759
|
+
`bootstrap envelope has ${payload.approvals.length} approvals \u2014 bootstrap envelopes MUST have approvals: []. Non-empty server signatures should go through the normal (non-bootstrap) attest flow.`,
|
|
12760
|
+
patch_id,
|
|
12761
|
+
resolved.base_sha,
|
|
12762
|
+
resolved.head_sha
|
|
12763
|
+
);
|
|
12764
|
+
}
|
|
12765
|
+
const validation = validateShape4ActivationDiff({
|
|
12766
|
+
repoRoot,
|
|
12767
|
+
baseSha: payload.base_sha,
|
|
12768
|
+
headSha: payload.head_sha
|
|
12769
|
+
});
|
|
12770
|
+
if (!validation.ok) {
|
|
12771
|
+
fail2(
|
|
12772
|
+
`bootstrap envelope rejected: diff does not match the Shape 4 activation whitelist \u2014 ${validation.reason}`,
|
|
12773
|
+
patch_id,
|
|
12774
|
+
resolved.base_sha,
|
|
12775
|
+
resolved.head_sha
|
|
12776
|
+
);
|
|
12777
|
+
}
|
|
12778
|
+
const claimed = [...marker.activated_paths].sort();
|
|
12779
|
+
const actual = [...validation.activatedPaths].sort();
|
|
12780
|
+
if (claimed.length !== actual.length || !claimed.every((p, i) => p === actual[i])) {
|
|
12781
|
+
fail2(
|
|
12782
|
+
`bootstrap envelope's migration_bootstrap.activated_paths (${claimed.join(", ")}) does not match the actual changed-files set (${actual.join(", ")}). The marker must declare exactly the paths the bootstrap PR activates.`,
|
|
12783
|
+
patch_id,
|
|
12784
|
+
resolved.base_sha,
|
|
12785
|
+
resolved.head_sha
|
|
12786
|
+
);
|
|
12787
|
+
}
|
|
12788
|
+
const baseManifest = loadManifestAtBase(payload.base_sha, repoRoot);
|
|
12789
|
+
if (!baseManifest) {
|
|
12790
|
+
fail2(
|
|
12791
|
+
`bootstrap envelope rejected: .stamp/trusted-keys/manifest.yml is missing or malformed at base ${payload.base_sha.slice(0, 8)}. Bootstrap requires the manifest in the merge-base tree (it's the trust root for the operator's outer signature and the admin counter-signature).`,
|
|
12792
|
+
patch_id,
|
|
12793
|
+
resolved.base_sha,
|
|
12794
|
+
resolved.head_sha
|
|
12795
|
+
);
|
|
12796
|
+
}
|
|
12797
|
+
if (!payload.manifest_snapshot_sha256) {
|
|
12798
|
+
fail2(
|
|
12799
|
+
`bootstrap envelope is missing manifest_snapshot_sha256 \u2014 required for v3+ envelopes`,
|
|
12800
|
+
patch_id,
|
|
12801
|
+
resolved.base_sha,
|
|
12802
|
+
resolved.head_sha
|
|
12803
|
+
);
|
|
12804
|
+
}
|
|
12805
|
+
const computedSnapshot = snapshotSha256(baseManifest);
|
|
12806
|
+
if (payload.manifest_snapshot_sha256 !== computedSnapshot) {
|
|
12807
|
+
fail2(
|
|
12808
|
+
`bootstrap envelope manifest_snapshot_sha256 (${payload.manifest_snapshot_sha256.slice(0, 16)}\u2026) does not match the manifest at base ${payload.base_sha.slice(0, 8)} (${computedSnapshot.slice(0, 16)}\u2026)`,
|
|
12809
|
+
patch_id,
|
|
12810
|
+
resolved.base_sha,
|
|
12811
|
+
resolved.head_sha
|
|
12812
|
+
);
|
|
12813
|
+
}
|
|
12814
|
+
const pubkeyByFingerprint = loadPubkeysAtBase(payload.base_sha, repoRoot);
|
|
12815
|
+
const operatorCaps = resolveCapability(baseManifest, payload.signer_key_id);
|
|
12816
|
+
if (operatorCaps === null) {
|
|
12817
|
+
fail2(
|
|
12818
|
+
`bootstrap envelope signer ${payload.signer_key_id} is not listed in the manifest at base ${payload.base_sha.slice(0, 8)}`,
|
|
12819
|
+
patch_id,
|
|
12820
|
+
resolved.base_sha,
|
|
12821
|
+
resolved.head_sha
|
|
12822
|
+
);
|
|
12823
|
+
}
|
|
12824
|
+
if (!operatorCaps.includes("operator") && !operatorCaps.includes("admin")) {
|
|
12825
|
+
fail2(
|
|
12826
|
+
`bootstrap envelope signer ${payload.signer_key_id} has capabilities [${operatorCaps.join(", ")}] at base ${payload.base_sha.slice(0, 8)} \u2014 needs operator or admin`,
|
|
12827
|
+
patch_id,
|
|
12828
|
+
resolved.base_sha,
|
|
12829
|
+
resolved.head_sha
|
|
12830
|
+
);
|
|
12831
|
+
}
|
|
12832
|
+
const operatorPem = pubkeyByFingerprint.get(payload.signer_key_id);
|
|
12833
|
+
if (!operatorPem) {
|
|
12834
|
+
fail2(
|
|
12835
|
+
`bootstrap envelope signer ${payload.signer_key_id} has no matching .pub file in .stamp/trusted-keys/ at base ${payload.base_sha.slice(0, 8)}`,
|
|
12836
|
+
patch_id,
|
|
12837
|
+
resolved.base_sha,
|
|
12838
|
+
resolved.head_sha
|
|
12839
|
+
);
|
|
12840
|
+
}
|
|
12841
|
+
const operatorBytes = serializePayload2(payload);
|
|
12842
|
+
let operatorSigOk = false;
|
|
12843
|
+
try {
|
|
12844
|
+
operatorSigOk = verifyBytes(operatorPem, operatorBytes, envelope.signature);
|
|
12845
|
+
} catch (err) {
|
|
12846
|
+
fail2(
|
|
12847
|
+
`bootstrap envelope operator-signature verification threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
12848
|
+
patch_id,
|
|
12849
|
+
resolved.base_sha,
|
|
12850
|
+
resolved.head_sha
|
|
12851
|
+
);
|
|
12852
|
+
}
|
|
12853
|
+
if (!operatorSigOk) {
|
|
12854
|
+
fail2(
|
|
12855
|
+
`bootstrap envelope operator signature does not verify against the operator's pubkey ${payload.signer_key_id}`,
|
|
12856
|
+
patch_id,
|
|
12857
|
+
resolved.base_sha,
|
|
12858
|
+
resolved.head_sha
|
|
12859
|
+
);
|
|
12860
|
+
}
|
|
12861
|
+
const sigs = payload.trust_anchor_signatures ?? [];
|
|
12862
|
+
if (sigs.length !== 1) {
|
|
12863
|
+
fail2(
|
|
12864
|
+
`bootstrap envelope must carry exactly one trust_anchor_signatures entry (got ${sigs.length}) \u2014 multi-admin bootstrap collection is not supported by the bootstrap path. Lower path_rules minimum_signatures or use the standard admin-sign flow.`,
|
|
12865
|
+
patch_id,
|
|
12866
|
+
resolved.base_sha,
|
|
12867
|
+
resolved.head_sha
|
|
12868
|
+
);
|
|
12869
|
+
}
|
|
12870
|
+
const adminSig = sigs[0];
|
|
12871
|
+
const adminCaps = resolveCapability(baseManifest, adminSig.signer_key_id);
|
|
12872
|
+
if (adminCaps === null) {
|
|
12873
|
+
fail2(
|
|
12874
|
+
`bootstrap envelope admin signer ${adminSig.signer_key_id} is not listed in the manifest at base ${payload.base_sha.slice(0, 8)}`,
|
|
12875
|
+
patch_id,
|
|
12876
|
+
resolved.base_sha,
|
|
12877
|
+
resolved.head_sha
|
|
12878
|
+
);
|
|
12879
|
+
}
|
|
12880
|
+
if (!adminCaps.includes("admin")) {
|
|
12881
|
+
fail2(
|
|
12882
|
+
`bootstrap envelope admin signer ${adminSig.signer_key_id} has capabilities [${adminCaps.join(", ")}] at base ${payload.base_sha.slice(0, 8)} \u2014 needs admin`,
|
|
12883
|
+
patch_id,
|
|
12884
|
+
resolved.base_sha,
|
|
12885
|
+
resolved.head_sha
|
|
12886
|
+
);
|
|
12887
|
+
}
|
|
12888
|
+
const adminPem = pubkeyByFingerprint.get(adminSig.signer_key_id);
|
|
12889
|
+
if (!adminPem) {
|
|
12890
|
+
fail2(
|
|
12891
|
+
`bootstrap envelope admin signer ${adminSig.signer_key_id} has no matching .pub file in .stamp/trusted-keys/ at base ${payload.base_sha.slice(0, 8)}`,
|
|
12892
|
+
patch_id,
|
|
12893
|
+
resolved.base_sha,
|
|
12894
|
+
resolved.head_sha
|
|
12895
|
+
);
|
|
12896
|
+
}
|
|
12897
|
+
const v4View = {
|
|
12898
|
+
schema_version: payload.schema_version,
|
|
12899
|
+
base_sha: payload.base_sha,
|
|
12900
|
+
head_sha: payload.head_sha,
|
|
12901
|
+
target_branch: payload.target_branch,
|
|
12902
|
+
diff_sha256: payload.diff_sha256,
|
|
12903
|
+
manifest_snapshot_sha256: payload.manifest_snapshot_sha256,
|
|
12904
|
+
approvals: [],
|
|
12905
|
+
checks: payload.checks,
|
|
12906
|
+
trust_anchor_signatures: [],
|
|
12907
|
+
signer_key_id: payload.signer_key_id
|
|
12908
|
+
};
|
|
12909
|
+
const adminBytes = bootstrapAdminSigningBytes({ payloadV4: v4View, marker });
|
|
12910
|
+
let adminSigOk = false;
|
|
12911
|
+
try {
|
|
12912
|
+
adminSigOk = verifyBytes(adminPem, adminBytes, adminSig.signature);
|
|
12913
|
+
} catch (err) {
|
|
12914
|
+
fail2(
|
|
12915
|
+
`bootstrap envelope admin-signature verification threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
12916
|
+
patch_id,
|
|
12917
|
+
resolved.base_sha,
|
|
12918
|
+
resolved.head_sha
|
|
12919
|
+
);
|
|
12920
|
+
}
|
|
12921
|
+
if (!adminSigOk) {
|
|
12922
|
+
fail2(
|
|
12923
|
+
`bootstrap envelope admin signature by ${adminSig.signer_key_id} does not verify against the bootstrap signing-bytes (payload-with-marker, trust_anchor_signatures: []).`,
|
|
12924
|
+
patch_id,
|
|
12925
|
+
resolved.base_sha,
|
|
12926
|
+
resolved.head_sha
|
|
12927
|
+
);
|
|
12928
|
+
}
|
|
12929
|
+
const pathRules = loadPathRulesAtBase(payload.base_sha, repoRoot);
|
|
12930
|
+
if (pathRules.length === 0) {
|
|
12931
|
+
fail2(
|
|
12932
|
+
`bootstrap envelope rejected: no path_rules configured at base ${payload.base_sha.slice(0, 8)}. The bootstrap path requires path_rules covering the activated paths with bypass_review_cycle: true \u2014 typically already in place from a prior Shape 2 migration.`,
|
|
12933
|
+
patch_id,
|
|
12934
|
+
resolved.base_sha,
|
|
12935
|
+
resolved.head_sha
|
|
12936
|
+
);
|
|
12937
|
+
}
|
|
12938
|
+
for (const p of marker.activated_paths) {
|
|
12939
|
+
let coveredBy = null;
|
|
12940
|
+
for (const rule2 of pathRules) {
|
|
12941
|
+
if (pathMatchesAny(p, [rule2.pattern])) {
|
|
12942
|
+
coveredBy = rule2;
|
|
12943
|
+
break;
|
|
12944
|
+
}
|
|
12945
|
+
}
|
|
12946
|
+
if (!coveredBy) {
|
|
12947
|
+
fail2(
|
|
12948
|
+
`bootstrap envelope rejected: path_rules at base ${payload.base_sha.slice(0, 8)} does not cover activated path "${p}". Configured patterns: ${pathRules.map((r) => `"${r.pattern}"`).join(", ")}.`,
|
|
12949
|
+
patch_id,
|
|
12950
|
+
resolved.base_sha,
|
|
12951
|
+
resolved.head_sha
|
|
12952
|
+
);
|
|
12953
|
+
}
|
|
12954
|
+
if (!coveredBy.bypass_review_cycle) {
|
|
12955
|
+
fail2(
|
|
12956
|
+
`bootstrap envelope rejected: path_rule "${coveredBy.pattern}" at base ${payload.base_sha.slice(0, 8)} has bypass_review_cycle: false. Bootstrap requires the matched rule to opt out of the reviewer cycle.`,
|
|
12957
|
+
patch_id,
|
|
12958
|
+
resolved.base_sha,
|
|
12959
|
+
resolved.head_sha
|
|
12960
|
+
);
|
|
12961
|
+
}
|
|
12962
|
+
}
|
|
12963
|
+
if (payload.target_branch !== opts.into) {
|
|
12964
|
+
fail2(
|
|
12965
|
+
`bootstrap envelope target_branch ("${payload.target_branch}") does not match --into ("${opts.into}")`,
|
|
12966
|
+
patch_id,
|
|
12967
|
+
resolved.base_sha,
|
|
12968
|
+
resolved.head_sha
|
|
12969
|
+
);
|
|
12970
|
+
}
|
|
12971
|
+
if (!payload.diff_sha256) {
|
|
12972
|
+
fail2(
|
|
12973
|
+
`bootstrap envelope is missing diff_sha256 \u2014 required for v3+ envelopes`,
|
|
12974
|
+
patch_id,
|
|
12975
|
+
resolved.base_sha,
|
|
12976
|
+
resolved.head_sha
|
|
12977
|
+
);
|
|
12978
|
+
}
|
|
12979
|
+
const actualDiff = spawnSync19(
|
|
12980
|
+
"git",
|
|
12981
|
+
["diff", `${payload.base_sha}...${payload.head_sha}`],
|
|
12982
|
+
{ cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }
|
|
12983
|
+
);
|
|
12984
|
+
if (actualDiff.status !== 0) {
|
|
12985
|
+
fail2(
|
|
12986
|
+
`bootstrap envelope rejected: could not compute base...head diff for diff_sha256 check`,
|
|
12987
|
+
patch_id,
|
|
12988
|
+
resolved.base_sha,
|
|
12989
|
+
resolved.head_sha
|
|
12990
|
+
);
|
|
12991
|
+
}
|
|
12992
|
+
const diffText = actualDiff.stdout ?? "";
|
|
12993
|
+
const actualDiffSha256 = createHash13("sha256").update(Buffer.from(diffText, "utf8")).digest("hex");
|
|
12994
|
+
if (actualDiffSha256 !== payload.diff_sha256) {
|
|
12995
|
+
fail2(
|
|
12996
|
+
`bootstrap envelope diff_sha256 mismatch \u2014 payload claims ${payload.diff_sha256.slice(0, 12)}\u2026 but base...head hashes to ${actualDiffSha256.slice(0, 12)}\u2026. The operator signed against a different diff.`,
|
|
12997
|
+
patch_id,
|
|
12998
|
+
resolved.base_sha,
|
|
12999
|
+
resolved.head_sha
|
|
13000
|
+
);
|
|
13001
|
+
}
|
|
13002
|
+
let configYaml;
|
|
13003
|
+
try {
|
|
13004
|
+
configYaml = showAtRef(payload.base_sha, ".stamp/config.yml", repoRoot);
|
|
13005
|
+
} catch (e) {
|
|
13006
|
+
fail2(
|
|
13007
|
+
`bootstrap envelope rejected: could not read .stamp/config.yml at base ${payload.base_sha.slice(0, 8)}: ${e.message}`,
|
|
13008
|
+
patch_id,
|
|
13009
|
+
resolved.base_sha,
|
|
13010
|
+
resolved.head_sha
|
|
13011
|
+
);
|
|
13012
|
+
}
|
|
13013
|
+
const config2 = parseConfigFromYaml(configYaml);
|
|
13014
|
+
const rule = findBranchRule(config2.branches, opts.into);
|
|
13015
|
+
if (!rule) {
|
|
13016
|
+
fail2(
|
|
13017
|
+
`bootstrap envelope rejected: no branch rule for "${opts.into}" in .stamp/config.yml at base ${payload.base_sha.slice(0, 8)}`,
|
|
13018
|
+
patch_id,
|
|
13019
|
+
resolved.base_sha,
|
|
13020
|
+
resolved.head_sha
|
|
13021
|
+
);
|
|
13022
|
+
}
|
|
13023
|
+
if (rule.strict_base) {
|
|
13024
|
+
const currentTip = runGit(
|
|
13025
|
+
["rev-parse", `${opts.into}^{commit}`],
|
|
13026
|
+
repoRoot
|
|
13027
|
+
).trim();
|
|
13028
|
+
if (envelope.payload.target_branch_tip_sha !== currentTip) {
|
|
13029
|
+
fail2(
|
|
13030
|
+
`bootstrap strict_base check failed: attestation was signed when ${opts.into} was at ${envelope.payload.target_branch_tip_sha?.slice(0, 8)}, but ${opts.into} is now at ${currentTip.slice(0, 8)}`,
|
|
13031
|
+
patch_id,
|
|
13032
|
+
resolved.base_sha,
|
|
13033
|
+
resolved.head_sha
|
|
13034
|
+
);
|
|
13035
|
+
}
|
|
13036
|
+
}
|
|
13037
|
+
printSuccess3({
|
|
13038
|
+
patch_id,
|
|
13039
|
+
base_sha: resolved.base_sha,
|
|
13040
|
+
head_sha: resolved.head_sha,
|
|
13041
|
+
target_branch: opts.into,
|
|
13042
|
+
signer_key_id: payload.signer_key_id,
|
|
13043
|
+
approvals: [],
|
|
13044
|
+
strict_base: rule.strict_base ?? false,
|
|
13045
|
+
schema_version: envelope.payload.schema_version,
|
|
13046
|
+
bootstrap: true,
|
|
13047
|
+
activated_paths: marker.activated_paths
|
|
13048
|
+
});
|
|
13049
|
+
}
|
|
12094
13050
|
function loadManifestAtBase(base_sha, repoRoot) {
|
|
12095
13051
|
let yaml;
|
|
12096
13052
|
try {
|
|
@@ -12101,7 +13057,7 @@ function loadManifestAtBase(base_sha, repoRoot) {
|
|
|
12101
13057
|
return parseManifest(yaml);
|
|
12102
13058
|
}
|
|
12103
13059
|
function loadPubkeysAtBase(base_sha, repoRoot) {
|
|
12104
|
-
const lsResult =
|
|
13060
|
+
const lsResult = spawnSync19(
|
|
12105
13061
|
"git",
|
|
12106
13062
|
["ls-tree", "--name-only", base_sha, ".stamp/trusted-keys/"],
|
|
12107
13063
|
{ cwd: repoRoot, encoding: "utf8" }
|
|
@@ -12113,7 +13069,7 @@ function loadPubkeysAtBase(base_sha, repoRoot) {
|
|
|
12113
13069
|
return l.startsWith(prefix) ? l.slice(prefix.length) : l;
|
|
12114
13070
|
}).filter((n) => n.endsWith(".pub"));
|
|
12115
13071
|
return buildPubkeyMap(pubFiles, (relPath) => {
|
|
12116
|
-
const show =
|
|
13072
|
+
const show = spawnSync19(
|
|
12117
13073
|
"git",
|
|
12118
13074
|
["show", `${base_sha}:${relPath}`],
|
|
12119
13075
|
{ cwd: repoRoot, encoding: "utf8" }
|
|
@@ -12131,7 +13087,7 @@ function loadPathRulesAtBase(base_sha, repoRoot) {
|
|
|
12131
13087
|
}
|
|
12132
13088
|
let raw;
|
|
12133
13089
|
try {
|
|
12134
|
-
raw =
|
|
13090
|
+
raw = parseYaml13(yaml);
|
|
12135
13091
|
} catch {
|
|
12136
13092
|
return [];
|
|
12137
13093
|
}
|
|
@@ -12144,7 +13100,7 @@ function loadPathRulesAtBase(base_sha, repoRoot) {
|
|
|
12144
13100
|
return parsedRules.rules;
|
|
12145
13101
|
}
|
|
12146
13102
|
function loadChangedFiles(base_sha, head_sha, repoRoot) {
|
|
12147
|
-
const result =
|
|
13103
|
+
const result = spawnSync19(
|
|
12148
13104
|
"git",
|
|
12149
13105
|
["diff", "-z", "--name-only", `${base_sha}...${head_sha}`],
|
|
12150
13106
|
{ cwd: repoRoot, encoding: "utf8" }
|
|
@@ -12160,12 +13116,18 @@ function printSuccess3(s) {
|
|
|
12160
13116
|
);
|
|
12161
13117
|
console.log(bar);
|
|
12162
13118
|
console.log(` patch-id: ${s.patch_id}`);
|
|
12163
|
-
console.log(
|
|
13119
|
+
console.log(
|
|
13120
|
+
` schema: v${s.schema_version}${s.bootstrap ? " (Shape 4 migration bootstrap)" : " (v4-trust)"}`
|
|
13121
|
+
);
|
|
12164
13122
|
console.log(` signer: ${s.signer_key_id}`);
|
|
12165
13123
|
console.log(` base mode: ${s.strict_base ? "strict" : "loose"}`);
|
|
12166
|
-
|
|
12167
|
-
|
|
12168
|
-
|
|
13124
|
+
if (s.bootstrap && s.activated_paths) {
|
|
13125
|
+
console.log(` activated: ${s.activated_paths.join(", ")}`);
|
|
13126
|
+
} else {
|
|
13127
|
+
for (const a of s.approvals) {
|
|
13128
|
+
const mark = a.verdict === "approved" ? "\u2713" : "\u2717";
|
|
13129
|
+
console.log(` ${mark} ${a.reviewer.padEnd(16)} ${a.verdict}`);
|
|
13130
|
+
}
|
|
12169
13131
|
}
|
|
12170
13132
|
console.log(bar);
|
|
12171
13133
|
console.log("result: VERIFIED");
|
|
@@ -12562,11 +13524,19 @@ program.command("attest [branch]").description(
|
|
|
12562
13524
|
).requiredOption("--into <target>", "target branch whose rule the gate is checked against").option(
|
|
12563
13525
|
"--push [remote]",
|
|
12564
13526
|
"after attesting locally, push the current branch + the attestation ref to <remote> in one atomic git push (default remote: origin)"
|
|
13527
|
+
).option(
|
|
13528
|
+
"--migrate-existing",
|
|
13529
|
+
"Shape 4 migration bootstrap (AGT-398): attest a narrowly-scoped diff that ADDS `review_server:` + `[server]`-capability trust-anchor entries (the chicken-and-egg PR that activates server-attested reviews on an existing repo). Produces a v3 envelope with empty server_signatures, a migration-bootstrap marker in the operator-signed payload, and exactly one operator-self admin counter-signature in `trust_anchor_signatures`. The flag is REFUSED on any diff outside the narrow Shape-4-activation whitelist (no files outside .stamp/, no modifications to existing trust-anchor entries, no removals). Requires the operator's local key to have `admin` capability in the working-tree manifest and `path_rules` to cover the activated paths with `bypass_review_cycle: true` and `minimum_signatures: 1`. See docs/migration-1.x-to-2.x.md for the full Shape 4 bootstrap walkthrough."
|
|
12565
13530
|
).action(
|
|
12566
13531
|
(branch, opts) => {
|
|
12567
13532
|
try {
|
|
12568
13533
|
const pushTo = opts.push === true ? "origin" : typeof opts.push === "string" ? opts.push : void 0;
|
|
12569
|
-
runAttest({
|
|
13534
|
+
runAttest({
|
|
13535
|
+
branch,
|
|
13536
|
+
into: opts.into,
|
|
13537
|
+
pushTo,
|
|
13538
|
+
migrateExisting: opts.migrateExisting === true
|
|
13539
|
+
});
|
|
12570
13540
|
} catch (err) {
|
|
12571
13541
|
handleCliError(err);
|
|
12572
13542
|
}
|
|
@@ -12618,7 +13588,7 @@ program.command("prune").description(
|
|
|
12618
13588
|
});
|
|
12619
13589
|
program.command("ui").description("launch the interactive terminal UI").action(async () => {
|
|
12620
13590
|
try {
|
|
12621
|
-
const { runUi } = await import("./ui-
|
|
13591
|
+
const { runUi } = await import("./ui-67BDQPER.js");
|
|
12622
13592
|
runUi();
|
|
12623
13593
|
} catch (err) {
|
|
12624
13594
|
handleCliError(err);
|