@cairnkeep/cli 2.22.4 → 2.22.5

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/docs/learning/CURRICULUM-MAP.md +1 -1
  3. package/docs/learning/FEATURE-GUIDE.md +1 -1
  4. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  5. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  6. package/docs/learning/lessons/L02-installation.md +2 -2
  7. package/docs/learning/lessons/L03-first-project.md +1 -1
  8. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  9. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  10. package/docs/learning/lessons/L21-context-packs.md +1 -1
  11. package/docs/learning/lessons/L22-native-windows.md +2 -2
  12. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  13. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  14. package/docs/learning/lessons/L25-playbooks.md +1 -1
  15. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  16. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  17. package/docs/learning/lessons/L28-security-assurance.md +22 -2
  18. package/docs/learning/tracks/operator.md +6 -1
  19. package/docs/learning/video-scripts/V28-security-assurance.md +20 -0
  20. package/docs/releasing.md +58 -0
  21. package/docs/security-assurance.md +15 -0
  22. package/examples/eval/bundled-fake.json +1 -1
  23. package/mcp-memory-server/dist/note-store.js +24 -4
  24. package/package.json +1 -1
  25. package/scripts/lib/release-verification.mjs +131 -0
  26. package/scripts/test-release-verification.mjs +109 -0
  27. package/scripts/test-release-verification.sh +4 -0
  28. package/scripts/test-setup-reconcile.mjs +4 -2
  29. package/scripts/test-windows-native.mjs +1 -0
  30. package/scripts/verify-phase19-runtime-evidence.mjs +43 -3
  31. package/scripts/verify-published-release.mjs +138 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable user-facing changes are documented here. This project follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.22.5] - 2026-10-08
9
+
10
+ ### Operations
11
+
12
+ - Add an explicit post-publication verifier for exact-tree CI candidates,
13
+ release and registry bytes, npm signatures/attestations, anonymous
14
+ dual-architecture OCI images, bound SBOMs and exact-source signed provenance.
15
+ Refuse partial verification and report replacement; keep artifact evidence
16
+ separate from deployment readiness. Add offline positive/mutation controls.
17
+
18
+ ### Security
19
+
20
+ - Bind note-transaction pre-image hashes to bounded inspected descriptors;
21
+ reject linked, replaced, growing or over-64-MiB files before accepting their
22
+ hashes. Create developer note fixtures exclusively without overwriting a
23
+ competing file. Add actual runtime replacement and fixture race controls.
24
+ - Use bounded descriptor reads for developer benchmark/import/note snapshots,
25
+ capability leases, corrupt-database controls and runtime-evidence files.
26
+ Preserve existing corruption, permissions and exact-byte assertions; keep
27
+ remote-context test labels static so provider values cannot forge log lines.
28
+ These controls do not make note publication a directory-wide transaction or
29
+ certify that every historical security-analysis finding is resolved.
30
+
8
31
  ## [2.22.4] - 2026-10-07
9
32
 
10
33
  ### Security
@@ -1,6 +1,6 @@
1
1
  # Curriculum Coverage Map
2
2
 
3
- **Baseline:** Cairnkeep 2.22.4
3
+ **Baseline:** Cairnkeep 2.22.5
4
4
  **Last reviewed:** 2026-10-07
5
5
 
6
6
  This map prevents the course from drifting behind the product. It assigns each
@@ -1,6 +1,6 @@
1
1
  # Cairnkeep feature guide
2
2
 
3
- **Baseline:** Cairnkeep 2.22.4
3
+ **Baseline:** Cairnkeep 2.22.5
4
4
 
5
5
  This is the step-back map for choosing and operating Cairnkeep features. The
6
6
  [course labs](https://github.com/cairnkeep/cairnkeep-course-labs) provide one
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 10 minutes
6
- **Tested with:** Cairnkeep 2.22.4
6
+ **Tested with:** Cairnkeep 2.22.5
7
7
 
8
8
  ## Outcome
9
9
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 15 minutes
6
- **Tested with:** Cairnkeep 2.22.4
6
+ **Tested with:** Cairnkeep 2.22.5
7
7
 
8
8
  ## Outcome
9
9
 
@@ -26,7 +26,7 @@ ephemeral; the named course volume is persistent.
26
26
  1. Pull the public image:
27
27
 
28
28
  ```bash
29
- podman pull ghcr.io/cairnkeep/cairnkeep:2.22.4
29
+ podman pull ghcr.io/cairnkeep/cairnkeep:2.22.5
30
30
  ```
31
31
 
32
32
  2. Start the stdio server with a read-only root filesystem and no Linux
@@ -38,7 +38,7 @@ ephemeral; the named course volume is persistent.
38
38
  --read-only --cap-drop=all --security-opt=no-new-privileges \
39
39
  --tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777 \
40
40
  --volume cairnkeep-course-data:/data:Z,U \
41
- ghcr.io/cairnkeep/cairnkeep:2.22.4 stdio
41
+ ghcr.io/cairnkeep/cairnkeep:2.22.5 stdio
42
42
  ```
43
43
 
44
44
  3. The process waits quietly for MCP input. Press `Ctrl-C` after confirming it
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 25 minutes
6
- **Tested with:** Cairnkeep 2.22.4 and Node.js 22 or newer
6
+ **Tested with:** Cairnkeep 2.22.5 and Node.js 22 or newer
7
7
 
8
8
  ## Outcome
9
9
 
@@ -47,7 +47,7 @@ sync. Codex project trust remains an explicit operator decision.
47
47
  2. Install Cairnkeep:
48
48
 
49
49
  ```bash
50
- npm install --global @cairnkeep/cli@2.22.4
50
+ npm install --global @cairnkeep/cli@2.22.5
51
51
  cairn --version
52
52
  ```
53
53
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 25 minutes
6
- **Tested with:** Cairnkeep 2.22.4
6
+ **Tested with:** Cairnkeep 2.22.5
7
7
 
8
8
  ## Outcome
9
9
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Evidence and Evaluation
5
5
  **Time:** 50 minutes
6
- **Tested with:** Cairnkeep 2.22.4
6
+ **Tested with:** Cairnkeep 2.22.5
7
7
 
8
8
  ## Outcome
9
9
 
@@ -1,7 +1,7 @@
1
1
  # L20 - Least-authority MCP tool profiles
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.4 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.5 and Node.js 22 or newer
5
5
  **Time:** 25 minutes
6
6
 
7
7
  ## Outcome
@@ -1,7 +1,7 @@
1
1
  # L21 - Immutable context packs and skill approval
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.4 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.5 and Node.js 22 or newer
5
5
  **Time:** 40 minutes
6
6
 
7
7
  ## Outcome
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Operator
5
5
  **Time:** 30 minutes
6
- **Tested with:** Cairnkeep 2.22.4, Windows x64, and Node.js 22 or newer
6
+ **Tested with:** Cairnkeep 2.22.5, Windows x64, and Node.js 22 or newer
7
7
 
8
8
  ## Outcome
9
9
 
@@ -16,7 +16,7 @@ perform a reversible uninstall without relying on WSL or Git Bash.
16
16
  From PowerShell:
17
17
 
18
18
  ```powershell
19
- npm install --global @cairnkeep/cli@2.22.4
19
+ npm install --global @cairnkeep/cli@2.22.5
20
20
  cairn version
21
21
 
22
22
  $Lab = Join-Path $env:TEMP 'Cairnkeep Windows Lab'
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart and Operator
5
5
  **Time:** 35 minutes
6
- **Tested with:** Cairnkeep 2.22.4; Pi 0.84.1; Node.js 22, 24, and 26
6
+ **Tested with:** Cairnkeep 2.22.5; Pi 0.84.1; Node.js 22, 24, and 26
7
7
 
8
8
  ## Outcome
9
9
 
@@ -1,7 +1,7 @@
1
1
  # L24 - Reviewed knowledge exchange
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.4 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.5 and Node.js 22 or newer
5
5
  **Time:** 35 minutes
6
6
 
7
7
  ## Outcome
@@ -1,7 +1,7 @@
1
1
  # L25 - Bounded workflow playbooks
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.4 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.5 and Node.js 22 or newer
5
5
  **Time:** 35 minutes
6
6
 
7
7
  ## Outcome
@@ -1,7 +1,7 @@
1
1
  # L26 - Context intelligence with explicit authority
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.4 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.5 and Node.js 22 or newer
5
5
 
6
6
  ## Outcome
7
7
 
@@ -1,6 +1,6 @@
1
1
  # L27 - Evaluate context companions without moving authority
2
2
 
3
- **Tested with:** Cairnkeep 2.22.4 and Node.js 22 or newer
3
+ **Tested with:** Cairnkeep 2.22.5 and Node.js 22 or newer
4
4
 
5
5
  ## Outcome
6
6
 
@@ -1,7 +1,7 @@
1
1
  # L28 - Verify Cairnkeep's security posture
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.4 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.5 and Node.js 22 or newer
5
5
 
6
6
  ## Outcome
7
7
 
@@ -11,7 +11,7 @@ and choose least authority for a network-facing MCP client.
11
11
 
12
12
  ## Prerequisites
13
13
 
14
- - Install Cairnkeep 2.22.4 or use a disposable source checkout.
14
+ - Install Cairnkeep 2.22.5 or use a disposable source checkout.
15
15
  - Complete [L20 - Least-authority MCP tool profiles](L20-mcp-tool-profiles.md).
16
16
  - Use synthetic values only; never paste a real bearer token into a recording,
17
17
  terminal transcript, or issue.
@@ -76,6 +76,8 @@ node mcp-memory-server/scripts/smoke-outbound-security.mjs
76
76
  node scripts/test-runtime-file-security.mjs
77
77
  node scripts/test-omp-staged-security.mjs
78
78
  node scripts/test-container-health.mjs
79
+ node mcp-memory-server/scripts/smoke-note-mcp.mjs --fixture-security-only
80
+ node scripts/verify-phase19-runtime-evidence.mjs --self-test
79
81
  ```
80
82
 
81
83
  The baseline rejects unpinned external Actions, `pull_request_target` and
@@ -89,6 +91,15 @@ These are static contract checks, not a live publication test. The npm secret
89
91
  is a separate credential, and steps within one job share its token authority.
90
92
  Do not republish an immutable version to demonstrate the test.
91
93
 
94
+ For post-publication evidence, run `node scripts/test-release-verification.mjs`
95
+ and read [Repeatable post-publication verification](../../releasing.md#repeatable-post-publication-verification).
96
+ The offline mutations reject mismatched trees/assets and failed signature or
97
+ provenance decisions; they do not validate real signatures. The explicit live
98
+ verifier checks the published artifacts with npm and the GitHub CLI and writes
99
+ a new report only after success. Its report says `deployment_verified: false`:
100
+ server backups, canaries and project doctors remain separate rollout evidence.
101
+ Never publish or upgrade a service just to record this exercise.
102
+
92
103
  The smoke tests cover token non-disclosure, request bounds, and deterministic
93
104
  portable-path adversarial cases.
94
105
 
@@ -141,6 +152,15 @@ retrieval still sends a query to the explicitly configured external provider.
141
152
  AnythingLLM responses have an 8 MiB cap; OpenViking retains its 1 MiB cap and
142
153
  separate consent gates. Prefer HTTPS outside loopback.
143
154
 
155
+ In a checkout with the developer-evidence follow-up, the focused note control
156
+ shows that exclusive fixture creation preserves a competing file and that the
157
+ actual note transaction rejects a substituted pre-image. Hashing is bounded
158
+ at 64 MiB, including same-inode growth checks. The runtime-evidence self-test
159
+ rejects changed or replaced log files under its existing 16 MiB cap. Developer
160
+ snapshots and corrupt-database controls still compare exact bytes; static log
161
+ labels cannot turn provider text into forged passing results. This does not
162
+ make multi-file note publication atomic or prove an empty analysis backlog.
163
+
144
164
  ## Common failures
145
165
 
146
166
  - A private `.ai` file is group/world accessible on POSIX: restore mode `0600`.
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Audience:** advanced users and platform maintainers
4
4
  **Prerequisite:** Practitioner track; L13 before operating captured evidence
5
- **Total time:** about 6 hours 45 minutes
5
+ **Total time:** about 6 hours 45 minutes, plus optional evidence/security supplements
6
6
 
7
7
  1. [L09 - Domain knowledge with RAG](../lessons/L09-domain-knowledge.md)
8
8
  2. [L10 - Faster context exploration](../lessons/L10-context-exploration.md)
@@ -17,6 +17,8 @@
17
17
  11. [L24 - Reviewed knowledge exchange](../lessons/L24-okf-exchange.md)
18
18
  12. [L25 - Bounded workflow playbooks](../lessons/L25-playbooks.md)
19
19
  13. [L26 - Context intelligence with explicit authority](../lessons/L26-context-intelligence.md)
20
+ 14. [L27 - Evaluate ecosystem ideas with bounded evidence](../lessons/L27-ecosystem-evidence.md)
21
+ 15. [L28 - Verify Cairnkeep's security posture](../lessons/L28-security-assurance.md)
20
22
 
21
23
  All modules are optional. The track is complete when the operator can add and
22
24
  remove each integration without breaking the standalone local workflow and can
@@ -27,6 +29,9 @@ L24 adds portable exchange only after the immutable-pack and privacy boundaries
27
29
  are understood.
28
30
  L25 adds policy enforcement and the unauthenticated local actor boundary before
29
31
  any future team deployment.
32
+ L27 separates protocol observations from measured task benefits. L28 adds
33
+ deployment posture, adversarial source-checkout controls and exact-source
34
+ release verification; a verified artifact is not a verified fleet rollout.
30
35
 
31
36
  Hands-on spine: `course-04-operation`, `course-06-governance`, and
32
37
  `course-07-evaluation`, `course-10-trust-context`, and
@@ -121,6 +121,26 @@ permissions, but steps in a job still share authority and the npm secret is
121
121
  separate. The baseline checks declared grants; do not present it as a successful
122
122
  live publish or rerun a release merely for this recording.
123
123
 
124
+ In the release-verification supplement, run
125
+ `node scripts/test-release-verification.mjs` using only the synthetic corpus.
126
+ Show a mismatched tree, altered asset and failed provenance decision, then
127
+ contrast offline policy coverage with the explicitly invoked live verifier in
128
+ `docs/releasing.md`. Show only public artifact metadata from an already
129
+ completed release, never tokens or raw diagnostics. Pause on
130
+ `deployment_verified: false`: signed artifact evidence does not prove that a
131
+ server was upgraded or its project routes still work. Do not publish or
132
+ upgrade production for a demonstration.
133
+
134
+ For the developer-evidence supplement, run
135
+ `node mcp-memory-server/scripts/smoke-note-mcp.mjs --fixture-security-only` and
136
+ `node scripts/verify-phase19-runtime-evidence.mjs --self-test` in a built source
137
+ checkout. Pause on the competing-file preservation and actual transaction
138
+ pre-image replacement controls. Explain that hashing uses one bounded inspected
139
+ descriptor, not a pathname check followed by reopening. Contrast integrity of
140
+ a read with atomicity of a multi-file transaction: this patch improves the
141
+ former, not the latter. Use synthetic files and keep historical alert status
142
+ separate from a green test result; record only after the release gates pass.
143
+
124
144
  ### 9:20–10:30 — Read the residual risks
125
145
 
126
146
  End on `docs/security-assurance.md`, not on a green checkmark. Call out the
package/docs/releasing.md CHANGED
@@ -68,6 +68,64 @@ different version or tree.
68
68
 
69
69
  ## Repository configuration
70
70
 
71
+ ### Repeatable post-publication verification
72
+
73
+ From a source checkout, use the maintained artifact verifier after the normal
74
+ publication workflow succeeds:
75
+
76
+ ```sh
77
+ node scripts/verify-published-release.mjs \
78
+ --version X.Y.Z --commit RELEASE_COMMIT --tree RELEASE_TREE \
79
+ --ci-run SUCCESSFUL_CI_RUN --security-run SUCCESSFUL_SECURITY_RUN \
80
+ --publish-run SUCCESSFUL_PUBLISH_RUN \
81
+ --out /private/evidence/release-verification.json
82
+ ```
83
+
84
+ Provide the exact 40-character commit and tree and numeric run IDs. The output
85
+ parent must already exist. The tool refuses to replace an existing report and
86
+ writes one only after all checks succeed, with mode `0600` on POSIX. It supports
87
+ stable releases whose npm version and both `latest` image tags are the expected
88
+ release; it does not reinterpret an old release as the current channel.
89
+
90
+ It requires a GitHub CLI supporting `attestation verify` and its exact-source,
91
+ workflow, certificate-identity and hosted-runner policy flags, plus a current
92
+ npm supporting registry-signature and provenance verification. Use `--gh PATH`
93
+ or `--npm PATH` for explicit tool selection; stale trust data or unsupported
94
+ flags fail closed rather than skipping a check. No runtime dependency is added.
95
+
96
+ The tool checks successful maintained CI, security and publication workflows, immutable
97
+ tag/commit/tree binding, retained CI candidate checksums and exact release-asset
98
+ bytes, CycloneDX 1.6, registry bytes/integrity, npm signatures/attestations, both
99
+ anonymous OCI architectures, per-architecture bound SPDX documents and signed
100
+ exact-source publication provenance. It uses bounded network responses and
101
+ subprocess deadlines. Registry blob redirects are limited to the designated
102
+ GitHub container storage host, without forwarding registry authorization.
103
+
104
+ Verification downloads assets and installs the exact registry package into
105
+ private disposable staging with lifecycle scripts disabled; staging is removed
106
+ on success or failure. It does not publish, move tags, change configuration,
107
+ restart a server, enable capture or upgrade a machine. The report explicitly
108
+ states `deployment_verified: false`: separately run the maintained backup,
109
+ upgrade/rollback, routed/legacy-session and project-doctor procedures. Artifact
110
+ verification is not deployment evidence or a security certification.
111
+ Anonymous OCI checks read manifests, configuration and bound SBOM blobs, not
112
+ every image layer. The report records `full_image_layer_pull_verified: false`;
113
+ the separate anonymous full-pull release gate above still applies.
114
+
115
+ Run the offline positive and mutation controls without network access:
116
+
117
+ ```sh
118
+ node scripts/test-release-verification.mjs
119
+ ```
120
+
121
+ These controls inject failed workflows, different trees/tags, expired
122
+ candidates, tampered assets, unsafe checksum paths, registry mismatches,
123
+ signature/provenance failures, missing architectures and unbound SBOMs. They
124
+ test policy decisions using synthetic inputs, not real cryptographic signatures;
125
+ the live verifier delegates signature validation to npm and the GitHub CLI.
126
+
127
+ ### Publication permissions
128
+
71
129
  The repository must provide an `NPM_TOKEN` Actions secret authorized to publish
72
130
  `@cairnkeep/cli`. Keep that credential out of local files and rotate it according
73
131
  to the npm account's security policy. Workflow defaults grant only
@@ -164,6 +164,8 @@ node mcp-memory-server/scripts/smoke-outbound-security.mjs
164
164
  node scripts/test-runtime-file-security.mjs
165
165
  node scripts/test-omp-staged-security.mjs
166
166
  node scripts/test-container-health.mjs
167
+ node mcp-memory-server/scripts/smoke-note-mcp.mjs --fixture-security-only
168
+ node scripts/verify-phase19-runtime-evidence.mjs --self-test
167
169
  ```
168
170
 
169
171
  Build the server first when testing a source checkout. These regressions do not
@@ -171,6 +173,19 @@ assert that every historical CodeQL/Scorecard alert is fixed. Triage findings
171
173
  individually and use a fresh default-branch analysis to confirm alert status;
172
174
  never dismiss an uninvestigated finding merely to improve the score.
173
175
 
176
+ The developer-evidence follow-up also hardens the shared runtime note pre-image
177
+ hasher: it reads from an inspected descriptor under a 64 MiB cap and rejects
178
+ replacement or growth before accepting a hash. Missing targets remain valid
179
+ pre-images; linked or unsafe targets do not. Fixture creation uses exclusive
180
+ creation, retaining a file created by a competing writer. Regression controls
181
+ exercise both the actual transaction entry point and fixture creation.
182
+ Developer snapshots retain exact hashes and modes from the same bounded read;
183
+ the runtime-evidence verifier retains its 16 MiB cap and rejects both growth
184
+ and replacement. Corrupt SQLite fixtures must still remain failed and unchanged
185
+ after doctor. Remote-context test labels never interpolate provider values.
186
+ These are read-integrity controls, not atomic multi-file note publication or a
187
+ same-account sandbox. Re-run the full native-platform gates before shipping.
188
+
174
189
  The bounded-input control exercises real evaluation, OKF import/export, graph
175
190
  status, receipt and progressive-cache readers. Valid-file substitution and
176
191
  same-inode growth are injected at actual inspection/read boundaries; the test
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.22.4","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.22.5","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
@@ -6,6 +6,7 @@ import { buildFailureSignature } from "./failure-signature.js";
6
6
  import { NOTE_SCHEMA_VERSION, noteNodeSchema, } from "./note-schema.js";
7
7
  import { memoryImportEnvelopeSchema, nodePathSchema, } from "./node-schema.js";
8
8
  import { atomicReplace } from "./platform-security.js";
9
+ import { readStableFile } from "./stable-file.js";
9
10
  const MANAGED_START = "<!-- cairnkeep:managed:v1:start -->";
10
11
  const MANAGED_END = "<!-- cairnkeep:managed:v1:end -->";
11
12
  const MAX_PROCESSED_SESSIONS_PER_PROJECT = 4096;
@@ -569,7 +570,22 @@ export function doctorNoteStore(repair = false, options = {}) {
569
570
  }
570
571
  }
571
572
  function fileHash(path) {
572
- return existsSync(path) ? createHash("sha256").update(readFileSync(path)).digest("hex") : null;
573
+ try {
574
+ const { bytes } = readStableFile(path, { label: "Note transaction pre-image", maxBytes: 64 * 1024 * 1024 });
575
+ return createHash("sha256").update(bytes).digest("hex");
576
+ }
577
+ catch (error) {
578
+ // Missing targets are valid pre-images. Diagnose absence only after
579
+ // the descriptor-first read failed; never authorize a read by a check.
580
+ try {
581
+ lstatSync(path);
582
+ }
583
+ catch (missing) {
584
+ if (missing.code === "ENOENT")
585
+ return null;
586
+ }
587
+ throw error;
588
+ }
573
589
  }
574
590
  function bytesHash(bytes) {
575
591
  return bytes === null ? null : createHash("sha256").update(bytes).digest("hex");
@@ -1024,9 +1040,13 @@ export async function commitNoteImport(plan) {
1024
1040
  export function createNoteMutationFixture(options) {
1025
1041
  const notesRoot = join(options.storeRoot, ".cairnkeep-note-fixture");
1026
1042
  const target = join(notesRoot, "live.txt");
1027
- if (!existsSync(target)) {
1028
- mkdirSync(dirname(target), { recursive: true });
1029
- writeFileSync(target, "before\n");
1043
+ mkdirSync(dirname(target), { recursive: true });
1044
+ try {
1045
+ writeFileSync(target, "before\n", { flag: "wx", mode: 0o600 });
1046
+ }
1047
+ catch (error) {
1048
+ if (error.code !== "EEXIST")
1049
+ throw error;
1030
1050
  }
1031
1051
  const bytes = options.operation === "delete" ? null : `${options.operation}-after\n`;
1032
1052
  return { schema_version: 1, operation: options.operation, notes_root: notesRoot, changes: [{ path: target, bytes, before_hash: fileHash(target), final_hash: bytesHash(bytes) }], result: { ok: true, fixture: true } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.22.4",
3
+ "version": "2.22.5",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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,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; }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
4
+ node "$ROOT/scripts/test-release-verification.mjs"
@@ -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 userBytes = readFileSync(ownedPath);
176
- const userMtime = statSync(ownedPath).mtimeMs;
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 info = lstatSync(path);
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
+ }