@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 +21 -0
- package/docs/context-packs.md +10 -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 +5 -1
- package/docs/learning/lessons/L22-native-windows.md +3 -2
- package/docs/learning/lessons/L23-guided-setup.md +1 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +1 -1
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +14 -4
- package/docs/learning/video-scripts/V28-security-assurance.md +13 -0
- package/docs/releasing.md +39 -5
- package/docs/security-assurance.md +11 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/context-pack-lock.js +48 -0
- package/mcp-memory-server/dist/context-pack.js +2 -27
- package/mcp-memory-server/package.json +1 -1
- package/package.json +1 -1
- package/scripts/test-learning-docs.sh +8 -0
- package/scripts/verify-security-baseline.mjs +70 -9
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
|
package/docs/context-packs.md
CHANGED
|
@@ -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
|
|
@@ -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.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.
|
|
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.
|
|
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.
|
|
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.
|
|
50
|
+
npm install --global @cairnkeep/cli@2.22.1
|
|
51
51
|
cairn --version
|
|
52
52
|
```
|
|
53
53
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# L21 - Immutable context packs and skill approval
|
|
2
2
|
|
|
3
3
|
**Status:** Ready
|
|
4
|
-
**Tested with:** Cairnkeep 2.22.
|
|
4
|
+
**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.
|
|
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.
|
|
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
|
|
|
@@ -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.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.
|
|
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
|
|
73
|
-
|
|
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.
|
|
56
|
-
`contents:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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)),
|
|
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
|
|
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`);
|