@cairnkeep/cli 2.22.1 → 2.22.3

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 (29) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/docs/context-packs.md +9 -0
  3. package/docs/learning/CURRICULUM-MAP.md +1 -1
  4. package/docs/learning/FEATURE-GUIDE.md +1 -1
  5. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  6. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  7. package/docs/learning/lessons/L02-installation.md +2 -2
  8. package/docs/learning/lessons/L03-first-project.md +1 -1
  9. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  10. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  11. package/docs/learning/lessons/L21-context-packs.md +1 -1
  12. package/docs/learning/lessons/L22-native-windows.md +2 -2
  13. package/docs/learning/lessons/L23-guided-setup.md +8 -1
  14. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  15. package/docs/learning/lessons/L25-playbooks.md +1 -1
  16. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  17. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  18. package/docs/learning/lessons/L28-security-assurance.md +10 -2
  19. package/docs/learning/video-scripts/V28-security-assurance.md +7 -0
  20. package/docs/operating.md +9 -1
  21. package/docs/security-assurance.md +1 -0
  22. package/examples/eval/bundled-fake.json +1 -1
  23. package/mcp-memory-server/dist/context-pack-state.js +93 -0
  24. package/mcp-memory-server/dist/context-pack.js +14 -28
  25. package/mcp-memory-server/package.json +1 -1
  26. package/package.json +1 -1
  27. package/scripts/setup.mjs +18 -3
  28. package/scripts/test-setup-diagnosis.mjs +97 -0
  29. package/scripts/test-windows-native.mjs +2 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable user-facing changes are documented here. This project follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.22.3] - 2026-10-07
9
+
10
+ ### Fixed
11
+
12
+ - Diagnose preserved custom setup files separately from generated-file
13
+ ownership. A complete scaffold no longer fails merely because setup skipped
14
+ caller-owned files or its ownership map is empty. Keep missing/unsafe asset,
15
+ launcher-executable and recorded digest/mode checks; never adopt, rewrite or
16
+ remove custom files during diagnosis. Add cross-platform regression controls
17
+ and clarify operating and guided-setup learning documentation.
18
+
19
+ ## [2.22.2] - 2026-10-07
20
+
21
+ ### Security
22
+
23
+ - Bind mutable private context-pack pointers, source records and graph-cache
24
+ reads to one opened descriptor. Reject replacement, non-private ownership,
25
+ symlink/special-file substitution, growth and invalid encoding under bounded
26
+ reads; close descriptors on success and failure. Update inspection uses the
27
+ same strict source-record parser as installation/listing.
28
+ - Add deterministic replacement and denial controls, including fabricated
29
+ pointer approvals, bounded parsing and descriptor cleanup. Keep schemas,
30
+ feature gates and approval policy unchanged; update recovery/security and
31
+ learning guidance. Existing security-analysis findings remain visible.
32
+
8
33
  ## [2.22.1] - 2026-10-07
9
34
 
10
35
  ### Fixed
@@ -69,6 +69,15 @@ If the bounded wait expires, let the active update finish, inspect `cairn doctor
69
69
  for temporary remnants, and retry. A persistent lock is not proof of abandonment;
70
70
  never delete it blindly or change ACLs to silence the error.
71
71
 
72
+ From 2.22.2, mutable private pointers, source records and graph-cache state are
73
+ opened once and read through that descriptor under their existing size limits.
74
+ Metadata, private ownership and path/parent identity are checked before and
75
+ after reading. Concurrent replacement or growth fails closed: retry after the
76
+ writer finishes, then inspect `cairn doctor` if the problem persists. Malformed
77
+ state is reported without quoting private JSON content. Missing state is not
78
+ an excuse to follow a dangling symlink or infer skill approval. These controls
79
+ do not protect against a hostile process with the same account's full access.
80
+
72
81
  ## Retrieval and skill approval
73
82
 
74
83
  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.1
3
+ **Baseline:** Cairnkeep 2.22.3
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.1
3
+ **Baseline:** Cairnkeep 2.22.3
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.1
6
+ **Tested with:** Cairnkeep 2.22.3
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.1
6
+ **Tested with:** Cairnkeep 2.22.3
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.1
29
+ podman pull ghcr.io/cairnkeep/cairnkeep:2.22.3
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.1 stdio
41
+ ghcr.io/cairnkeep/cairnkeep:2.22.3 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.1 and Node.js 22 or newer
6
+ **Tested with:** Cairnkeep 2.22.3 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.1
50
+ npm install --global @cairnkeep/cli@2.22.3
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.1
6
+ **Tested with:** Cairnkeep 2.22.3
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.1
6
+ **Tested with:** Cairnkeep 2.22.3
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.1 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.3 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.1 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.3 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.1, Windows x64, and Node.js 22 or newer
6
+ **Tested with:** Cairnkeep 2.22.3, 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.1
19
+ npm install --global @cairnkeep/cli@2.22.3
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.1; Pi 0.84.1; Node.js 22, 24, and 26
6
+ **Tested with:** Cairnkeep 2.22.3; Pi 0.84.1; Node.js 22, 24, and 26
7
7
 
8
8
  ## Outcome
9
9
 
@@ -54,6 +54,13 @@ records the selected Pi harness in private `.ai/cairnkeep.json`, and reports
54
54
  created, updated, unchanged, and skipped assets. Repeating the same `cairn
55
55
  setup` command should report the managed files as unchanged.
56
56
 
57
+ When an existing project has customized launchers or scaffold files, setup
58
+ preserves and skips them. Its private asset map tracks ownership, not a full
59
+ inventory. Doctor accepts safely present custom files without adopting them,
60
+ but still rejects missing files, links, non-executable POSIX launchers and
61
+ changes to recorded owned assets. Do not replace a custom file just to satisfy
62
+ a generated digest. Diagnosis does not change file contents or ownership.
63
+
57
64
  Project setup never changes the Pi installation. Apply and check machine assets
58
65
  explicitly, then diagnose the project again:
59
66
 
@@ -1,7 +1,7 @@
1
1
  # L24 - Reviewed knowledge exchange
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.1 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.3 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.1 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.3 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.1 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.3 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.1 and Node.js 22 or newer
3
+ **Tested with:** Cairnkeep 2.22.3 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.1 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.3 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.1 or use a disposable source checkout.
14
+ - Install Cairnkeep 2.22.3 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.
@@ -67,6 +67,7 @@ npm run security:baseline
67
67
  scripts/test-security-baseline.sh
68
68
  npm --prefix mcp-memory-server run check:security-assurance
69
69
  npm --prefix mcp-memory-server run check:http-guard
70
+ node mcp-memory-server/scripts/smoke-context-pack-state.mjs
70
71
  ```
71
72
 
72
73
  The baseline rejects unpinned external Actions, `pull_request_target` and
@@ -83,6 +84,13 @@ Do not republish an immutable version to demonstrate the test.
83
84
  The smoke tests cover token non-disclosure, request bounds, and deterministic
84
85
  portable-path adversarial cases.
85
86
 
87
+ The pack-state control deterministically substitutes a second valid pointer
88
+ after metadata inspection. The descriptor-bound reader rejects it, including
89
+ fabricated approvals; growth, unsafe types and non-private state are rejected
90
+ under bounded reads. A resolved review thread or green CodeQL job does not mean
91
+ all historical alerts are fixed: inspect current findings and test the actual
92
+ boundary without dismissing results merely to improve an advisory score.
93
+
86
94
  ## Common failures
87
95
 
88
96
  - A private `.ai` file is group/world accessible on POSIX: restore mode `0600`.
@@ -62,6 +62,13 @@ Windows contention/denial and unsafe-lock cases deterministically, then checks
62
62
  real cross-process exclusion. Explain bounded retry versus lock stealing; a
63
63
  Linux run with injected Windows errors is not native-Windows evidence.
64
64
 
65
+ Run `node mcp-memory-server/scripts/smoke-context-pack-state.mjs` next. Explain
66
+ check-then-reopen races with two synthetic pointer files: a valid metadata
67
+ check must not authorize reading a replacement file. Show descriptor-bound
68
+ rejection, bounded growth and cleanup; use no real private pointer/approval
69
+ data. Distinguish resolved old review comments from current analysis findings,
70
+ and do not imply a green CI run certifies an empty security backlog.
71
+
65
72
  Briefly open `.github/workflows/publish.yml` beside the permission table in
66
73
  `docs/releasing.md`. Contrast the read-only preparation job with the necessary
67
74
  npm and OCI publication grants. Explain that explicit maps deny unspecified
package/docs/operating.md CHANGED
@@ -94,6 +94,12 @@ returns the same schema-v1 result. The private mode-`0600`
94
94
  selected harnesses, and digests/modes/template identifiers for setup-owned
95
95
  assets. It contains no credentials, endpoints, or absolute paths.
96
96
 
97
+ The asset map records ownership, not every file in the project. Existing custom
98
+ files that setup skips remain caller-owned. Doctor checks that every required
99
+ selected asset is a regular file under non-symlinked scaffold directories and
100
+ that POSIX launchers are executable, without adopting or rewriting custom files.
101
+ An empty ownership map is valid when all required assets were already present.
102
+
97
103
  Setup never installs or refreshes machine-level harness assets. Its
98
104
  `machine_sync.automatic` field is always false, and the human output labels each
99
105
  reported command as not run automatically. The JSON result's
@@ -1072,7 +1078,9 @@ Unsupported schema, SQLite failure, invalid full records, or digest corruption
1072
1078
  fails untouched with guidance to preserve the database before manual recovery.
1073
1079
 
1074
1080
  When `.ai/cairnkeep.json` exists, doctor also validates its private state,
1075
- selected assets, digests, and modes. It reports `limited` for explicit
1081
+ selected assets, and the digests and modes of recorded setup-owned files.
1082
+ Preserved caller-owned files need safe presence, not a generated-file digest;
1083
+ doctor never claims ownership of them. It reports `limited` for explicit
1076
1084
  `--git none`, fails an incomplete or drifted setup with the recorded
1077
1085
  deterministic `cairn setup` recovery command, and checks the Pi machine
1078
1086
  extension when Pi was selected. `cairn sync-pi --apply` repairs that
@@ -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
+ | Replaced or growing private pack state between inspection and use | One descriptor for metadata and bounded content reads; name/parent identity rechecks, private ownership and encoding validation; deterministic replacement/denial controls | Concurrent legitimate replacement can require retry; these checks are not per-user ACLs or a defense against an account with full local control |
49
50
  | 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 |
50
51
  | 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 |
51
52
  | 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 |
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.22.1","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.22.3","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
@@ -0,0 +1,93 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { posixPrivateMetadataIsSafe, privatePathIsSafe } from "./platform-security.js";
4
+ function sameFile(left, right) {
5
+ return right.isFile() && !right.isSymbolicLink()
6
+ && left.dev === right.dev && left.ino === right.ino && left.size === right.size
7
+ && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs
8
+ && left.uid === right.uid && left.mode === right.mode;
9
+ }
10
+ // Mutable private pack state must be inspected and read through one opened
11
+ // descriptor. Pathname checks alone cannot authorize a later pathname read.
12
+ export function readPrivatePackJson(path, options) {
13
+ const { label, maxBytes } = options;
14
+ let descriptor;
15
+ try {
16
+ const nonBlocking = process.platform === "win32" ? 0 : (constants.O_NONBLOCK ?? 0);
17
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | nonBlocking);
18
+ const opened = fstatSync(descriptor);
19
+ if (!opened.isFile() || opened.size > maxBytes
20
+ || (process.platform !== "win32" && !posixPrivateMetadataIsSafe(opened))) {
21
+ throw new Error(`${label} is unsafe or too large.`);
22
+ }
23
+ const parent = lstatSync(dirname(path));
24
+ if (!parent.isDirectory() || parent.isSymbolicLink() || !sameFile(opened, lstatSync(path))) {
25
+ throw new Error(`${label} is unsafe or changed.`);
26
+ }
27
+ if (process.platform === "win32") {
28
+ if (!privatePathIsSafe(path))
29
+ throw new Error(`${label} is unsafe.`);
30
+ if (!sameFile(opened, lstatSync(path)))
31
+ throw new Error(`${label} changed during ACL inspection.`);
32
+ }
33
+ const buffer = Buffer.allocUnsafe(opened.size + 1);
34
+ let offset = 0;
35
+ while (offset < buffer.byteLength) {
36
+ const count = readSync(descriptor, buffer, offset, buffer.byteLength - offset, null);
37
+ if (count === 0)
38
+ break;
39
+ offset += count;
40
+ }
41
+ const after = fstatSync(descriptor);
42
+ const named = lstatSync(path);
43
+ const parentAfter = lstatSync(dirname(path));
44
+ if (!sameFile(opened, after) || !sameFile(opened, named) || offset !== after.size
45
+ || parentAfter.isSymbolicLink() || !parentAfter.isDirectory()
46
+ || parent.dev !== parentAfter.dev || parent.ino !== parentAfter.ino) {
47
+ throw new Error(`${label} changed while it was being read.`);
48
+ }
49
+ let text;
50
+ try {
51
+ text = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
52
+ }
53
+ catch {
54
+ throw new Error(`${label} contains invalid UTF-8.`);
55
+ }
56
+ try {
57
+ return JSON.parse(text);
58
+ }
59
+ catch {
60
+ throw new Error(`${label} contains invalid JSON.`);
61
+ }
62
+ }
63
+ catch (error) {
64
+ if (descriptor === undefined && options.optional && error.code === "ENOENT") {
65
+ // A dangling link is unsafe state, not an absent project/source.
66
+ try {
67
+ lstatSync(path);
68
+ throw new Error(`${label} is unsafe or changed.`);
69
+ }
70
+ catch (missingError) {
71
+ if (missingError.code !== "ENOENT")
72
+ throw missingError;
73
+ }
74
+ try {
75
+ const parent = lstatSync(dirname(path));
76
+ if (!parent.isDirectory() || parent.isSymbolicLink())
77
+ throw new Error(`${label} directory is unsafe.`);
78
+ }
79
+ catch (parentError) {
80
+ if (parentError.code !== "ENOENT")
81
+ throw parentError;
82
+ }
83
+ return undefined;
84
+ }
85
+ if (error instanceof Error && error.message.startsWith(`${label} `))
86
+ throw error;
87
+ throw new Error(`${label} is unsafe or unreadable.`);
88
+ }
89
+ finally {
90
+ if (descriptor !== undefined)
91
+ closeSync(descriptor);
92
+ }
93
+ }
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { constants, existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
3
+ import { constants, existsSync, lstatSync, realpathSync } from "node:fs";
4
4
  import { chmod, mkdir, mkdtemp, open, readdir, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -9,9 +9,10 @@ import { z } from "zod";
9
9
  import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText } from "./embeddings.js";
10
10
  import { doctorProgressiveContextCache, loadProgressiveContext, publicTree, selectHierarchicalFiles, } from "./context-pack-retrieval.js";
11
11
  import { indexOkfBundle, validateOkfBundle, validatedOkfFileContent } from "./okf.js";
12
- import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
12
+ import { atomicReplace, hardenPrivatePath } from "./platform-security.js";
13
13
  import { portablePathCollisionKey, portableRelativePathIssue } from "./path-security.js";
14
14
  import { acquireContextPackPointerLock } from "./context-pack-lock.js";
15
+ import { readPrivatePackJson } from "./context-pack-state.js";
15
16
  const execFileAsync = promisify(execFile);
16
17
  export const CONTEXT_PACK_MANIFEST = "context-pack.json";
17
18
  export const CONTEXT_PACK_MAX_FILE_BYTES = 1024 * 1024;
@@ -78,15 +79,9 @@ function assertUtf8(bytes, path) {
78
79
  function readPackSource(digest) {
79
80
  const directory = join(packBaseDir(), "sources");
80
81
  const path = join(directory, `${digest}.json`);
81
- if (!existsSync(path))
82
+ const value = readPrivatePackJson(path, { label: "Context pack source record", maxBytes: 64 * 1024, optional: true });
83
+ if (value === undefined)
82
84
  return undefined;
83
- const directoryInfo = lstatSync(directory);
84
- const info = lstatSync(path);
85
- if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink() || !info.isFile() || info.isSymbolicLink()
86
- || info.size > 64 * 1024 || !privatePathIsSafe(path)) {
87
- throw new Error(`Context pack source record is unsafe: ${digest}`);
88
- }
89
- const value = JSON.parse(readFileSync(path, "utf8"));
90
85
  if (!value || typeof value !== "object" || Array.isArray(value))
91
86
  throw new Error(`Context pack source record is invalid: ${digest}`);
92
87
  const record = value;
@@ -604,13 +599,8 @@ function parseProjectPointer(value, expectedId) {
604
599
  }
605
600
  export function readProjectPointer(options) {
606
601
  const identity = projectIdentity(options);
607
- if (!existsSync(identity.path))
608
- return emptyPointer(identity.id);
609
- const info = lstatSync(identity.path);
610
- if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024
611
- || !privatePathIsSafe(identity.path))
612
- throw new Error("Context pack project pointer is unsafe.");
613
- return parseProjectPointer(JSON.parse(readFileSync(identity.path, "utf8")), identity.id);
602
+ const value = readPrivatePackJson(identity.path, { label: "Context pack project pointer", maxBytes: 1024 * 1024, optional: true });
603
+ return value === undefined ? emptyPointer(identity.id) : parseProjectPointer(value, identity.id);
614
604
  }
615
605
  async function acquirePointerLock(options) {
616
606
  const identity = projectIdentity(options);
@@ -659,15 +649,10 @@ async function allPointers() {
659
649
  throw new Error("Context pack project pointer directory is unsafe.");
660
650
  const result = [];
661
651
  for (const entry of await readdir(directory, { withFileTypes: true })) {
662
- if (!entry.isFile() || !entry.name.endsWith(".json"))
652
+ if (!entry.name.endsWith(".json"))
663
653
  continue;
664
654
  const path = join(directory, entry.name);
665
- const info = lstatSync(path);
666
- if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024
667
- || !privatePathIsSafe(path)) {
668
- throw new Error(`Context pack project pointer is unsafe: ${path}`);
669
- }
670
- const raw = JSON.parse(await readFile(path, "utf8"));
655
+ const raw = readPrivatePackJson(path, { label: "Context pack project pointer", maxBytes: 1024 * 1024 });
671
656
  const value = raw;
672
657
  if (typeof value.project_id !== "string")
673
658
  throw new Error("Context pack project pointer is invalid.");
@@ -1149,14 +1134,14 @@ export async function doctorContextPacks(options = {}) {
1149
1134
  for (const entry of await readdir(graphDirectory, { withFileTypes: true })) {
1150
1135
  const match = /^([a-f0-9]{64})\.json$/.exec(entry.name);
1151
1136
  const path = join(graphDirectory, entry.name);
1152
- if (!entry.isFile() || entry.isSymbolicLink() || !match || lstatSync(path).size > CONTEXT_PACK_MAX_FILE_BYTES || !privatePathIsSafe(path)) {
1137
+ if (!entry.isFile() || entry.isSymbolicLink() || !match) {
1153
1138
  issues.push(`Invalid context pack graph cache entry: ${entry.name}`);
1154
1139
  continue;
1155
1140
  }
1156
1141
  if (!existsSync(objectRoot(match[1])))
1157
1142
  issues.push(`Orphaned context pack graph cache: ${entry.name}`);
1158
1143
  try {
1159
- const cached = JSON.parse(await readFile(path, "utf8"));
1144
+ const cached = readPrivatePackJson(path, { label: "Context pack graph cache", maxBytes: CONTEXT_PACK_MAX_FILE_BYTES });
1160
1145
  if (cached.schema_version !== 1 || !Array.isArray(cached.files) || !Array.isArray(cached.diagnostics))
1161
1146
  throw new Error("invalid graph cache schema");
1162
1147
  const expected = expectedGraphs.get(match[1]);
@@ -1212,8 +1197,9 @@ export async function inspectContextPackUpdate(selector, options) {
1212
1197
  const enabled = pointer.enabled.find((entry) => entry.id === selector || entry.digest === selector || entry.digest.startsWith(selector));
1213
1198
  if (!enabled)
1214
1199
  throw new Error("Context pack is not enabled for this project.");
1215
- const sourceRecord = JSON.parse(await readFile(join(packBaseDir(), "sources", `${enabled.digest}.json`), "utf8"));
1216
- const source = sourceRecord.source;
1200
+ const source = readPackSource(enabled.digest);
1201
+ if (!source)
1202
+ throw new Error("Context pack source record is missing.");
1217
1203
  const current = await validateContextPack(objectRoot(enabled.digest));
1218
1204
  const materialized = await materializeSource(source.kind === "local" ? source.path : source.url, source.kind === "git" ? source.ref : undefined);
1219
1205
  let prepared;
@@ -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-lock.mjs && node scripts/smoke-context-pack.mjs",
54
+ "check:context-pack": "node scripts/smoke-context-pack-state.mjs && 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.1",
3
+ "version": "2.22.3",
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",
package/scripts/setup.mjs CHANGED
@@ -344,6 +344,21 @@ function safeStateAsset(path, record) {
344
344
  && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(record.template);
345
345
  }
346
346
 
347
+ function safeRequiredAsset(project, path, launcher) {
348
+ // Presence is not ownership: setup intentionally skips pre-existing custom
349
+ // files. Validate the selected asset without adopting or reading its content.
350
+ const parts = path.split("/");
351
+ let destination = project;
352
+ for (const part of parts.slice(0, -1)) {
353
+ destination = join(destination, part);
354
+ const parent = lstatSync(destination);
355
+ if (!parent.isDirectory() || parent.isSymbolicLink()) return false;
356
+ }
357
+ const info = lstatSync(join(destination, parts.at(-1)));
358
+ return info.isFile() && !info.isSymbolicLink()
359
+ && (!launcher || process.platform === "win32" || (info.mode & 0o100) !== 0);
360
+ }
361
+
347
362
  function readPrivateState(path) {
348
363
  const info = lstatSync(path);
349
364
  if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw new Error("unsafe");
@@ -435,8 +450,7 @@ export function diagnoseSetup(target = ".") {
435
450
  || state.harnesses.some((name) => !HARNESSES.includes(name))
436
451
  || !state.assets
437
452
  || typeof state.assets !== "object"
438
- || Array.isArray(state.assets)
439
- || Object.keys(state.assets).length === 0) return incompleteDiagnosis();
453
+ || Array.isArray(state.assets)) return incompleteDiagnosis();
440
454
 
441
455
  const recovery = `cairn setup . --git ${state.git} --harness ${state.harnesses.join(",")} --memory ${state.memory} --yes`;
442
456
  for (const [path, record] of Object.entries(state.assets)) {
@@ -449,7 +463,8 @@ export function diagnoseSetup(target = ".") {
449
463
  if (digest !== record.digest || (process.platform !== "win32" && (info.mode & 0o777) !== record.mode)) return incompleteDiagnosis(recovery);
450
464
  }
451
465
  const requiredAssets = [...COMMON_SETUP_ASSETS, ...requiredHarnessAssetPaths(state.harnesses, state.memory)];
452
- if (requiredAssets.some((path) => !Object.hasOwn(state.assets, path))) return incompleteDiagnosis(recovery);
466
+ const launchers = new Set(state.harnesses.map((id) => HARNESS_REGISTRY.find((harness) => harness.id === id).launcher.path));
467
+ if (requiredAssets.some((path) => !safeRequiredAsset(project, path, launchers.has(path)))) return incompleteDiagnosis(recovery);
453
468
  if (state.memory === "local" && state.harnesses.includes("codex")) {
454
469
  if (!hasCodexMemoryConfig(project)) return incompleteDiagnosis(recovery);
455
470
  if (hasCodexRemoteMemoryConflict(project)) {
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ import assert from "node:assert/strict";
3
+ import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { buildSetupPlan } from "./setup-core.mjs";
7
+ import { reconcileSetupPlan } from "./setup-reconcile.mjs";
8
+ import { diagnoseSetup } from "./setup.mjs";
9
+
10
+ const sandbox = mkdtempSync(join(tmpdir(), "cairn-setup-diagnosis-"));
11
+ try {
12
+ const project = join(sandbox, "project");
13
+ mkdirSync(project);
14
+ const plan = buildSetupPlan({
15
+ target: project,
16
+ preflight: { targetState: "empty", gitExecutable: "available", repository: "work-tree" },
17
+ choices: { git: "existing", harnesses: ["claude", "opencode", "omp"], memory: "local", confirmed: true },
18
+ });
19
+ const customPaths = [".ai/env.example", ".ai/start-claude.sh", ".ai/start-opencode.sh", ".planning/graphs/policy.md"];
20
+ for (const path of customPaths) {
21
+ const file = join(project, path);
22
+ mkdirSync(dirname(file), { recursive: true });
23
+ writeFileSync(file, path.endsWith(".sh") ? "#!/bin/sh\n# Project-owned launcher\nexit 0\n" : "Project-owned configuration\n");
24
+ chmodSync(file, path.endsWith(".sh") ? 0o755 : 0o640);
25
+ }
26
+ const before = new Map(customPaths.map(path => [path, { bytes: readFileSync(join(project, path)), mode: statSync(join(project, path)).mode, mtime: statSync(join(project, path)).mtimeMs }]));
27
+ const result = await reconcileSetupPlan(plan);
28
+ assert.equal(result.counts.skipped, customPaths.length);
29
+ for (const path of customPaths) assert.equal(Object.hasOwn(result.state.assets, path), false, "custom file became setup-owned");
30
+ const statePath = join(project, ".ai/cairnkeep.json");
31
+ const stateBytes = readFileSync(statePath);
32
+ assert.equal(diagnoseSetup(project).status, "complete", "safe preserved custom assets must not make setup incomplete");
33
+ for (const [path, saved] of before) {
34
+ assert.deepEqual(readFileSync(join(project, path)), saved.bytes);
35
+ assert.equal(statSync(join(project, path)).mode, saved.mode);
36
+ assert.equal(statSync(join(project, path)).mtimeMs, saved.mtime);
37
+ }
38
+ assert.deepEqual(readFileSync(statePath), stateBytes, "diagnosis rewrote ownership state");
39
+
40
+ for (const path of customPaths) {
41
+ const file = join(project, path), saved = `${file}.saved`;
42
+ renameSync(file, saved);
43
+ assert.equal(diagnoseSetup(project).status, "incomplete", "missing unowned asset accepted");
44
+ mkdirSync(file);
45
+ assert.equal(diagnoseSetup(project).status, "incomplete", "directory asset accepted");
46
+ rmSync(file, { recursive: true });
47
+ if (process.platform !== "win32") {
48
+ symlinkSync(saved, file);
49
+ assert.equal(diagnoseSetup(project).status, "incomplete", "symlink asset accepted");
50
+ rmSync(file);
51
+ }
52
+ renameSync(saved, file);
53
+ }
54
+ if (process.platform !== "win32") {
55
+ const launcher = join(project, ".ai/start-claude.sh");
56
+ chmodSync(launcher, 0o644);
57
+ assert.equal(diagnoseSetup(project).status, "incomplete", "non-executable custom launcher accepted");
58
+ chmodSync(launcher, 0o755);
59
+ const directory = join(project, ".planning/graphs"), saved = `${directory}.saved`;
60
+ renameSync(directory, saved);
61
+ symlinkSync(saved, directory, "dir");
62
+ assert.equal(diagnoseSetup(project).status, "incomplete", "symlinked asset ancestor accepted");
63
+ rmSync(directory);
64
+ renameSync(saved, directory);
65
+ }
66
+ const owned = join(project, ".ai/start-omp.sh"), ownedBytes = readFileSync(owned);
67
+ writeFileSync(owned, "owned drift\n");
68
+ assert.equal(diagnoseSetup(project).status, "incomplete", "owned digest drift accepted");
69
+ writeFileSync(owned, ownedBytes);
70
+ assert.equal(diagnoseSetup(project).status, "complete");
71
+
72
+ // An ownership map can legitimately be empty when every asset was preserved.
73
+ const unowned = { ...result.state, assets: {} };
74
+ writeFileSync(statePath, JSON.stringify(unowned) + "\n");
75
+ chmodSync(statePath, 0o600);
76
+ assert.equal(diagnoseSetup(project).status, "complete", "all-custom setup falsely requires owned files");
77
+ assert.deepEqual(JSON.parse(readFileSync(statePath)), unowned);
78
+ assert.equal(existsSync(join(project, ".ai/start-omp.sh")), true);
79
+
80
+ const codexProject = join(sandbox, "codex");
81
+ mkdirSync(codexProject);
82
+ const codexPlan = buildSetupPlan({
83
+ target: codexProject,
84
+ preflight: { targetState: "empty", gitExecutable: "available", repository: "work-tree" },
85
+ choices: { git: "existing", harnesses: ["codex"], memory: "local", confirmed: true },
86
+ });
87
+ const codexResult = await reconcileSetupPlan(codexPlan);
88
+ const codexState = join(codexProject, ".ai/cairnkeep.json");
89
+ writeFileSync(codexState, JSON.stringify({ ...codexResult.state, assets: {} }) + "\n");
90
+ chmodSync(codexState, 0o600);
91
+ assert.equal(diagnoseSetup(codexProject).status, "complete");
92
+ writeFileSync(join(codexProject, ".codex/config.toml"), "# Caller-owned but no memory server configured\n");
93
+ assert.equal(diagnoseSetup(codexProject).status, "incomplete", "empty ownership map bypassed Codex MCP validation");
94
+ console.log("PASS: setup diagnosis preserves custom ownership and rejects missing, unsafe, non-executable and drifted assets");
95
+ } finally {
96
+ rmSync(sandbox, { recursive: true, force: true });
97
+ }
@@ -19,6 +19,8 @@ import { runNativeContainer } from "./cairn-container-cli.mjs";
19
19
  import { HARNESS_IDS } from "./harness-registry.mjs";
20
20
 
21
21
  const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
22
+ // Runs on real Windows and POSIX, independently of shell-only doctor checks.
23
+ await import("./test-setup-diagnosis.mjs");
22
24
  const sandbox = mkdtempSync(join(tmpdir(), "cairn-windows-native-"));
23
25
  const originalBase = process.env.CAIRN_AGENTFS_BASE_DIR;
24
26
  const originalPackBase = process.env.CAIRN_PACK_BASE_DIR;