@cairnkeep/cli 2.22.0 → 2.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable user-facing changes are documented here. This project follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.22.1] - 2026-10-07
9
+
10
+ ### Fixed
11
+
12
+ - Handle bounded Windows context-pack pointer-lock contention during concurrent
13
+ updates, including delete-pending access errors. Preserve exclusive ownership,
14
+ unsafe-lock rejection, denied access and the existing retry budget; never
15
+ steal another writer's lock. Add deterministic fault controls and native
16
+ cross-process exclusion coverage without changing persisted data or approval.
17
+ - Align lesson npm/container installation commands with the current release;
18
+ add a regression rejecting stale pins and update Windows/context-pack recovery
19
+ guidance and learning baselines.
20
+
21
+ ### CI
22
+
23
+ - Restrict publication token permissions to the preparation, npm and container
24
+ jobs that need them. Read-only workflow defaults and parsed-YAML positive/
25
+ mutation controls reject unnecessary authority and missing provenance grants.
26
+ Update maintainer guidance and the security learning/video supplement; no
27
+ runtime package or deployed configuration change is required.
28
+
8
29
  ## [2.22.0] - 2026-10-07
9
30
 
10
31
  ### Added
@@ -59,6 +59,16 @@ cairn pack update my-pack --apply --confirm CANDIDATE-DIGEST --project ./project
59
59
  requires the candidate digest, installs an immutable object, and atomically
60
60
  switches only the named project.
61
61
 
62
+ Project enablement, disablement and skill approvals serialize through an
63
+ exclusive directory lock before reading/replacing the pointer. Windows can
64
+ temporarily report access errors while a previous directory deletion completes;
65
+ 2.22.1 retries this contention under the existing 200-attempt, 10 ms wait budget.
66
+ Unsafe symlink/non-directory locks and unrelated access failures are rejected.
67
+ No contender removes another writer's lock or grants approval on a failed write.
68
+ If the bounded wait expires, let the active update finish, inspect `cairn doctor`
69
+ for temporary remnants, and retry. A persistent lock is not proof of abandonment;
70
+ never delete it blindly or change ACLs to silence the error.
71
+
62
72
  ## Retrieval and skill approval
63
73
 
64
74
  Set `CAIRN_CONTEXT_PACKS=1` and restart the memory server to register the
@@ -1,6 +1,6 @@
1
1
  # Curriculum Coverage Map
2
2
 
3
- **Baseline:** Cairnkeep 2.22.0
3
+ **Baseline:** Cairnkeep 2.22.1
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.0
3
+ **Baseline:** Cairnkeep 2.22.1
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.0
6
+ **Tested with:** Cairnkeep 2.22.1
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.0
6
+ **Tested with:** Cairnkeep 2.22.1
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.17.1
29
+ podman pull ghcr.io/cairnkeep/cairnkeep:2.22.1
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.17.1 stdio
41
+ ghcr.io/cairnkeep/cairnkeep:2.22.1 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.0 and Node.js 22 or newer
6
+ **Tested with:** Cairnkeep 2.22.1 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.17.1
50
+ npm install --global @cairnkeep/cli@2.22.1
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.0
6
+ **Tested with:** Cairnkeep 2.22.1
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.0
6
+ **Tested with:** Cairnkeep 2.22.1
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.0 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.1 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.0 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.1 and Node.js 22 or newer
5
5
  **Time:** 40 minutes
6
6
 
7
7
  ## Outcome
@@ -54,6 +54,10 @@ copies them into a harness.
54
54
  an enabled digest all fail closed.
55
55
  - Enabling the pack does not expose a skill until its current file digest is
56
56
  approved.
57
+ - A concurrent project update can exhaust the bounded pointer-lock wait. Let
58
+ the writer finish, inspect `cairn doctor`, then retry; do not delete another
59
+ writer's lock or weaken permissions. Windows contention recovery is included
60
+ in 2.22.1, with symlink/non-directory locks still rejected.
57
61
 
58
62
  ## Privacy and trust boundary
59
63
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Operator
5
5
  **Time:** 30 minutes
6
- **Tested with:** Cairnkeep 2.22.0, Windows x64, and Node.js 22 or newer
6
+ **Tested with:** Cairnkeep 2.22.1, 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.17.1
19
+ npm install --global @cairnkeep/cli@2.22.1
20
20
  cairn version
21
21
 
22
22
  $Lab = Join-Path $env:TEMP 'Cairnkeep Windows Lab'
@@ -62,6 +62,7 @@ the removed managed assets.
62
62
  | Hook registration reports drift | Managed `.cmd` hook or settings entry is absent | Run `cairn sync --apply`, then repeat `cairn sync --check` |
63
63
  | Memory export fails | `sqlite3.exe` is absent | Install SQLite and confirm `Get-Command sqlite3.exe`; runtime and import remain available |
64
64
  | A private-state check fails | The file inherited a broader ACL | Preserve the file, inspect it with `Get-Acl`, and recreate it through the owning Cairnkeep command |
65
+ | A context-pack project update is locked | Another writer is active or a directory remains delete-pending | Allow the writer to finish, inspect `cairn doctor`, then retry; never blindly delete a lock or weaken ACLs |
65
66
 
66
67
  ## Privacy and trust boundary
67
68
 
@@ -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.0; Pi 0.84.1; Node.js 22, 24, and 26
6
+ **Tested with:** Cairnkeep 2.22.1; 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.0 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.1 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.0 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.1 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.0 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.1 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.0 and Node.js 22 or newer
3
+ **Tested with:** Cairnkeep 2.22.1 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.0 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.1 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.0 or use a disposable source checkout.
14
+ - Install Cairnkeep 2.22.1 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.
@@ -69,8 +69,18 @@ npm --prefix mcp-memory-server run check:security-assurance
69
69
  npm --prefix mcp-memory-server run check:http-guard
70
70
  ```
71
71
 
72
- The baseline rejects unpinned external Actions and `pull_request_target`. The
73
- smoke tests cover token non-disclosure, request bounds, and deterministic
72
+ The baseline rejects unpinned external Actions, `pull_request_target` and
73
+ workflow-wide write permissions. It also checks the exact preparation/npm/OCI
74
+ job permission maps, including missing provenance grants and unnecessary write
75
+ authority. Inspect `.github/workflows/publish.yml` and compare its maps with
76
+ [Releasing](../../releasing.md): preparation must not inherit publishing or
77
+ signing authority. Unspecified entries in an explicit job map are denied.
78
+
79
+ These are static contract checks, not a live publication test. The npm secret
80
+ is a separate credential, and steps within one job share its token authority.
81
+ Do not republish an immutable version to demonstrate the test.
82
+
83
+ The smoke tests cover token non-disclosure, request bounds, and deterministic
74
84
  portable-path adversarial cases.
75
85
 
76
86
  ## Common failures
@@ -56,6 +56,19 @@ Highlight full-SHA action pins, CodeQL, dependency review, token non-leakage,
56
56
  portable Windows/POSIX path cases, and the HTTP 413 test. Do not show repository
57
57
  secrets or live security-alert details.
58
58
 
59
+ Include the offline pointer-lock control in a source checkout:
60
+ `node mcp-memory-server/scripts/smoke-context-pack-lock.mjs`. It reproduces
61
+ Windows contention/denial and unsafe-lock cases deterministically, then checks
62
+ real cross-process exclusion. Explain bounded retry versus lock stealing; a
63
+ Linux run with injected Windows errors is not native-Windows evidence.
64
+
65
+ Briefly open `.github/workflows/publish.yml` beside the permission table in
66
+ `docs/releasing.md`. Contrast the read-only preparation job with the necessary
67
+ npm and OCI publication grants. Explain that explicit maps deny unspecified
68
+ permissions, but steps in a job still share authority and the npm secret is
69
+ separate. The baseline checks declared grants; do not present it as a successful
70
+ live publish or rerun a release merely for this recording.
71
+
59
72
  ### 9:20–10:30 — Read the residual risks
60
73
 
61
74
  End on `docs/security-assurance.md`, not on a green checkmark. Call out the
package/docs/releasing.md CHANGED
@@ -3,6 +3,23 @@
3
3
  Publishing is driven by a GitHub Release. Maintainers do not publish the package
4
4
  from a workstation.
5
5
 
6
+ ## Advisory Scorecard recovery
7
+
8
+ The repository's Actions policy must allow the exact pinned
9
+ `ossf/scorecard-action@<full-SHA-from-scorecard.yml>` reference. An entry ending
10
+ in `scorecard-action/*` does not allow the root action. Keep SHA pinning required;
11
+ do not enable all third-party actions to recover this one workflow.
12
+
13
+ If GitHub rejects a run before creating any jobs and refuses to retry it, fix
14
+ the repository allowlist and start a fresh advisory analysis explicitly:
15
+
16
+ ```bash
17
+ gh workflow run scorecard.yml --ref main
18
+ ```
19
+
20
+ This does not bypass required platform, CodeQL or dependency-review gates and
21
+ does not require republishing the npm package.
22
+
6
23
  ## Release contract
7
24
 
8
25
  1. Update the root `package.json` version and `CHANGELOG.md` on a pull request.
@@ -52,11 +69,28 @@ different version or tree.
52
69
 
53
70
  The repository must provide an `NPM_TOKEN` Actions secret authorized to publish
54
71
  `@cairnkeep/cli`. Keep that credential out of local files and rotate it according
55
- to the npm account's security policy. The workflow grants `actions: read`,
56
- `contents: write`, `packages: write`, `attestations: write`, and
57
- `id-token: write`. Read access resolves the successful tree-addressed CI
58
- artifact; the last two permissions are required for registry-backed build
59
- provenance.
72
+ to the npm account's security policy. Workflow defaults grant only
73
+ `contents: read`; publication authority is explicit and job-scoped:
74
+
75
+ | Job | Token permissions | Purpose |
76
+ |---|---|---|
77
+ | `prepare` | `contents: read`, `actions: read` | Check the tag and download the successful exact-tree CI candidate; no publication or signing authority |
78
+ | `npm` | `contents: write`, `id-token: write` | Attach verified release assets and request npm provenance; registry publication uses the separate `NPM_TOKEN` |
79
+ | `containers` | `contents: read`, `packages: write`, `attestations: write`, `id-token: write`, `artifact-metadata: write` | Read source, publish OCI images, sign/upload provenance and create the pinned action's registry storage record |
80
+
81
+ Each job declares its complete permission map. Unspecified token permissions
82
+ are denied rather than inherited; see [GitHub's permission semantics](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions).
83
+ The container grant retains `artifact-metadata: write` because the pinned
84
+ [attestation implementation](https://github.com/actions/attest/blob/508db95dd578ae2727ebd6217d5ba78e4fbda05d/README.md#usage)
85
+ creates a storage record. Do not remove required provenance permissions merely
86
+ to improve an advisory score.
87
+
88
+ The parsed-YAML baseline rejects workflow-wide writes, unexpected publication
89
+ jobs and any missing or excessive grant in these maps. Its positive control
90
+ and mutation tests verify the declared contract, not GitHub's execution or a
91
+ future privileged publication. Keep the required platform/security gates and
92
+ verify the next normal release's publication and attestations separately.
93
+ CI-only permission changes do not require republishing an immutable version.
60
94
 
61
95
  GitHub creates each new GHCR package as private. After the first container
62
96
  release, a package administrator must change both `cairnkeep` and
@@ -46,6 +46,7 @@ be enabled and evaluated independently.
46
46
  |---|---|---|
47
47
  | Traversal, alternate separators, drive paths, ADS, forbidden Windows characters/device aliases, invalid Unicode, or case collisions in portable data | One cross-platform path validator is used by context packs, OKF, artifacts, and evaluations; deterministic adversarial cases run in the smoke suite | Filesystems can have additional locale- or mount-specific semantics; containment checks remain required at every write |
48
48
  | Symlink or special-file substitution | Canonical roots, non-symlink regular-file checks, atomic replacement, and existing symlink/race regression tests | A hostile process running as the same account can race or replace resources outside Cairnkeep's process boundary |
49
+ | Concurrent context-pack project pointer updates | Exclusive directory locks, bounded Windows contention retries, unsafe-lock rejection, injected denial controls and native cross-process exclusion tests | A persistent lock can block progress; recovery never infers abandonment or steals the lock |
49
50
  | Unauthenticated or rebound HTTP access | HTTP refuses to start without a token, compares it in constant time, validates `Host`, denies CORS by default, and bounds headers, request time, keep-alive time, and bodies to 8 MiB | Bearer tokens are not user identities; deploy TLS at the ingress for non-loopback traffic |
50
51
  | Excessive MCP authority | Explicit MCP annotations, restrictive profiles, capability intersection, and separate HTTP consent for sensitive optional tools | The default profile remains `full` for compatibility; operators should choose `read-only` or `custom` for observation-only clients |
51
52
  | Malicious repository or context content influencing an agent | Memory results are locators, skills need digest-bound approval, pack content stays read-only, and maintained source must be verified | Prompt injection is not solved; the harness/model must continue treating retrieved text as untrusted data |
@@ -105,6 +106,16 @@ provenance, produces CycloneDX 1.6 and container SBOMs, and attests container
105
106
  provenance. A digest proves content integrity, not publisher identity; verify
106
107
  the repository, release tag, registry namespace, and provenance together.
107
108
 
109
+ Workflow token defaults are read-only. Publication grants are explicit per job:
110
+ candidate preparation receives only source/Actions read access, npm receives
111
+ release-attachment and provenance authority, and container publication receives
112
+ registry/signing/attestation/storage-record authority without release-write
113
+ access. See the exact maps in [Releasing](releasing.md). The security baseline
114
+ rejects excess and missing grants with positive and adversarial controls.
115
+ This reduces unnecessary token authority; it neither isolates steps within a
116
+ job nor constrains the separate npm publishing secret. Static checks do not
117
+ prove a future privileged publish run succeeds.
118
+
108
119
  ## Deliberate non-goals and residual risks
109
120
 
110
121
  - Cairnkeep is not a sandbox, endpoint-security product, secret manager, TLS
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.22.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.22.1","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
@@ -0,0 +1,48 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { mkdir, rm } from "node:fs/promises";
3
+ const filesystem = {
4
+ platform: process.platform,
5
+ create: (path) => mkdir(path, { mode: 0o700 }),
6
+ inspect: (path) => lstatSync(path),
7
+ remove: (path) => rm(path, { recursive: true, force: true }),
8
+ wait: (milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)),
9
+ };
10
+ // The internal filesystem seam lets offline controls reproduce platform errors
11
+ // without replacing process.platform or weakening the real filesystem checks.
12
+ export async function acquireContextPackPointerLock(lock, operations = filesystem) {
13
+ for (let attempt = 0; attempt < 200; attempt += 1) {
14
+ try {
15
+ await operations.create(lock);
16
+ return () => operations.remove(lock);
17
+ }
18
+ catch (error) {
19
+ const code = error.code;
20
+ const windowsContention = operations.platform === "win32" && code === "EPERM";
21
+ if (code !== "EEXIST" && !windowsContention)
22
+ throw error;
23
+ let info;
24
+ try {
25
+ info = operations.inspect(lock);
26
+ }
27
+ catch (inspectionError) {
28
+ const inspectionCode = inspectionError.code;
29
+ if (inspectionCode === "ENOENT") {
30
+ // A Windows delete-pending directory can temporarily appear
31
+ // absent; pace this case under the same bounded retry budget.
32
+ if (windowsContention)
33
+ await operations.wait(10);
34
+ continue;
35
+ }
36
+ if (operations.platform === "win32" && ["EPERM", "EBUSY"].includes(inspectionCode ?? "")) {
37
+ await operations.wait(10);
38
+ continue;
39
+ }
40
+ throw inspectionError;
41
+ }
42
+ if (!info.isDirectory() || info.isSymbolicLink())
43
+ throw new Error("Context pack project pointer lock is unsafe.");
44
+ await operations.wait(10);
45
+ }
46
+ }
47
+ throw new Error("Context pack project pointer is locked; retry after the active update finishes.");
48
+ }
@@ -11,6 +11,7 @@ import { doctorProgressiveContextCache, loadProgressiveContext, publicTree, sele
11
11
  import { indexOkfBundle, validateOkfBundle, validatedOkfFileContent } from "./okf.js";
12
12
  import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
13
13
  import { portablePathCollisionKey, portableRelativePathIssue } from "./path-security.js";
14
+ import { acquireContextPackPointerLock } from "./context-pack-lock.js";
14
15
  const execFileAsync = promisify(execFile);
15
16
  export const CONTEXT_PACK_MANIFEST = "context-pack.json";
16
17
  export const CONTEXT_PACK_MAX_FILE_BYTES = 1024 * 1024;
@@ -611,36 +612,10 @@ export function readProjectPointer(options) {
611
612
  throw new Error("Context pack project pointer is unsafe.");
612
613
  return parseProjectPointer(JSON.parse(readFileSync(identity.path, "utf8")), identity.id);
613
614
  }
614
- function delay(milliseconds) {
615
- return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
616
- }
617
615
  async function acquirePointerLock(options) {
618
616
  const identity = projectIdentity(options);
619
617
  await mkdir(dirname(identity.path), { recursive: true, mode: 0o700 });
620
- const lock = `${identity.path}.lock`;
621
- for (let attempt = 0; attempt < 200; attempt += 1) {
622
- try {
623
- await mkdir(lock, { mode: 0o700 });
624
- return () => rm(lock, { recursive: true, force: true });
625
- }
626
- catch (error) {
627
- if (error.code !== "EEXIST")
628
- throw error;
629
- let info;
630
- try {
631
- info = lstatSync(lock);
632
- }
633
- catch (inspectionError) {
634
- if (inspectionError.code === "ENOENT")
635
- continue;
636
- throw inspectionError;
637
- }
638
- if (!info.isDirectory() || info.isSymbolicLink())
639
- throw new Error("Context pack project pointer lock is unsafe.");
640
- await delay(10);
641
- }
642
- }
643
- throw new Error("Context pack project pointer is locked; retry after the active update finishes.");
618
+ return acquireContextPackPointerLock(`${identity.path}.lock`);
644
619
  }
645
620
  async function mutateProjectPointer(options, mutate) {
646
621
  const release = await acquirePointerLock(options);
@@ -51,7 +51,7 @@
51
51
  "check:graph-cli": "node scripts/smoke-graph-cli.mjs",
52
52
  "check:mcp-trust": "node scripts/smoke-mcp-trust.mjs",
53
53
  "check:pi-mcp-bridge": "node scripts/smoke-pi-mcp-bridge.mjs",
54
- "check:context-pack": "node scripts/smoke-context-pack.mjs",
54
+ "check:context-pack": "node scripts/smoke-context-pack-lock.mjs && node scripts/smoke-context-pack.mjs",
55
55
  "check:context-pack-retrieval": "node scripts/smoke-context-pack-retrieval.mjs",
56
56
  "check:context-pack-v216-compat": "node scripts/smoke-context-pack-v216-compat.mjs",
57
57
  "check:context-usage": "node scripts/smoke-context-usage.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.22.0",
3
+ "version": "2.22.1",
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",
@@ -74,6 +74,7 @@ done < <(bin/cairn help | sed -n 's/^ cairn \([a-z-]*\).*/\1/p' | sort -u)
74
74
  node <<'NODE'
75
75
  const { existsSync, readdirSync, readFileSync } = require("node:fs");
76
76
  const { dirname, resolve } = require("node:path");
77
+ const packageVersion = JSON.parse(readFileSync("package.json", "utf8")).version;
77
78
 
78
79
  function markdownFiles(root) {
79
80
  return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
@@ -84,6 +85,13 @@ function markdownFiles(root) {
84
85
 
85
86
  for (const file of markdownFiles("docs/learning")) {
86
87
  const text = readFileSync(file, "utf8");
88
+ if (file.startsWith("docs/learning/lessons/")) {
89
+ for (const match of text.matchAll(/(?:@cairnkeep\/cli@|ghcr\.io\/cairnkeep\/cairnkeep(?:-workspace)?:)(\d+\.\d+\.\d+(?:-[\w.-]+)?)/g)) {
90
+ if (match[1] !== packageVersion) {
91
+ throw new Error(`stale installation command in ${file}: ${match[1]} instead of ${packageVersion}`);
92
+ }
93
+ }
94
+ }
87
95
  for (const match of text.matchAll(/\]\(([^)]+)\)/g)) {
88
96
  const target = match[1].split("#", 1)[0];
89
97
  if (!target || /^[a-z]+:/i.test(target)) continue;
@@ -35,6 +35,10 @@ function walk(value, visit, path = "workflow") {
35
35
 
36
36
  function validateWorkflow(name, workflow) {
37
37
  assert.ok(workflow && typeof workflow === "object" && !Array.isArray(workflow), `${name} is not a workflow mapping`);
38
+ assert.ok(workflow.permissions && typeof workflow.permissions === "object" && !Array.isArray(workflow.permissions), `${name} must have an explicit workflow permission mapping`);
39
+ for (const [permission, level] of Object.entries(workflow.permissions)) {
40
+ assert.ok(["read", "none"].includes(level), `${name} has a non-read-only workflow permission: ${permission}`);
41
+ }
38
42
  const triggers = workflow.on;
39
43
  const triggerNames = typeof triggers === "string" ? [triggers] : Array.isArray(triggers) ? triggers : Object.keys(triggers ?? {});
40
44
  assert.ok(!triggerNames.includes("pull_request_target"), `${name} uses pull_request_target`);
@@ -51,24 +55,81 @@ function validateWorkflow(name, workflow) {
51
55
  });
52
56
  }
53
57
 
54
- for (const { name, workflow } of sources) validateWorkflow(name, workflow);
58
+ const PUBLICATION_PERMISSIONS = {
59
+ prepare: { actions: "read", contents: "read" },
60
+ npm: { contents: "write", "id-token": "write" },
61
+ containers: {
62
+ contents: "read",
63
+ packages: "write",
64
+ attestations: "write",
65
+ "id-token": "write",
66
+ "artifact-metadata": "write",
67
+ },
68
+ };
69
+
70
+ function validatePublicationPermissions(workflow) {
71
+ validateWorkflow("publish.yml", workflow);
72
+ assert.deepEqual(workflow.permissions, { contents: "read" }, "publication workflow default must be contents-read only");
73
+ assert.deepEqual(Object.keys(workflow.jobs).sort(), Object.keys(PUBLICATION_PERMISSIONS).sort(), "publication jobs require explicit permission review");
74
+ for (const [job, permissions] of Object.entries(PUBLICATION_PERMISSIONS)) {
75
+ assert.deepEqual(workflow.jobs[job].permissions, permissions, `publication ${job} permissions must match its reviewed tasks`);
76
+ }
77
+ }
78
+
79
+ const publish = sources.find(({ name }) => name === "publish.yml")?.workflow;
80
+ assert.ok(publish, "publication workflow is missing");
81
+ const permissionControl = structuredClone(publish);
82
+ permissionControl.permissions = { contents: "read" };
83
+ for (const [job, permissions] of Object.entries(PUBLICATION_PERMISSIONS)) {
84
+ permissionControl.jobs[job].permissions = structuredClone(permissions);
85
+ }
86
+ validatePublicationPermissions(permissionControl);
87
+
88
+ let permissionFixtures = 0;
89
+ function rejectPermissionMutation(mutate, expected) {
90
+ const fixture = structuredClone(permissionControl);
91
+ mutate(fixture);
92
+ assert.throws(() => validatePublicationPermissions(fixture), expected, "unsafe publication permission mutation was accepted");
93
+ permissionFixtures += 1;
94
+ }
95
+ for (const permission of ["actions", "contents", "id-token", "packages", "attestations", "artifact-metadata"]) {
96
+ rejectPermissionMutation((fixture) => { fixture.permissions[permission] = "write"; }, /non-read-only workflow permission/);
97
+ }
98
+ rejectPermissionMutation((fixture) => { delete fixture.permissions; }, /explicit workflow permission mapping/);
99
+ rejectPermissionMutation((fixture) => { fixture.permissions = "write-all"; }, /explicit workflow permission mapping/);
100
+ rejectPermissionMutation((fixture) => { fixture.jobs.extra = { permissions: { contents: "read" } }; }, /explicit permission review/);
101
+ for (const [job, permissions] of Object.entries(PUBLICATION_PERMISSIONS)) {
102
+ rejectPermissionMutation((fixture) => { delete fixture.jobs[job].permissions; }, /permissions must match/);
103
+ rejectPermissionMutation((fixture) => { fixture.jobs[job].permissions = "write-all"; }, /broad scalar permissions/);
104
+ for (const permission of Object.keys(permissions)) {
105
+ rejectPermissionMutation((fixture) => { delete fixture.jobs[job].permissions[permission]; }, /permissions must match/);
106
+ }
107
+ for (const permission of ["actions", "contents", "id-token", "packages", "attestations", "artifact-metadata"]) {
108
+ if (permissions[permission] === "write") continue;
109
+ rejectPermissionMutation((fixture) => { fixture.jobs[job].permissions[permission] = "write"; }, /permissions must match/);
110
+ }
111
+ }
55
112
 
56
113
  const maliciousFixtures = [
57
- "name: bad\non: push\npermissions: write-all\njobs: {}\n",
58
- "name: bad\non: pull_request_target\njobs: {}\n",
59
- "name: bad\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: >-\n actions/checkout@v7\n",
60
- "name: bad\non: push\njobs:\n call:\n uses: 'owner/repo/.github/workflows/reuse.yml@main'\n",
61
- `name: bad\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: evil/action@${"a".repeat(40)}\n`,
114
+ ["name: bad\non: push\npermissions: write-all\njobs: {}\n", /explicit workflow permission mapping/],
115
+ ["name: bad\non: pull_request_target\npermissions: {contents: read}\njobs: {}\n", /uses pull_request_target/],
116
+ ["name: bad\non: push\npermissions: {contents: read}\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: >-\n actions/checkout@v7\n", /not pinned to a full commit/],
117
+ ["name: bad\non: push\npermissions: {contents: read}\njobs:\n call:\n uses: 'owner/repo/.github/workflows/reuse.yml@main'\n", /not pinned to a full commit/],
118
+ [`name: bad\non: push\npermissions: {contents: read}\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: evil/action@${"a".repeat(40)}\n`, /not on the maintained allowlist/],
62
119
  ];
63
- for (const [index, fixture] of maliciousFixtures.entries()) {
64
- assert.throws(() => validateWorkflow(`malicious-${index}.yml`, parse(fixture)), undefined, `malicious workflow fixture ${index} was accepted`);
120
+ for (const [index, [fixture, expected]] of maliciousFixtures.entries()) {
121
+ assert.throws(() => validateWorkflow(`malicious-${index}.yml`, parse(fixture)), expected, `malicious workflow fixture ${index} was accepted`);
65
122
  }
66
123
 
124
+ for (const { name, workflow } of sources) validateWorkflow(name, workflow);
125
+ validatePublicationPermissions(publish);
126
+
67
127
  const security = sources.find(({ name }) => name === "security.yml")?.text ?? "";
68
128
  assert.match(security, /github\/codeql-action\/init@[a-f0-9]{40}/);
69
129
  assert.match(security, /actions\/dependency-review-action@[a-f0-9]{40}/);
70
130
  assert.match(security, /queries:\s*security-extended/);
71
131
  const scorecard = sources.find(({ name }) => name === "scorecard.yml")?.text ?? "";
132
+ assert.ok(Object.hasOwn(parse(scorecard).on, "workflow_dispatch"), "Scorecard must support manual recovery without a source push");
72
133
  assert.match(scorecard, /ossf\/scorecard-action@[a-f0-9]{40}/);
73
134
  assert.match(scorecard, /publish_results:\s*true/);
74
135
 
@@ -76,4 +137,4 @@ const rootPackage = JSON.parse(readFileSync(join(root, "package.json"), "utf8"))
76
137
  assert.match(rootPackage.scripts["audit:runtime"] ?? "", /npm audit --omit=dev/);
77
138
  assert.match(rootPackage.scripts["check:public"] ?? "", /security:baseline/);
78
139
 
79
- console.log(`PASS: ${workflowFiles.length} workflows and ${maliciousFixtures.length} adversarial fixtures enforce parsed YAML, allowlisted full-SHA actions, bounded triggers, and required security controls`);
140
+ console.log(`PASS: ${workflowFiles.length} workflows, ${maliciousFixtures.length} action/trigger fixtures and ${permissionFixtures} permission mutations enforce parsed YAML, full-SHA actions, read-only defaults and job-scoped publication authority`);