@cairnkeep/cli 2.22.4 → 2.23.0
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/CHANGELOG.md +44 -0
- package/claude/commands/recall.md +8 -1
- package/docs/README.md +3 -0
- package/docs/agents.md +9 -0
- package/docs/benchmarks/memory-retrieval-v1.json +115 -0
- package/docs/learning/CURRICULUM-MAP.md +1 -1
- package/docs/learning/FEATURE-GUIDE.md +7 -1
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L04-memory-fundamentals.md +20 -0
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +1 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +1 -1
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +22 -2
- package/docs/learning/tracks/operator.md +6 -1
- package/docs/learning/video-scripts/V04-memory-retrieval-supplement.md +34 -0
- package/docs/learning/video-scripts/V28-security-assurance.md +20 -0
- package/docs/memory-retrieval.md +174 -0
- package/docs/operating.md +5 -0
- package/docs/releasing.md +65 -0
- package/docs/security-assurance.md +15 -0
- package/docs/storage.md +5 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/index.js +43 -2
- package/mcp-memory-server/dist/memory-retrieval.js +128 -0
- package/mcp-memory-server/dist/note-store.js +24 -4
- package/mcp-memory-server/package.json +2 -0
- package/opencode/command/recall.md +8 -1
- package/package.json +1 -1
- package/scripts/lib/release-verification.mjs +131 -0
- package/scripts/test-memory-retrieval-docs.sh +19 -0
- package/scripts/test-release-sbom.sh +32 -0
- package/scripts/test-release-verification.mjs +109 -0
- package/scripts/test-release-verification.sh +4 -0
- package/scripts/test-setup-reconcile.mjs +4 -2
- package/scripts/test-windows-native.mjs +1 -0
- package/scripts/verify-phase19-runtime-evidence.mjs +43 -3
- package/scripts/verify-published-release.mjs +138 -0
- package/templates/playbook-agent-instructions.md.template +5 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
function requireThat(condition, label) { if (!condition) throw new Error(`Release verification failed: ${label}.`); }
|
|
4
|
+
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
5
|
+
const digestPattern = /^sha256:[a-f0-9]{64}$/;
|
|
6
|
+
|
|
7
|
+
export function validateReleaseOptions(options) {
|
|
8
|
+
requireThat(/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(options.repo ?? ""), "repository");
|
|
9
|
+
requireThat(/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(options.version ?? ""), "stable version");
|
|
10
|
+
for (const field of ["commit", "tree"]) requireThat(/^[a-f0-9]{40}$/.test(options[field] ?? ""), `exact ${field}`);
|
|
11
|
+
for (const field of ["ciRun", "securityRun", "publishRun"]) requireThat(/^[1-9][0-9]*$/.test(options[field] ?? ""), field);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function releaseAttestationArguments(image, digest, options) {
|
|
15
|
+
validateReleaseOptions(options);
|
|
16
|
+
requireThat(digestPattern.test(digest), "attestation subject digest");
|
|
17
|
+
requireThat(image === options.repo || image === `${options.repo}-workspace`, "attestation image repository");
|
|
18
|
+
// Exact certificate identity binds repository, workflow and tag. The CLI
|
|
19
|
+
// makes this mutually exclusive with its broader signer-workflow selector.
|
|
20
|
+
return ["attestation", "verify", `oci://ghcr.io/${image}@${digest}`, "--repo", options.repo,
|
|
21
|
+
"--source-ref", `refs/tags/v${options.version}`, "--source-digest", options.commit,
|
|
22
|
+
"--cert-identity", `https://github.com/${options.repo}/.github/workflows/publish.yml@refs/tags/v${options.version}`,
|
|
23
|
+
"--cert-oidc-issuer", "https://token.actions.githubusercontent.com",
|
|
24
|
+
"--predicate-type", "https://slsa.dev/provenance/v1", "--deny-self-hosted-runners", "--format", "json"];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function json(bytes, label) {
|
|
28
|
+
try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); }
|
|
29
|
+
catch { throw new Error(`Release verification failed: ${label} encoding or JSON.`); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function verifyAssets(options, candidate, released) {
|
|
33
|
+
const names = [`cairnkeep-cli-${options.version}.tgz`, `cairnkeep-cli-${options.version}.cdx.json`];
|
|
34
|
+
const exactKeys = (map, expected) => map instanceof Map && JSON.stringify([...map.keys()].sort()) === JSON.stringify(expected.sort());
|
|
35
|
+
requireThat(exactKeys(candidate, [...names, "SHA256SUMS", "RELEASE-CANDIDATE.json"]), "candidate file set");
|
|
36
|
+
requireThat(exactKeys(released, [...names, "SHA256SUMS"]), "release asset set");
|
|
37
|
+
const manifest = json(candidate.get("RELEASE-CANDIDATE.json"), "candidate manifest");
|
|
38
|
+
requireThat(Object.keys(manifest).sort().join(",") === "schema_version,tree,version"
|
|
39
|
+
&& manifest.schema_version === 1 && manifest.tree === options.tree && manifest.version === options.version, "candidate manifest binding");
|
|
40
|
+
const sums = new TextDecoder("utf-8", { fatal: true }).decode(candidate.get("SHA256SUMS")).trim().split("\n");
|
|
41
|
+
requireThat(sums.length === names.length, "checksum file count");
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (const line of sums) {
|
|
44
|
+
const match = /^([a-f0-9]{64})\s+\*?(?:\.\/)?([A-Za-z0-9._-]+)$/.exec(line);
|
|
45
|
+
requireThat(match && names.includes(match[2]) && !seen.has(match[2]), "checksum paths and uniqueness");
|
|
46
|
+
seen.add(match[2]);
|
|
47
|
+
requireThat(hash(candidate.get(match[2])) === match[1], "candidate checksum");
|
|
48
|
+
requireThat(candidate.get(match[2]).equals(released.get(match[2])), "release/candidate exact bytes");
|
|
49
|
+
}
|
|
50
|
+
requireThat(candidate.get("SHA256SUMS").equals(released.get("SHA256SUMS")), "checksum asset exact bytes");
|
|
51
|
+
const sbom = json(candidate.get(names[1]), "SBOM");
|
|
52
|
+
requireThat(sbom.bomFormat === "CycloneDX" && sbom.specVersion === "1.6"
|
|
53
|
+
&& sbom.metadata?.component?.version === options.version, "CycloneDX release binding");
|
|
54
|
+
return candidate.get(names[0]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function verifyPublishedRelease(options, io) {
|
|
58
|
+
validateReleaseOptions(options);
|
|
59
|
+
const api = path => io.api(path);
|
|
60
|
+
const ci = await api(`actions/runs/${options.ciRun}`);
|
|
61
|
+
requireThat(ci.conclusion === "success" && ci.path === ".github/workflows/ci.yml", "successful maintained CI");
|
|
62
|
+
requireThat((await api(`git/commits/${ci.head_sha}`)).tree?.sha === options.tree, "tested tree");
|
|
63
|
+
requireThat((await api(`git/commits/${options.commit}`)).tree?.sha === options.tree, "release tree");
|
|
64
|
+
const security = await api(`actions/runs/${options.securityRun}`);
|
|
65
|
+
requireThat(security.conclusion === "success" && security.path === ".github/workflows/security.yml", "successful maintained security workflow");
|
|
66
|
+
requireThat((await api(`git/commits/${security.head_sha}`)).tree?.sha === options.tree, "security-tested tree");
|
|
67
|
+
let tag = (await api(`git/ref/tags/v${options.version}`)).object;
|
|
68
|
+
for (let depth = 0; tag?.type === "tag" && depth < 4; depth++) tag = (await api(`git/tags/${tag.sha}`)).object;
|
|
69
|
+
requireThat(tag?.type === "commit" && tag.sha === options.commit, "immutable tag source");
|
|
70
|
+
const publication = await api(`actions/runs/${options.publishRun}`);
|
|
71
|
+
requireThat(publication.conclusion === "success" && publication.path === ".github/workflows/publish.yml"
|
|
72
|
+
&& publication.event === "release" && publication.head_sha === options.commit, "successful exact-source publication");
|
|
73
|
+
const release = await api(`releases/tags/v${options.version}`);
|
|
74
|
+
requireThat(release.tag_name === `v${options.version}` && release.draft === false && release.prerelease === false, "published stable release");
|
|
75
|
+
const artifacts = (await api(`actions/runs/${options.ciRun}/artifacts?per_page=100`)).artifacts;
|
|
76
|
+
const matches = artifacts?.filter(a => a.name === `release-candidate-${options.tree}` && a.expired === false);
|
|
77
|
+
requireThat(matches?.length === 1, "one retained exact-tree candidate");
|
|
78
|
+
const tarball = verifyAssets(options, await io.candidate(matches[0]), await io.release());
|
|
79
|
+
const registry = await io.registry();
|
|
80
|
+
let url;
|
|
81
|
+
try { url = new URL(registry.dist?.tarball); } catch { /* fail below */ }
|
|
82
|
+
requireThat(registry.version === options.version && url?.protocol === "https:" && url.hostname === "registry.npmjs.org"
|
|
83
|
+
&& !url.username && !url.password && !url.port && !url.hash, "registry tarball authority");
|
|
84
|
+
const publishedTarball = await io.tarball(url.href);
|
|
85
|
+
requireThat(tarball.equals(publishedTarball), "registry/candidate exact bytes");
|
|
86
|
+
requireThat(registry.dist.integrity === `sha512-${createHash("sha512").update(tarball).digest("base64")}`, "registry integrity");
|
|
87
|
+
requireThat(await io.verifyNpm(tarball), "npm signatures and attestations");
|
|
88
|
+
|
|
89
|
+
const images = [];
|
|
90
|
+
const [owner, project] = options.repo.split("/");
|
|
91
|
+
for (const name of [project, `${project}-workspace`]) {
|
|
92
|
+
const image = `${owner}/${name}`;
|
|
93
|
+
const get = async (kind, ref) => {
|
|
94
|
+
requireThat([options.version, "latest"].includes(ref) || digestPattern.test(ref), "OCI reference");
|
|
95
|
+
const result = await io.oci(image, kind, ref);
|
|
96
|
+
requireThat(digestPattern.test(result.digest) && (!ref.startsWith("sha256:") || result.digest === ref), "OCI content digest");
|
|
97
|
+
return result;
|
|
98
|
+
};
|
|
99
|
+
const index = await get("manifests", options.version);
|
|
100
|
+
requireThat((await get("manifests", "latest")).digest === index.digest, "stable image channel");
|
|
101
|
+
const manifests = index.value.manifests;
|
|
102
|
+
requireThat(Array.isArray(manifests) && manifests.length <= 32, "bounded OCI index");
|
|
103
|
+
for (const arch of ["amd64", "arm64"]) {
|
|
104
|
+
const entries = manifests.filter(m => m.platform?.os === "linux" && m.platform?.architecture === arch);
|
|
105
|
+
requireThat(entries.length === 1, "one image per supported architecture");
|
|
106
|
+
const entry = entries[0];
|
|
107
|
+
const manifest = (await get("manifests", entry.digest)).value;
|
|
108
|
+
const config = (await get("blobs", manifest.config?.digest)).value;
|
|
109
|
+
requireThat(config.os === "linux" && config.architecture === arch
|
|
110
|
+
&& config.config?.Labels?.["org.opencontainers.image.revision"] === options.commit, "image platform/source binding");
|
|
111
|
+
const attestations = manifests.filter(m => m.annotations?.["vnd.docker.reference.digest"] === entry.digest
|
|
112
|
+
&& m.annotations?.["vnd.docker.reference.type"] === "attestation-manifest");
|
|
113
|
+
requireThat(attestations.length === 1, "bound build attestation");
|
|
114
|
+
const layers = (await get("manifests", attestations[0].digest)).value.layers;
|
|
115
|
+
requireThat(Array.isArray(layers) && layers.length <= 32, "bounded attestation layers");
|
|
116
|
+
let sbom = false;
|
|
117
|
+
for (const layer of layers) {
|
|
118
|
+
const statement = (await get("blobs", layer.digest)).value;
|
|
119
|
+
if (statement.predicateType === "https://spdx.dev/Document" && statement.predicate?.spdxVersion?.startsWith("SPDX-")
|
|
120
|
+
&& statement.subject?.some(s => s.digest?.sha256 === entry.digest.slice(7))) sbom = true;
|
|
121
|
+
}
|
|
122
|
+
requireThat(sbom, "per-architecture bound SPDX SBOM");
|
|
123
|
+
}
|
|
124
|
+
requireThat(await io.verifyAttestation(image, index.digest, options), "verified exact-source release-workflow provenance");
|
|
125
|
+
images.push({ image: `ghcr.io/${image}`, digest: index.digest, platforms: ["linux/amd64", "linux/arm64"] });
|
|
126
|
+
}
|
|
127
|
+
return { schema_version: 1, status: "verified", ...options, candidate_artifact: matches[0].id,
|
|
128
|
+
exact_bytes: true, cyclonedx: "1.6", npm_signatures_and_attestations: true, images,
|
|
129
|
+
scope: "release-artifacts-only", anonymous_oci_metadata_verified: true,
|
|
130
|
+
full_image_layer_pull_verified: false, deployment_verified: false };
|
|
131
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
4
|
+
cd "$ROOT"
|
|
5
|
+
for path in docs/memory-retrieval.md docs/benchmarks/memory-retrieval-v1.json docs/learning/video-scripts/V04-memory-retrieval-supplement.md; do
|
|
6
|
+
[[ -f "$path" ]]
|
|
7
|
+
done
|
|
8
|
+
for path in docs/README.md docs/operating.md docs/storage.md docs/agents.md docs/learning/lessons/L04-memory-fundamentals.md; do
|
|
9
|
+
grep -qF 'memory-retrieval.md' "$path"
|
|
10
|
+
done
|
|
11
|
+
for path in claude/commands/recall.md opencode/command/recall.md; do
|
|
12
|
+
grep -qF 'do not automatically widen' "$path"
|
|
13
|
+
grep -qF 'strategy: "hybrid"' "$path"
|
|
14
|
+
done
|
|
15
|
+
grep -qF 'same admission' docs/memory-retrieval.md
|
|
16
|
+
grep -qF 'HTTP 502' docs/memory-retrieval.md
|
|
17
|
+
grep -qF 'ranking-not-confidence' docs/memory-retrieval.md
|
|
18
|
+
grep -qF 'Status: available since Cairnkeep 2.23.0' docs/memory-retrieval.md
|
|
19
|
+
echo 'PASS: hybrid retrieval, Markdown parity, provider limitations and learning/recording references'
|
|
@@ -14,6 +14,8 @@ fail() {
|
|
|
14
14
|
|
|
15
15
|
node - "$ROOT/package.json" "$ROOT/package-lock.json" "$CANDIDATE_WORKFLOW" "$PUBLISH_WORKFLOW" <<'NODE'
|
|
16
16
|
const fs = require("node:fs");
|
|
17
|
+
const assert = require("node:assert/strict");
|
|
18
|
+
const { parse } = require("yaml");
|
|
17
19
|
|
|
18
20
|
const manifest = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
|
19
21
|
const lock = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
|
|
@@ -36,6 +38,36 @@ if (!candidateWorkflow.includes("release-candidate:")
|
|
|
36
38
|
|| !candidateWorkflow.includes("name: release-candidate-${{ steps.candidate.outputs.tree }}")) {
|
|
37
39
|
throw new Error("CI must build and upload a tree-addressed release candidate after its required jobs");
|
|
38
40
|
}
|
|
41
|
+
function validateCandidateRetry(workflow) {
|
|
42
|
+
const job = workflow.jobs["release-candidate"];
|
|
43
|
+
assert.deepEqual(new Set(job.needs), new Set([
|
|
44
|
+
"memory-server", "native-windows", "repository", "shell-portability",
|
|
45
|
+
"server-boot", "container", "fresh-install-macos",
|
|
46
|
+
]), "candidate retry must retain every platform/runtime gate");
|
|
47
|
+
assert.equal(job.if, undefined, "candidate retry must not bypass failed or pending gates");
|
|
48
|
+
const uploads = job.steps.filter(step => step.uses?.startsWith("actions/upload-artifact@"));
|
|
49
|
+
assert.equal(uploads.length, 1, "candidate retry must have one artifact publication");
|
|
50
|
+
assert.equal(uploads[0].with.name, "release-candidate-${{ steps.candidate.outputs.tree }}");
|
|
51
|
+
assert.equal(uploads[0].with.overwrite, true, "candidate upload must support same-run retries");
|
|
52
|
+
assert.equal(uploads[0].with["if-no-files-found"], "error");
|
|
53
|
+
}
|
|
54
|
+
const candidate = parse(candidateWorkflow);
|
|
55
|
+
const positive = structuredClone(candidate);
|
|
56
|
+
positive.jobs["release-candidate"].steps.find(step => step.uses?.startsWith("actions/upload-artifact@")).with.overwrite = true;
|
|
57
|
+
validateCandidateRetry(positive);
|
|
58
|
+
for (const overwrite of [undefined, false]) {
|
|
59
|
+
const invalid = structuredClone(positive);
|
|
60
|
+
invalid.jobs["release-candidate"].steps.find(step => step.uses?.startsWith("actions/upload-artifact@")).with.overwrite = overwrite;
|
|
61
|
+
assert.throws(() => validateCandidateRetry(invalid), /same-run retries/);
|
|
62
|
+
}
|
|
63
|
+
const missingGate = structuredClone(positive);
|
|
64
|
+
missingGate.jobs["release-candidate"].needs = missingGate.jobs["release-candidate"].needs.filter(name => name !== "native-windows");
|
|
65
|
+
assert.throws(() => validateCandidateRetry(missingGate), /every platform\/runtime gate/);
|
|
66
|
+
const bypass = structuredClone(positive);
|
|
67
|
+
bypass.jobs["release-candidate"].if = "always()";
|
|
68
|
+
assert.throws(() => validateCandidateRetry(bypass), /must not bypass/);
|
|
69
|
+
validateCandidateRetry(candidate);
|
|
70
|
+
console.log("PASS: retry-safe candidate upload and four gate/replacement mutation controls");
|
|
39
71
|
if (!publishWorkflow.includes("- name: Download the CI-verified release candidate")
|
|
40
72
|
|| !publishWorkflow.includes("jq -r '.conclusion'")
|
|
41
73
|
|| !publishWorkflow.includes("== success")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { releaseAttestationArguments, validateReleaseOptions, verifyPublishedRelease } from "./lib/release-verification.mjs";
|
|
9
|
+
|
|
10
|
+
const options = { repo: "example/project", version: "1.2.3", commit: "a".repeat(40), tree: "b".repeat(40), ciRun: "12", publishRun: "13", securityRun: "14" };
|
|
11
|
+
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
12
|
+
function fixture() {
|
|
13
|
+
const tar = Buffer.from("synthetic-package");
|
|
14
|
+
const sbom = Buffer.from(JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.6", metadata: { component: { version: options.version } } }));
|
|
15
|
+
const sums = Buffer.from(`${hash(tar)} ./cairnkeep-cli-1.2.3.tgz\n${hash(sbom)} ./cairnkeep-cli-1.2.3.cdx.json\n`);
|
|
16
|
+
const candidate = new Map([["cairnkeep-cli-1.2.3.tgz", tar], ["cairnkeep-cli-1.2.3.cdx.json", sbom], ["SHA256SUMS", sums],
|
|
17
|
+
["RELEASE-CANDIDATE.json", Buffer.from(JSON.stringify({ schema_version: 1, tree: options.tree, version: options.version }))]]);
|
|
18
|
+
const released = new Map([...candidate].filter(([name]) => name !== "RELEASE-CANDIDATE.json"));
|
|
19
|
+
const api = new Map([
|
|
20
|
+
["actions/runs/12", { conclusion: "success", path: ".github/workflows/ci.yml", head_sha: options.commit }],
|
|
21
|
+
["actions/runs/13", { conclusion: "success", path: ".github/workflows/publish.yml", head_sha: options.commit, event: "release" }],
|
|
22
|
+
["actions/runs/14", { conclusion: "success", path: ".github/workflows/security.yml", head_sha: options.commit }],
|
|
23
|
+
[`git/commits/${options.commit}`, { tree: { sha: options.tree } }],
|
|
24
|
+
["git/ref/tags/v1.2.3", { object: { type: "commit", sha: options.commit } }],
|
|
25
|
+
["actions/runs/12/artifacts?per_page=100", { artifacts: [{ id: 5, name: `release-candidate-${options.tree}`, expired: false }] }],
|
|
26
|
+
["releases/tags/v1.2.3", { draft: false, prerelease: false, tag_name: "v1.2.3" }],
|
|
27
|
+
]);
|
|
28
|
+
const objects = new Map();
|
|
29
|
+
const add = value => { const digest = `sha256:${hash(Buffer.from(JSON.stringify(value)))}`; objects.set(digest, value); return digest; };
|
|
30
|
+
const manifests = [];
|
|
31
|
+
for (const architecture of ["amd64", "arm64"]) {
|
|
32
|
+
const config = add({ os: "linux", architecture, config: { Labels: { "org.opencontainers.image.revision": options.commit } } });
|
|
33
|
+
const image = add({ config: { digest: config } });
|
|
34
|
+
manifests.push({ digest: image, platform: { os: "linux", architecture } });
|
|
35
|
+
const statement = add({ predicateType: "https://spdx.dev/Document", subject: [{ digest: { sha256: image.slice(7) } }], predicate: { spdxVersion: "SPDX-2.3" } });
|
|
36
|
+
const attestation = add({ layers: [{ digest: statement }] });
|
|
37
|
+
manifests.push({ digest: attestation, annotations: { "vnd.docker.reference.digest": image, "vnd.docker.reference.type": "attestation-manifest" } });
|
|
38
|
+
}
|
|
39
|
+
const index = add({ manifests });
|
|
40
|
+
let signed = 0, signatures = 0;
|
|
41
|
+
const io = {
|
|
42
|
+
api: async path => { assert(api.has(path), `unexpected API request ${path}`); return api.get(path); },
|
|
43
|
+
candidate: async () => candidate, release: async () => released,
|
|
44
|
+
registry: async () => ({ version: options.version, dist: { tarball: "https://registry.npmjs.org/package.tgz", integrity: `sha512-${createHash("sha512").update(tar).digest("base64")}` } }),
|
|
45
|
+
tarball: async () => tar,
|
|
46
|
+
oci: async (_image, _kind, ref) => { const digest = ref.startsWith("sha256:") ? ref : index; return { digest, value: objects.get(digest) }; },
|
|
47
|
+
verifyAttestation: async (_image, digest, expected) => { assert.equal(digest, index); assert.deepEqual(expected, options); signed++; return true; },
|
|
48
|
+
verifyNpm: async bytes => { assert(bytes.equals(tar)); signatures++; return true; },
|
|
49
|
+
};
|
|
50
|
+
return { io, api, candidate, released, objects, manifests, index, counts: () => ({ signed, signatures }) };
|
|
51
|
+
}
|
|
52
|
+
const originalFetch = globalThis.fetch;
|
|
53
|
+
globalThis.fetch = () => { throw new Error("offline release controls must not use the network"); };
|
|
54
|
+
try {
|
|
55
|
+
for (const invalid of [{ repo: "../.." }, { repo: "owner/../secret" }, { version: "01.2.3" }, { version: "1.2.3-beta" },
|
|
56
|
+
{ commit: "main" }, { ciRun: "0" }, { publishRun: "not-a-run" }]) {
|
|
57
|
+
assert.throws(() => validateReleaseOptions({ ...options, ...invalid }));
|
|
58
|
+
}
|
|
59
|
+
const attestationArgs = releaseAttestationArguments("example/project", `sha256:${"d".repeat(64)}`, options);
|
|
60
|
+
for (const [flag, value] of [["--source-digest", options.commit], ["--source-ref", "refs/tags/v1.2.3"],
|
|
61
|
+
["--cert-identity", "https://github.com/example/project/.github/workflows/publish.yml@refs/tags/v1.2.3"],
|
|
62
|
+
["--cert-oidc-issuer", "https://token.actions.githubusercontent.com"], ["--predicate-type", "https://slsa.dev/provenance/v1"]]) {
|
|
63
|
+
assert.equal(attestationArgs[attestationArgs.indexOf(flag) + 1], value);
|
|
64
|
+
}
|
|
65
|
+
assert(attestationArgs.includes("--deny-self-hosted-runners"));
|
|
66
|
+
assert(!attestationArgs.includes("--signer-workflow"), "mutually exclusive identity flags cannot be combined");
|
|
67
|
+
assert.throws(() => releaseAttestationArguments("other/project", `sha256:${"d".repeat(64)}`, options));
|
|
68
|
+
const valid = fixture();
|
|
69
|
+
const report = await verifyPublishedRelease(options, valid.io);
|
|
70
|
+
assert.equal(report.status, "verified"); assert.equal(report.images.length, 2);
|
|
71
|
+
assert.equal(report.deployment_verified, false); assert.equal(report.full_image_layer_pull_verified, false);
|
|
72
|
+
assert.deepEqual(valid.counts(), { signed: 2, signatures: 1 });
|
|
73
|
+
for (const [name, mutate] of [
|
|
74
|
+
["failed CI", f => { f.api.get("actions/runs/12").conclusion = "failure"; }],
|
|
75
|
+
["wrong CI workflow", f => { f.api.get("actions/runs/12").path = ".github/workflows/other.yml"; }],
|
|
76
|
+
["failed security", f => { f.api.get("actions/runs/14").conclusion = "failure"; }],
|
|
77
|
+
["wrong security workflow", f => { f.api.get("actions/runs/14").path = ".github/workflows/other.yml"; }],
|
|
78
|
+
["different security tree", f => { const sha = "c".repeat(40); f.api.get("actions/runs/14").head_sha = sha; f.api.set(`git/commits/${sha}`, { tree: { sha: "d".repeat(40) } }); }],
|
|
79
|
+
["different tree", f => { f.api.get(`git/commits/${options.commit}`).tree.sha = "c".repeat(40); }],
|
|
80
|
+
["different tag", f => { f.api.get("git/ref/tags/v1.2.3").object.sha = "c".repeat(40); }],
|
|
81
|
+
["expired candidate", f => { f.api.get("actions/runs/12/artifacts?per_page=100").artifacts[0].expired = true; }],
|
|
82
|
+
["failed publication", f => { f.api.get("actions/runs/13").conclusion = "failure"; }],
|
|
83
|
+
["wrong publish source", f => { f.api.get("actions/runs/13").head_sha = "c".repeat(40); }],
|
|
84
|
+
["draft release", f => { f.api.get("releases/tags/v1.2.3").draft = true; }],
|
|
85
|
+
["candidate tampering", f => { f.candidate.set("cairnkeep-cli-1.2.3.tgz", Buffer.from("tampered")); }],
|
|
86
|
+
["asset tampering", f => { f.released.set("cairnkeep-cli-1.2.3.cdx.json", Buffer.from("tampered")); }],
|
|
87
|
+
["undeclared candidate", f => { f.candidate.set("extra", Buffer.from("extra")); }],
|
|
88
|
+
["checksum traversal", f => { f.candidate.set("SHA256SUMS", Buffer.from(`${"a".repeat(64)} ../escape\n`)); }],
|
|
89
|
+
["registry mismatch", f => { f.io.tarball = async () => Buffer.from("different"); }],
|
|
90
|
+
["registry credentials", f => { f.io.registry = async () => ({ version: "1.2.3", dist: { tarball: "https://secret@registry.npmjs.org/package.tgz" } }); }],
|
|
91
|
+
["signature failure", f => { f.io.verifyNpm = async () => false; }],
|
|
92
|
+
["missing architecture", f => { f.objects.get(f.index).manifests = f.manifests.filter(m => m.platform?.architecture !== "arm64"); }],
|
|
93
|
+
["wrong image revision", f => { for (const value of f.objects.values()) if (value.config?.Labels) value.config.Labels["org.opencontainers.image.revision"] = "c".repeat(40); }],
|
|
94
|
+
["unbound SBOM", f => { for (const value of f.objects.values()) if (value.subject) value.subject[0].digest.sha256 = "c".repeat(64); }],
|
|
95
|
+
["provenance failure", f => { f.io.verifyAttestation = async () => false; }],
|
|
96
|
+
]) { const f = fixture(); mutate(f); await assert.rejects(() => verifyPublishedRelease(options, f.io), undefined, name); }
|
|
97
|
+
const scratch = mkdtempSync(join(tmpdir(), "cairn-release-report-test-"));
|
|
98
|
+
try {
|
|
99
|
+
const output = join(scratch, "existing.json"); writeFileSync(output, "retained-report\n");
|
|
100
|
+
const command = fileURLToPath(new URL("verify-published-release.mjs", import.meta.url));
|
|
101
|
+
const result = spawnSync(process.execPath, [command, "--version", options.version, "--commit", options.commit,
|
|
102
|
+
"--tree", options.tree, "--ci-run", options.ciRun, "--security-run", options.securityRun, "--publish-run", options.publishRun,
|
|
103
|
+
"--out", output, "--gh", join(scratch, "missing-command")], { encoding: "utf8", timeout: 3000 });
|
|
104
|
+
assert.notEqual(result.status, 0); assert.match(result.stderr, /output already exists/);
|
|
105
|
+
assert.equal(readFileSync(output, "utf8"), "retained-report\n");
|
|
106
|
+
assert.equal(result.stdout, "");
|
|
107
|
+
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
|
108
|
+
console.log("PASS: offline exact-tree release, assets, registry, signatures, architectures, SBOM and provenance controls");
|
|
109
|
+
} finally { globalThis.fetch = originalFetch; }
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
import { dirname, join, resolve } from "node:path";
|
|
20
20
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
21
|
+
import { readStableFile } from "./lib/stable-file.mjs";
|
|
21
22
|
|
|
22
23
|
const EXPECTED_RED_EXIT = 86;
|
|
23
24
|
const RED_MARKER = "PHASE26_RED:SETUP_RECONCILE_MISSING";
|
|
@@ -172,8 +173,9 @@ async function testOwnershipBranches(reconciler, fixture, baseline) {
|
|
|
172
173
|
|
|
173
174
|
writeFileSync(ownedPath, "operator-owned\n");
|
|
174
175
|
chmodSync(ownedPath, 0o700);
|
|
175
|
-
const
|
|
176
|
-
const
|
|
176
|
+
const userSnapshot = readStableFile(ownedPath, { label: "Operator-owned setup fixture", maxBytes: 1024 * 1024 });
|
|
177
|
+
const userBytes = userSnapshot.bytes;
|
|
178
|
+
const userMtime = userSnapshot.stat.mtimeMs;
|
|
177
179
|
const diverged = await reconciler.reconcileSetupPlan(plan(fixture.target, [fixture.first]), { previousState: updated.state });
|
|
178
180
|
assertCounts(diverged, { created: 0, updated: 0, unchanged: 0, skipped: 1 });
|
|
179
181
|
assert.deepEqual(readFileSync(ownedPath), userBytes);
|
|
@@ -24,6 +24,7 @@ await import("./test-setup-diagnosis.mjs");
|
|
|
24
24
|
await import("./test-runtime-file-security.mjs");
|
|
25
25
|
await import("./test-container-health.mjs");
|
|
26
26
|
await import("./test-omp-staged-security.mjs");
|
|
27
|
+
await import("./test-release-verification.mjs");
|
|
27
28
|
const sandbox = mkdtempSync(join(tmpdir(), "cairn-windows-native-"));
|
|
28
29
|
const originalBase = process.env.CAIRN_AGENTFS_BASE_DIR;
|
|
29
30
|
const originalPackBase = process.env.CAIRN_PACK_BASE_DIR;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
5
7
|
import {
|
|
6
8
|
existsSync,
|
|
7
9
|
lstatSync,
|
|
@@ -15,6 +17,7 @@ import {
|
|
|
15
17
|
import { tmpdir } from "node:os";
|
|
16
18
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
17
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { readStableFile } from "./lib/stable-file.mjs";
|
|
18
21
|
|
|
19
22
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
23
|
const EVIDENCE_RELATIVE = join(
|
|
@@ -250,9 +253,7 @@ function assertNoSentinels(bytes, label) {
|
|
|
250
253
|
function readOwnedFile(root, relative) {
|
|
251
254
|
const label = safeRelative(relative, "evidence file");
|
|
252
255
|
const path = join(root, label);
|
|
253
|
-
const
|
|
254
|
-
if (!info.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 * 1024) throw new Error(`${label}: unsafe evidence file`);
|
|
255
|
-
const bytes = readFileSync(path);
|
|
256
|
+
const { bytes } = readStableFile(path, { label: "Runtime evidence file", maxBytes: 16 * 1024 * 1024 });
|
|
256
257
|
assertNoSentinels(bytes, label);
|
|
257
258
|
return bytes;
|
|
258
259
|
}
|
|
@@ -762,6 +763,44 @@ function expectFailure(label, operation) {
|
|
|
762
763
|
assert.throws(operation, undefined, label);
|
|
763
764
|
}
|
|
764
765
|
|
|
766
|
+
function testEvidenceReadRaces(root) {
|
|
767
|
+
const path = join(root, "read-race.log");
|
|
768
|
+
const original = { readFileSync: fs.readFileSync, readSync: fs.readSync, lstatSync: fs.lstatSync };
|
|
769
|
+
let injected = false;
|
|
770
|
+
const grow = () => {
|
|
771
|
+
if (!injected) { injected = true; fs.appendFileSync(path, "changed-after-inspection\n"); }
|
|
772
|
+
};
|
|
773
|
+
writeFileSync(path, "original\n");
|
|
774
|
+
fs.readFileSync = (candidate, ...args) => { if (candidate === path) grow(); return original.readFileSync(candidate, ...args); };
|
|
775
|
+
fs.readSync = (...args) => { grow(); return original.readSync(...args); };
|
|
776
|
+
syncBuiltinESMExports();
|
|
777
|
+
try {
|
|
778
|
+
assert.throws(() => readOwnedFile(root, "read-race.log"), /unsafe|changed/,
|
|
779
|
+
"evidence reader accepted bytes changed after inspection");
|
|
780
|
+
assert.equal(injected, true, "evidence growth race was not exercised");
|
|
781
|
+
} finally { Object.assign(fs, original); syncBuiltinESMExports(); }
|
|
782
|
+
|
|
783
|
+
writeFileSync(path, "original\n");
|
|
784
|
+
const replacement = join(root, "replacement.log");
|
|
785
|
+
writeFileSync(replacement, "replacement\n");
|
|
786
|
+
injected = false;
|
|
787
|
+
fs.lstatSync = (candidate, ...args) => {
|
|
788
|
+
const result = original.lstatSync(candidate, ...args);
|
|
789
|
+
if (candidate === path && !injected) {
|
|
790
|
+
injected = true;
|
|
791
|
+
renameSync(path, join(root, "old.log"));
|
|
792
|
+
renameSync(replacement, path);
|
|
793
|
+
}
|
|
794
|
+
return result;
|
|
795
|
+
};
|
|
796
|
+
syncBuiltinESMExports();
|
|
797
|
+
try {
|
|
798
|
+
assert.throws(() => readOwnedFile(root, "read-race.log"), /unsafe|changed/,
|
|
799
|
+
"evidence reader accepted a pathname replacement");
|
|
800
|
+
assert.equal(injected, true, "evidence replacement race was not exercised");
|
|
801
|
+
} finally { Object.assign(fs, original); syncBuiltinESMExports(); }
|
|
802
|
+
}
|
|
803
|
+
|
|
765
804
|
function selfTest() {
|
|
766
805
|
const sourceCommit = requireSuccess(git(ROOT, ["rev-parse", "HEAD"]), "read self-test source commit");
|
|
767
806
|
requireCommit(sourceCommit);
|
|
@@ -787,6 +826,7 @@ function selfTest() {
|
|
|
787
826
|
"published contract mutation did not change its digest");
|
|
788
827
|
const root = mkdtempSync(join(tmpdir(), "cairn-phase19-evidence-"));
|
|
789
828
|
try {
|
|
829
|
+
testEvidenceReadRaces(root);
|
|
790
830
|
const valid = join(root, "valid");
|
|
791
831
|
writeFixture(valid, sourceCommit);
|
|
792
832
|
verifyEvidence(valid, sourceCommit);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { readStableFile } from "./lib/stable-file.mjs";
|
|
9
|
+
import { releaseAttestationArguments, validateReleaseOptions, verifyPublishedRelease } from "./lib/release-verification.mjs";
|
|
10
|
+
|
|
11
|
+
const usage = "Usage: node scripts/verify-published-release.mjs --version X.Y.Z --commit SHA --tree SHA --ci-run ID --security-run ID --publish-run ID --out FILE [--repo OWNER/REPO] [--gh PATH] [--npm PATH]";
|
|
12
|
+
function optionsFrom(args) {
|
|
13
|
+
const options = { repo: "cairnkeep/cairnkeep", gh: "gh", npm: "npm" };
|
|
14
|
+
const keys = new Map([["--version", "version"], ["--commit", "commit"], ["--tree", "tree"], ["--ci-run", "ciRun"],
|
|
15
|
+
["--security-run", "securityRun"], ["--publish-run", "publishRun"], ["--repo", "repo"], ["--gh", "gh"], ["--npm", "npm"], ["--out", "out"]]);
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
18
|
+
const key = keys.get(args[i]);
|
|
19
|
+
if (!key || seen.has(key) || !args[i + 1] || args[i + 1].startsWith("--")) throw new Error(usage);
|
|
20
|
+
seen.add(key); options[key] = args[i + 1];
|
|
21
|
+
}
|
|
22
|
+
if (!options.out) throw new Error(usage);
|
|
23
|
+
validateReleaseOptions(options);
|
|
24
|
+
return options;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function run(command, args, options = {}) {
|
|
28
|
+
const result = spawnSync(command, args, { encoding: "utf8", timeout: 60000, maxBuffer: 16 * 1024 * 1024, ...options });
|
|
29
|
+
if (result.error || result.status !== 0) {
|
|
30
|
+
const operation = ["api", "run", "release", "install", "audit", "attestation"].includes(args[0]) ? args[0] : "required";
|
|
31
|
+
throw new Error(`Release verification failed: ${operation} command unavailable, timed out or failed.`);
|
|
32
|
+
}
|
|
33
|
+
return result.stdout;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function bytesFrom(url, maxBytes, headers = {}, blobRedirects = false) {
|
|
37
|
+
const signal = AbortSignal.timeout(30000);
|
|
38
|
+
let response;
|
|
39
|
+
for (let redirects = 0; redirects <= 3; redirects++) {
|
|
40
|
+
response = await fetch(url, { headers, redirect: "manual", signal });
|
|
41
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) break;
|
|
42
|
+
const next = new URL(response.headers.get("location"), url);
|
|
43
|
+
await response.body?.cancel();
|
|
44
|
+
if (!blobRedirects || redirects === 3 || next.protocol !== "https:"
|
|
45
|
+
|| next.hostname !== "pkg-containers.githubusercontent.com" || next.username || next.password || next.port) {
|
|
46
|
+
throw new Error("Release verification failed: unexpected registry redirect.");
|
|
47
|
+
}
|
|
48
|
+
url = next.href; headers = {}; // Never forward registry authorization.
|
|
49
|
+
}
|
|
50
|
+
if (!response.ok || !response.body) throw new Error("Release verification failed: remote response unavailable.");
|
|
51
|
+
const declared = Number(response.headers.get("content-length"));
|
|
52
|
+
if (Number.isFinite(declared) && declared > maxBytes) { await response.body.cancel(); throw new Error("Release verification failed: remote byte limit."); }
|
|
53
|
+
const reader = response.body.getReader();
|
|
54
|
+
const chunks = []; let total = 0;
|
|
55
|
+
try {
|
|
56
|
+
while (true) {
|
|
57
|
+
const { done, value } = await reader.read(); if (done) break;
|
|
58
|
+
total += value.byteLength;
|
|
59
|
+
if (total > maxBytes) throw new Error("Release verification failed: remote byte limit.");
|
|
60
|
+
chunks.push(Buffer.from(value));
|
|
61
|
+
}
|
|
62
|
+
return Buffer.concat(chunks, total);
|
|
63
|
+
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
64
|
+
}
|
|
65
|
+
const parse = bytes => JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
66
|
+
const digest = bytes => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
67
|
+
|
|
68
|
+
async function main(args) {
|
|
69
|
+
if (args.length === 1 && ["--help", "-h"].includes(args[0])) { console.log(usage); return; }
|
|
70
|
+
const options = optionsFrom(args);
|
|
71
|
+
const { gh, npm, out, ...expected } = options;
|
|
72
|
+
const output = resolve(out);
|
|
73
|
+
try { fs.lstatSync(output); throw new Error("Release verification failed: output already exists."); }
|
|
74
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
75
|
+
const scratch = fs.mkdtempSync(join(tmpdir(), "cairn-release-verification-"));
|
|
76
|
+
fs.chmodSync(scratch, 0o700);
|
|
77
|
+
const ghRun = args => run(gh, args);
|
|
78
|
+
const readDirectory = directory => new Map(fs.readdirSync(directory).map(name => [name,
|
|
79
|
+
readStableFile(join(directory, name), { label: "Release verification input", maxBytes: 64 * 1024 * 1024 }).bytes]));
|
|
80
|
+
const tokens = new Map();
|
|
81
|
+
try {
|
|
82
|
+
const io = {
|
|
83
|
+
api: async suffix => JSON.parse(ghRun(["api", `repos/${expected.repo}/${suffix}`])),
|
|
84
|
+
candidate: async artifact => {
|
|
85
|
+
const directory = join(scratch, "candidate");
|
|
86
|
+
ghRun(["run", "download", expected.ciRun, "--repo", expected.repo, "--name", artifact.name, "--dir", directory]);
|
|
87
|
+
return readDirectory(directory);
|
|
88
|
+
},
|
|
89
|
+
release: async () => {
|
|
90
|
+
const directory = join(scratch, "release");
|
|
91
|
+
ghRun(["release", "download", `v${expected.version}`, "--repo", expected.repo, "--dir", directory]);
|
|
92
|
+
return readDirectory(directory);
|
|
93
|
+
},
|
|
94
|
+
registry: async () => parse(await bytesFrom(`https://registry.npmjs.org/@cairnkeep%2fcli/${expected.version}`, 8 * 1024 * 1024)),
|
|
95
|
+
tarball: async url => bytesFrom(url, 64 * 1024 * 1024),
|
|
96
|
+
verifyNpm: async tarball => {
|
|
97
|
+
const prefix = join(scratch, "npm");
|
|
98
|
+
run(npm, ["install", "--prefix", prefix, `@cairnkeep/cli@${expected.version}`, "--ignore-scripts", "--no-audit", "--no-fund", "--registry", "https://registry.npmjs.org"]);
|
|
99
|
+
const lock = parse(readStableFile(join(prefix, "package-lock.json"), { label: "Verification install lock", maxBytes: 8 * 1024 * 1024 }).bytes);
|
|
100
|
+
if (lock.packages?.["node_modules/@cairnkeep/cli"]?.integrity !== `sha512-${createHash("sha512").update(tarball).digest("base64")}`) {
|
|
101
|
+
throw new Error("Release verification failed: audited package/candidate binding.");
|
|
102
|
+
}
|
|
103
|
+
const audited = run(npm, ["audit", "signatures", "--prefix", prefix, "--registry", "https://registry.npmjs.org"]);
|
|
104
|
+
return /[1-9][0-9]* packages have verified registry signatures/.test(audited)
|
|
105
|
+
&& /[1-9][0-9]* packages have verified attestations/.test(audited);
|
|
106
|
+
},
|
|
107
|
+
oci: async (image, kind, ref) => {
|
|
108
|
+
if (!tokens.has(image)) {
|
|
109
|
+
const result = parse(await bytesFrom(`https://ghcr.io/token?service=ghcr.io&scope=repository:${image}:pull`, 64 * 1024));
|
|
110
|
+
if (typeof result.token !== "string" || !result.token) throw new Error("Release verification failed: anonymous OCI access.");
|
|
111
|
+
tokens.set(image, result.token);
|
|
112
|
+
}
|
|
113
|
+
const bytes = await bytesFrom(`https://ghcr.io/v2/${image}/${kind}/${ref}`, 16 * 1024 * 1024, {
|
|
114
|
+
Authorization: `Bearer ${tokens.get(image)}`,
|
|
115
|
+
Accept: "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json",
|
|
116
|
+
}, kind === "blobs");
|
|
117
|
+
return { digest: digest(bytes), value: parse(bytes) };
|
|
118
|
+
},
|
|
119
|
+
verifyAttestation: async (image, imageDigest) => {
|
|
120
|
+
const verified = JSON.parse(ghRun(releaseAttestationArguments(image, imageDigest, expected)));
|
|
121
|
+
return Array.isArray(verified) && verified.length > 0;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
const report = await verifyPublishedRelease(expected, io);
|
|
125
|
+
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
126
|
+
console.log(JSON.stringify(report));
|
|
127
|
+
} finally { fs.rmSync(scratch, { recursive: true, force: true }); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
131
|
+
const previousMask = process.umask(0o077);
|
|
132
|
+
try { await main(process.argv.slice(2)); }
|
|
133
|
+
catch (error) {
|
|
134
|
+
console.error(error.message === usage || error.message?.startsWith("Release verification failed:")
|
|
135
|
+
? error.message : "Release verification failed; no verified report was written.");
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
} finally { process.umask(previousMask); }
|
|
138
|
+
}
|
|
@@ -10,6 +10,11 @@ inventory, or search commands such as `pwd`, `ls`, `rg --files`, `find`,
|
|
|
10
10
|
- If `memory_search` is available, derive one short query from the task and
|
|
11
11
|
search `scope: project` once. Derive it from the user's task; do not inspect
|
|
12
12
|
the repository first merely to formulate the query.
|
|
13
|
+
- When the discovered schema supports hybrid search, use it for explicit
|
|
14
|
+
identifiers or mixed wording; request explanations when needed. Supply only
|
|
15
|
+
aliases already known from the task. Hybrid semantic-only matches require an
|
|
16
|
+
explicit model-specific positive similarity threshold; otherwise keep the
|
|
17
|
+
existing semantic strategy for paraphrases. Empty results are acceptable.
|
|
13
18
|
- Treat returned memory as a locator, not authority. Read and verify the
|
|
14
19
|
maintained repository sources it references before implementing a result.
|
|
15
20
|
- If the tool is unavailable or no relevant result exists, continue with
|