@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.
- package/CHANGELOG.md +25 -0
- package/docs/context-packs.md +9 -0
- package/docs/learning/CURRICULUM-MAP.md +1 -1
- package/docs/learning/FEATURE-GUIDE.md +1 -1
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +8 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +1 -1
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +10 -2
- package/docs/learning/video-scripts/V28-security-assurance.md +7 -0
- package/docs/operating.md +9 -1
- package/docs/security-assurance.md +1 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/context-pack-state.js +93 -0
- package/mcp-memory-server/dist/context-pack.js +14 -28
- package/mcp-memory-server/package.json +1 -1
- package/package.json +1 -1
- package/scripts/setup.mjs +18 -3
- package/scripts/test-setup-diagnosis.mjs +97 -0
- 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
|
package/docs/context-packs.md
CHANGED
|
@@ -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
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Status:** Ready
|
|
4
4
|
**Track:** Quickstart
|
|
5
5
|
**Time:** 15 minutes
|
|
6
|
-
**Tested with:** Cairnkeep 2.22.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:** Operator
|
|
5
5
|
**Time:** 30 minutes
|
|
6
|
-
**Tested with:** Cairnkeep 2.22.
|
|
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.
|
|
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.
|
|
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
|
# L28 - Verify Cairnkeep's security posture
|
|
2
2
|
|
|
3
3
|
**Status:** Ready
|
|
4
|
-
**Tested with:** Cairnkeep 2.22.
|
|
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.
|
|
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
|
|
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
|
+
{"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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
608
|
-
|
|
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.
|
|
652
|
+
if (!entry.name.endsWith(".json"))
|
|
663
653
|
continue;
|
|
664
654
|
const path = join(directory, entry.name);
|
|
665
|
-
const
|
|
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
|
|
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 =
|
|
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
|
|
1216
|
-
|
|
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
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
|
-
|
|
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;
|