@cairnkeep/cli 2.22.0 → 2.22.2

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 +35 -0
  2. package/docs/context-packs.md +19 -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 +5 -1
  12. package/docs/learning/lessons/L22-native-windows.md +3 -2
  13. package/docs/learning/lessons/L23-guided-setup.md +1 -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 +22 -4
  19. package/docs/learning/video-scripts/V28-security-assurance.md +20 -0
  20. package/docs/releasing.md +39 -5
  21. package/docs/security-assurance.md +12 -0
  22. package/examples/eval/bundled-fake.json +1 -1
  23. package/mcp-memory-server/dist/context-pack-lock.js +48 -0
  24. package/mcp-memory-server/dist/context-pack-state.js +93 -0
  25. package/mcp-memory-server/dist/context-pack.js +16 -55
  26. package/mcp-memory-server/package.json +1 -1
  27. package/package.json +1 -1
  28. package/scripts/test-learning-docs.sh +8 -0
  29. package/scripts/verify-security-baseline.mjs +70 -9
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ All notable user-facing changes are documented here. This project follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.22.2] - 2026-10-07
9
+
10
+ ### Security
11
+
12
+ - Bind mutable private context-pack pointers, source records and graph-cache
13
+ reads to one opened descriptor. Reject replacement, non-private ownership,
14
+ symlink/special-file substitution, growth and invalid encoding under bounded
15
+ reads; close descriptors on success and failure. Update inspection uses the
16
+ same strict source-record parser as installation/listing.
17
+ - Add deterministic replacement and denial controls, including fabricated
18
+ pointer approvals, bounded parsing and descriptor cleanup. Keep schemas,
19
+ feature gates and approval policy unchanged; update recovery/security and
20
+ learning guidance. Existing security-analysis findings remain visible.
21
+
22
+ ## [2.22.1] - 2026-10-07
23
+
24
+ ### Fixed
25
+
26
+ - Handle bounded Windows context-pack pointer-lock contention during concurrent
27
+ updates, including delete-pending access errors. Preserve exclusive ownership,
28
+ unsafe-lock rejection, denied access and the existing retry budget; never
29
+ steal another writer's lock. Add deterministic fault controls and native
30
+ cross-process exclusion coverage without changing persisted data or approval.
31
+ - Align lesson npm/container installation commands with the current release;
32
+ add a regression rejecting stale pins and update Windows/context-pack recovery
33
+ guidance and learning baselines.
34
+
35
+ ### CI
36
+
37
+ - Restrict publication token permissions to the preparation, npm and container
38
+ jobs that need them. Read-only workflow defaults and parsed-YAML positive/
39
+ mutation controls reject unnecessary authority and missing provenance grants.
40
+ Update maintainer guidance and the security learning/video supplement; no
41
+ runtime package or deployed configuration change is required.
42
+
8
43
  ## [2.22.0] - 2026-10-07
9
44
 
10
45
  ### Added
@@ -59,6 +59,25 @@ 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
+
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
+
62
81
  ## Retrieval and skill approval
63
82
 
64
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.0
3
+ **Baseline:** Cairnkeep 2.22.2
4
4
  **Last reviewed:** 2026-10-07
5
5
 
6
6
  This map prevents the course from drifting behind the product. It assigns each
@@ -1,6 +1,6 @@
1
1
  # Cairnkeep feature guide
2
2
 
3
- **Baseline:** Cairnkeep 2.22.0
3
+ **Baseline:** Cairnkeep 2.22.2
4
4
 
5
5
  This is the step-back map for choosing and operating Cairnkeep features. The
6
6
  [course labs](https://github.com/cairnkeep/cairnkeep-course-labs) provide one
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 10 minutes
6
- **Tested with:** Cairnkeep 2.22.0
6
+ **Tested with:** Cairnkeep 2.22.2
7
7
 
8
8
  ## Outcome
9
9
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 15 minutes
6
- **Tested with:** Cairnkeep 2.22.0
6
+ **Tested with:** Cairnkeep 2.22.2
7
7
 
8
8
  ## Outcome
9
9
 
@@ -26,7 +26,7 @@ ephemeral; the named course volume is persistent.
26
26
  1. Pull the public image:
27
27
 
28
28
  ```bash
29
- podman pull ghcr.io/cairnkeep/cairnkeep:2.17.1
29
+ podman pull ghcr.io/cairnkeep/cairnkeep:2.22.2
30
30
  ```
31
31
 
32
32
  2. Start the stdio server with a read-only root filesystem and no Linux
@@ -38,7 +38,7 @@ ephemeral; the named course volume is persistent.
38
38
  --read-only --cap-drop=all --security-opt=no-new-privileges \
39
39
  --tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777 \
40
40
  --volume cairnkeep-course-data:/data:Z,U \
41
- ghcr.io/cairnkeep/cairnkeep:2.17.1 stdio
41
+ ghcr.io/cairnkeep/cairnkeep:2.22.2 stdio
42
42
  ```
43
43
 
44
44
  3. The process waits quietly for MCP input. Press `Ctrl-C` after confirming it
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 25 minutes
6
- **Tested with:** Cairnkeep 2.22.0 and Node.js 22 or newer
6
+ **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
7
7
 
8
8
  ## Outcome
9
9
 
@@ -47,7 +47,7 @@ sync. Codex project trust remains an explicit operator decision.
47
47
  2. Install Cairnkeep:
48
48
 
49
49
  ```bash
50
- npm install --global @cairnkeep/cli@2.17.1
50
+ npm install --global @cairnkeep/cli@2.22.2
51
51
  cairn --version
52
52
  ```
53
53
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 25 minutes
6
- **Tested with:** Cairnkeep 2.22.0
6
+ **Tested with:** Cairnkeep 2.22.2
7
7
 
8
8
  ## Outcome
9
9
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Evidence and Evaluation
5
5
  **Time:** 50 minutes
6
- **Tested with:** Cairnkeep 2.22.0
6
+ **Tested with:** Cairnkeep 2.22.2
7
7
 
8
8
  ## Outcome
9
9
 
@@ -1,7 +1,7 @@
1
1
  # L20 - Least-authority MCP tool profiles
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.0 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
5
5
  **Time:** 25 minutes
6
6
 
7
7
  ## Outcome
@@ -1,7 +1,7 @@
1
1
  # L21 - Immutable context packs and skill approval
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.0 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
5
5
  **Time:** 40 minutes
6
6
 
7
7
  ## Outcome
@@ -54,6 +54,10 @@ copies them into a harness.
54
54
  an enabled digest all fail closed.
55
55
  - Enabling the pack does not expose a skill until its current file digest is
56
56
  approved.
57
+ - A concurrent project update can exhaust the bounded pointer-lock wait. Let
58
+ the writer finish, inspect `cairn doctor`, then retry; do not delete another
59
+ writer's lock or weaken permissions. Windows contention recovery is included
60
+ in 2.22.1, with symlink/non-directory locks still rejected.
57
61
 
58
62
  ## Privacy and trust boundary
59
63
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Operator
5
5
  **Time:** 30 minutes
6
- **Tested with:** Cairnkeep 2.22.0, Windows x64, and Node.js 22 or newer
6
+ **Tested with:** Cairnkeep 2.22.2, Windows x64, and Node.js 22 or newer
7
7
 
8
8
  ## Outcome
9
9
 
@@ -16,7 +16,7 @@ perform a reversible uninstall without relying on WSL or Git Bash.
16
16
  From PowerShell:
17
17
 
18
18
  ```powershell
19
- npm install --global @cairnkeep/cli@2.17.1
19
+ npm install --global @cairnkeep/cli@2.22.2
20
20
  cairn version
21
21
 
22
22
  $Lab = Join-Path $env:TEMP 'Cairnkeep Windows Lab'
@@ -62,6 +62,7 @@ the removed managed assets.
62
62
  | Hook registration reports drift | Managed `.cmd` hook or settings entry is absent | Run `cairn sync --apply`, then repeat `cairn sync --check` |
63
63
  | Memory export fails | `sqlite3.exe` is absent | Install SQLite and confirm `Get-Command sqlite3.exe`; runtime and import remain available |
64
64
  | A private-state check fails | The file inherited a broader ACL | Preserve the file, inspect it with `Get-Acl`, and recreate it through the owning Cairnkeep command |
65
+ | A context-pack project update is locked | Another writer is active or a directory remains delete-pending | Allow the writer to finish, inspect `cairn doctor`, then retry; never blindly delete a lock or weaken ACLs |
65
66
 
66
67
  ## Privacy and trust boundary
67
68
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart and Operator
5
5
  **Time:** 35 minutes
6
- **Tested with:** Cairnkeep 2.22.0; Pi 0.84.1; Node.js 22, 24, and 26
6
+ **Tested with:** Cairnkeep 2.22.2; Pi 0.84.1; Node.js 22, 24, and 26
7
7
 
8
8
  ## Outcome
9
9
 
@@ -1,7 +1,7 @@
1
1
  # L24 - Reviewed knowledge exchange
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.0 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
5
5
  **Time:** 35 minutes
6
6
 
7
7
  ## Outcome
@@ -1,7 +1,7 @@
1
1
  # L25 - Bounded workflow playbooks
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.0 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
5
5
  **Time:** 35 minutes
6
6
 
7
7
  ## Outcome
@@ -1,7 +1,7 @@
1
1
  # L26 - Context intelligence with explicit authority
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.0 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
5
5
 
6
6
  ## Outcome
7
7
 
@@ -1,6 +1,6 @@
1
1
  # L27 - Evaluate context companions without moving authority
2
2
 
3
- **Tested with:** Cairnkeep 2.22.0 and Node.js 22 or newer
3
+ **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
4
4
 
5
5
  ## Outcome
6
6
 
@@ -1,7 +1,7 @@
1
1
  # L28 - Verify Cairnkeep's security posture
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.0 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
5
5
 
6
6
  ## Outcome
7
7
 
@@ -11,7 +11,7 @@ and choose least authority for a network-facing MCP client.
11
11
 
12
12
  ## Prerequisites
13
13
 
14
- - Install Cairnkeep 2.22.0 or use a disposable source checkout.
14
+ - Install Cairnkeep 2.22.2 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,12 +67,30 @@ 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
- The baseline rejects unpinned external Actions and `pull_request_target`. The
73
- smoke tests cover token non-disclosure, request bounds, and deterministic
73
+ The baseline rejects unpinned external Actions, `pull_request_target` and
74
+ workflow-wide write permissions. It also checks the exact preparation/npm/OCI
75
+ job permission maps, including missing provenance grants and unnecessary write
76
+ authority. Inspect `.github/workflows/publish.yml` and compare its maps with
77
+ [Releasing](../../releasing.md): preparation must not inherit publishing or
78
+ signing authority. Unspecified entries in an explicit job map are denied.
79
+
80
+ These are static contract checks, not a live publication test. The npm secret
81
+ is a separate credential, and steps within one job share its token authority.
82
+ Do not republish an immutable version to demonstrate the test.
83
+
84
+ The smoke tests cover token non-disclosure, request bounds, and deterministic
74
85
  portable-path adversarial cases.
75
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
+
76
94
  ## Common failures
77
95
 
78
96
  - A private `.ai` file is group/world accessible on POSIX: restore mode `0600`.
@@ -56,6 +56,26 @@ 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
+ 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
+
72
+ Briefly open `.github/workflows/publish.yml` beside the permission table in
73
+ `docs/releasing.md`. Contrast the read-only preparation job with the necessary
74
+ npm and OCI publication grants. Explain that explicit maps deny unspecified
75
+ permissions, but steps in a job still share authority and the npm secret is
76
+ separate. The baseline checks declared grants; do not present it as a successful
77
+ live publish or rerun a release merely for this recording.
78
+
59
79
  ### 9:20–10:30 — Read the residual risks
60
80
 
61
81
  End on `docs/security-assurance.md`, not on a green checkmark. Call out the
package/docs/releasing.md CHANGED
@@ -3,6 +3,23 @@
3
3
  Publishing is driven by a GitHub Release. Maintainers do not publish the package
4
4
  from a workstation.
5
5
 
6
+ ## Advisory Scorecard recovery
7
+
8
+ The repository's Actions policy must allow the exact pinned
9
+ `ossf/scorecard-action@<full-SHA-from-scorecard.yml>` reference. An entry ending
10
+ in `scorecard-action/*` does not allow the root action. Keep SHA pinning required;
11
+ do not enable all third-party actions to recover this one workflow.
12
+
13
+ If GitHub rejects a run before creating any jobs and refuses to retry it, fix
14
+ the repository allowlist and start a fresh advisory analysis explicitly:
15
+
16
+ ```bash
17
+ gh workflow run scorecard.yml --ref main
18
+ ```
19
+
20
+ This does not bypass required platform, CodeQL or dependency-review gates and
21
+ does not require republishing the npm package.
22
+
6
23
  ## Release contract
7
24
 
8
25
  1. Update the root `package.json` version and `CHANGELOG.md` on a pull request.
@@ -52,11 +69,28 @@ different version or tree.
52
69
 
53
70
  The repository must provide an `NPM_TOKEN` Actions secret authorized to publish
54
71
  `@cairnkeep/cli`. Keep that credential out of local files and rotate it according
55
- to the npm account's security policy. The workflow grants `actions: read`,
56
- `contents: write`, `packages: write`, `attestations: write`, and
57
- `id-token: write`. Read access resolves the successful tree-addressed CI
58
- artifact; the last two permissions are required for registry-backed build
59
- provenance.
72
+ to the npm account's security policy. Workflow defaults grant only
73
+ `contents: read`; publication authority is explicit and job-scoped:
74
+
75
+ | Job | Token permissions | Purpose |
76
+ |---|---|---|
77
+ | `prepare` | `contents: read`, `actions: read` | Check the tag and download the successful exact-tree CI candidate; no publication or signing authority |
78
+ | `npm` | `contents: write`, `id-token: write` | Attach verified release assets and request npm provenance; registry publication uses the separate `NPM_TOKEN` |
79
+ | `containers` | `contents: read`, `packages: write`, `attestations: write`, `id-token: write`, `artifact-metadata: write` | Read source, publish OCI images, sign/upload provenance and create the pinned action's registry storage record |
80
+
81
+ Each job declares its complete permission map. Unspecified token permissions
82
+ are denied rather than inherited; see [GitHub's permission semantics](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions).
83
+ The container grant retains `artifact-metadata: write` because the pinned
84
+ [attestation implementation](https://github.com/actions/attest/blob/508db95dd578ae2727ebd6217d5ba78e4fbda05d/README.md#usage)
85
+ creates a storage record. Do not remove required provenance permissions merely
86
+ to improve an advisory score.
87
+
88
+ The parsed-YAML baseline rejects workflow-wide writes, unexpected publication
89
+ jobs and any missing or excessive grant in these maps. Its positive control
90
+ and mutation tests verify the declared contract, not GitHub's execution or a
91
+ future privileged publication. Keep the required platform/security gates and
92
+ verify the next normal release's publication and attestations separately.
93
+ CI-only permission changes do not require republishing an immutable version.
60
94
 
61
95
  GitHub creates each new GHCR package as private. After the first container
62
96
  release, a package administrator must change both `cairnkeep` and
@@ -46,6 +46,8 @@ 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 |
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 |
49
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 |
50
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 |
51
53
  | 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 +107,16 @@ provenance, produces CycloneDX 1.6 and container SBOMs, and attests container
105
107
  provenance. A digest proves content integrity, not publisher identity; verify
106
108
  the repository, release tag, registry namespace, and provenance together.
107
109
 
110
+ Workflow token defaults are read-only. Publication grants are explicit per job:
111
+ candidate preparation receives only source/Actions read access, npm receives
112
+ release-attachment and provenance authority, and container publication receives
113
+ registry/signing/attestation/storage-record authority without release-write
114
+ access. See the exact maps in [Releasing](releasing.md). The security baseline
115
+ rejects excess and missing grants with positive and adversarial controls.
116
+ This reduces unnecessary token authority; it neither isolates steps within a
117
+ job nor constrains the separate npm publishing secret. Static checks do not
118
+ prove a future privileged publish run succeeds.
119
+
108
120
  ## Deliberate non-goals and residual risks
109
121
 
110
122
  - Cairnkeep is not a sandbox, endpoint-security product, secret manager, TLS
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.22.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.22.2","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
+ }
@@ -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,8 +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
+ import { acquireContextPackPointerLock } from "./context-pack-lock.js";
15
+ import { readPrivatePackJson } from "./context-pack-state.js";
14
16
  const execFileAsync = promisify(execFile);
15
17
  export const CONTEXT_PACK_MANIFEST = "context-pack.json";
16
18
  export const CONTEXT_PACK_MAX_FILE_BYTES = 1024 * 1024;
@@ -77,15 +79,9 @@ function assertUtf8(bytes, path) {
77
79
  function readPackSource(digest) {
78
80
  const directory = join(packBaseDir(), "sources");
79
81
  const path = join(directory, `${digest}.json`);
80
- if (!existsSync(path))
82
+ const value = readPrivatePackJson(path, { label: "Context pack source record", maxBytes: 64 * 1024, optional: true });
83
+ if (value === undefined)
81
84
  return undefined;
82
- const directoryInfo = lstatSync(directory);
83
- const info = lstatSync(path);
84
- if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink() || !info.isFile() || info.isSymbolicLink()
85
- || info.size > 64 * 1024 || !privatePathIsSafe(path)) {
86
- throw new Error(`Context pack source record is unsafe: ${digest}`);
87
- }
88
- const value = JSON.parse(readFileSync(path, "utf8"));
89
85
  if (!value || typeof value !== "object" || Array.isArray(value))
90
86
  throw new Error(`Context pack source record is invalid: ${digest}`);
91
87
  const record = value;
@@ -603,44 +599,13 @@ function parseProjectPointer(value, expectedId) {
603
599
  }
604
600
  export function readProjectPointer(options) {
605
601
  const identity = projectIdentity(options);
606
- if (!existsSync(identity.path))
607
- return emptyPointer(identity.id);
608
- const info = lstatSync(identity.path);
609
- if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024
610
- || !privatePathIsSafe(identity.path))
611
- throw new Error("Context pack project pointer is unsafe.");
612
- return parseProjectPointer(JSON.parse(readFileSync(identity.path, "utf8")), identity.id);
613
- }
614
- function delay(milliseconds) {
615
- return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
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);
616
604
  }
617
605
  async function acquirePointerLock(options) {
618
606
  const identity = projectIdentity(options);
619
607
  await mkdir(dirname(identity.path), { recursive: true, mode: 0o700 });
620
- const lock = `${identity.path}.lock`;
621
- for (let attempt = 0; attempt < 200; attempt += 1) {
622
- try {
623
- await mkdir(lock, { mode: 0o700 });
624
- return () => rm(lock, { recursive: true, force: true });
625
- }
626
- catch (error) {
627
- if (error.code !== "EEXIST")
628
- throw error;
629
- let info;
630
- try {
631
- info = lstatSync(lock);
632
- }
633
- catch (inspectionError) {
634
- if (inspectionError.code === "ENOENT")
635
- continue;
636
- throw inspectionError;
637
- }
638
- if (!info.isDirectory() || info.isSymbolicLink())
639
- throw new Error("Context pack project pointer lock is unsafe.");
640
- await delay(10);
641
- }
642
- }
643
- throw new Error("Context pack project pointer is locked; retry after the active update finishes.");
608
+ return acquireContextPackPointerLock(`${identity.path}.lock`);
644
609
  }
645
610
  async function mutateProjectPointer(options, mutate) {
646
611
  const release = await acquirePointerLock(options);
@@ -684,15 +649,10 @@ async function allPointers() {
684
649
  throw new Error("Context pack project pointer directory is unsafe.");
685
650
  const result = [];
686
651
  for (const entry of await readdir(directory, { withFileTypes: true })) {
687
- if (!entry.isFile() || !entry.name.endsWith(".json"))
652
+ if (!entry.name.endsWith(".json"))
688
653
  continue;
689
654
  const path = join(directory, entry.name);
690
- const info = lstatSync(path);
691
- if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024
692
- || !privatePathIsSafe(path)) {
693
- throw new Error(`Context pack project pointer is unsafe: ${path}`);
694
- }
695
- const raw = JSON.parse(await readFile(path, "utf8"));
655
+ const raw = readPrivatePackJson(path, { label: "Context pack project pointer", maxBytes: 1024 * 1024 });
696
656
  const value = raw;
697
657
  if (typeof value.project_id !== "string")
698
658
  throw new Error("Context pack project pointer is invalid.");
@@ -1174,14 +1134,14 @@ export async function doctorContextPacks(options = {}) {
1174
1134
  for (const entry of await readdir(graphDirectory, { withFileTypes: true })) {
1175
1135
  const match = /^([a-f0-9]{64})\.json$/.exec(entry.name);
1176
1136
  const path = join(graphDirectory, entry.name);
1177
- if (!entry.isFile() || entry.isSymbolicLink() || !match || lstatSync(path).size > CONTEXT_PACK_MAX_FILE_BYTES || !privatePathIsSafe(path)) {
1137
+ if (!entry.isFile() || entry.isSymbolicLink() || !match) {
1178
1138
  issues.push(`Invalid context pack graph cache entry: ${entry.name}`);
1179
1139
  continue;
1180
1140
  }
1181
1141
  if (!existsSync(objectRoot(match[1])))
1182
1142
  issues.push(`Orphaned context pack graph cache: ${entry.name}`);
1183
1143
  try {
1184
- const cached = JSON.parse(await readFile(path, "utf8"));
1144
+ const cached = readPrivatePackJson(path, { label: "Context pack graph cache", maxBytes: CONTEXT_PACK_MAX_FILE_BYTES });
1185
1145
  if (cached.schema_version !== 1 || !Array.isArray(cached.files) || !Array.isArray(cached.diagnostics))
1186
1146
  throw new Error("invalid graph cache schema");
1187
1147
  const expected = expectedGraphs.get(match[1]);
@@ -1237,8 +1197,9 @@ export async function inspectContextPackUpdate(selector, options) {
1237
1197
  const enabled = pointer.enabled.find((entry) => entry.id === selector || entry.digest === selector || entry.digest.startsWith(selector));
1238
1198
  if (!enabled)
1239
1199
  throw new Error("Context pack is not enabled for this project.");
1240
- const sourceRecord = JSON.parse(await readFile(join(packBaseDir(), "sources", `${enabled.digest}.json`), "utf8"));
1241
- const source = sourceRecord.source;
1200
+ const source = readPackSource(enabled.digest);
1201
+ if (!source)
1202
+ throw new Error("Context pack source record is missing.");
1242
1203
  const current = await validateContextPack(objectRoot(enabled.digest));
1243
1204
  const materialized = await materializeSource(source.kind === "local" ? source.path : source.url, source.kind === "git" ? source.ref : undefined);
1244
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.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.0",
3
+ "version": "2.22.2",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -74,6 +74,7 @@ done < <(bin/cairn help | sed -n 's/^ cairn \([a-z-]*\).*/\1/p' | sort -u)
74
74
  node <<'NODE'
75
75
  const { existsSync, readdirSync, readFileSync } = require("node:fs");
76
76
  const { dirname, resolve } = require("node:path");
77
+ const packageVersion = JSON.parse(readFileSync("package.json", "utf8")).version;
77
78
 
78
79
  function markdownFiles(root) {
79
80
  return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
@@ -84,6 +85,13 @@ function markdownFiles(root) {
84
85
 
85
86
  for (const file of markdownFiles("docs/learning")) {
86
87
  const text = readFileSync(file, "utf8");
88
+ if (file.startsWith("docs/learning/lessons/")) {
89
+ for (const match of text.matchAll(/(?:@cairnkeep\/cli@|ghcr\.io\/cairnkeep\/cairnkeep(?:-workspace)?:)(\d+\.\d+\.\d+(?:-[\w.-]+)?)/g)) {
90
+ if (match[1] !== packageVersion) {
91
+ throw new Error(`stale installation command in ${file}: ${match[1]} instead of ${packageVersion}`);
92
+ }
93
+ }
94
+ }
87
95
  for (const match of text.matchAll(/\]\(([^)]+)\)/g)) {
88
96
  const target = match[1].split("#", 1)[0];
89
97
  if (!target || /^[a-z]+:/i.test(target)) continue;
@@ -35,6 +35,10 @@ function walk(value, visit, path = "workflow") {
35
35
 
36
36
  function validateWorkflow(name, workflow) {
37
37
  assert.ok(workflow && typeof workflow === "object" && !Array.isArray(workflow), `${name} is not a workflow mapping`);
38
+ assert.ok(workflow.permissions && typeof workflow.permissions === "object" && !Array.isArray(workflow.permissions), `${name} must have an explicit workflow permission mapping`);
39
+ for (const [permission, level] of Object.entries(workflow.permissions)) {
40
+ assert.ok(["read", "none"].includes(level), `${name} has a non-read-only workflow permission: ${permission}`);
41
+ }
38
42
  const triggers = workflow.on;
39
43
  const triggerNames = typeof triggers === "string" ? [triggers] : Array.isArray(triggers) ? triggers : Object.keys(triggers ?? {});
40
44
  assert.ok(!triggerNames.includes("pull_request_target"), `${name} uses pull_request_target`);
@@ -51,24 +55,81 @@ function validateWorkflow(name, workflow) {
51
55
  });
52
56
  }
53
57
 
54
- for (const { name, workflow } of sources) validateWorkflow(name, workflow);
58
+ const PUBLICATION_PERMISSIONS = {
59
+ prepare: { actions: "read", contents: "read" },
60
+ npm: { contents: "write", "id-token": "write" },
61
+ containers: {
62
+ contents: "read",
63
+ packages: "write",
64
+ attestations: "write",
65
+ "id-token": "write",
66
+ "artifact-metadata": "write",
67
+ },
68
+ };
69
+
70
+ function validatePublicationPermissions(workflow) {
71
+ validateWorkflow("publish.yml", workflow);
72
+ assert.deepEqual(workflow.permissions, { contents: "read" }, "publication workflow default must be contents-read only");
73
+ assert.deepEqual(Object.keys(workflow.jobs).sort(), Object.keys(PUBLICATION_PERMISSIONS).sort(), "publication jobs require explicit permission review");
74
+ for (const [job, permissions] of Object.entries(PUBLICATION_PERMISSIONS)) {
75
+ assert.deepEqual(workflow.jobs[job].permissions, permissions, `publication ${job} permissions must match its reviewed tasks`);
76
+ }
77
+ }
78
+
79
+ const publish = sources.find(({ name }) => name === "publish.yml")?.workflow;
80
+ assert.ok(publish, "publication workflow is missing");
81
+ const permissionControl = structuredClone(publish);
82
+ permissionControl.permissions = { contents: "read" };
83
+ for (const [job, permissions] of Object.entries(PUBLICATION_PERMISSIONS)) {
84
+ permissionControl.jobs[job].permissions = structuredClone(permissions);
85
+ }
86
+ validatePublicationPermissions(permissionControl);
87
+
88
+ let permissionFixtures = 0;
89
+ function rejectPermissionMutation(mutate, expected) {
90
+ const fixture = structuredClone(permissionControl);
91
+ mutate(fixture);
92
+ assert.throws(() => validatePublicationPermissions(fixture), expected, "unsafe publication permission mutation was accepted");
93
+ permissionFixtures += 1;
94
+ }
95
+ for (const permission of ["actions", "contents", "id-token", "packages", "attestations", "artifact-metadata"]) {
96
+ rejectPermissionMutation((fixture) => { fixture.permissions[permission] = "write"; }, /non-read-only workflow permission/);
97
+ }
98
+ rejectPermissionMutation((fixture) => { delete fixture.permissions; }, /explicit workflow permission mapping/);
99
+ rejectPermissionMutation((fixture) => { fixture.permissions = "write-all"; }, /explicit workflow permission mapping/);
100
+ rejectPermissionMutation((fixture) => { fixture.jobs.extra = { permissions: { contents: "read" } }; }, /explicit permission review/);
101
+ for (const [job, permissions] of Object.entries(PUBLICATION_PERMISSIONS)) {
102
+ rejectPermissionMutation((fixture) => { delete fixture.jobs[job].permissions; }, /permissions must match/);
103
+ rejectPermissionMutation((fixture) => { fixture.jobs[job].permissions = "write-all"; }, /broad scalar permissions/);
104
+ for (const permission of Object.keys(permissions)) {
105
+ rejectPermissionMutation((fixture) => { delete fixture.jobs[job].permissions[permission]; }, /permissions must match/);
106
+ }
107
+ for (const permission of ["actions", "contents", "id-token", "packages", "attestations", "artifact-metadata"]) {
108
+ if (permissions[permission] === "write") continue;
109
+ rejectPermissionMutation((fixture) => { fixture.jobs[job].permissions[permission] = "write"; }, /permissions must match/);
110
+ }
111
+ }
55
112
 
56
113
  const maliciousFixtures = [
57
- "name: bad\non: push\npermissions: write-all\njobs: {}\n",
58
- "name: bad\non: pull_request_target\njobs: {}\n",
59
- "name: bad\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: >-\n actions/checkout@v7\n",
60
- "name: bad\non: push\njobs:\n call:\n uses: 'owner/repo/.github/workflows/reuse.yml@main'\n",
61
- `name: bad\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: evil/action@${"a".repeat(40)}\n`,
114
+ ["name: bad\non: push\npermissions: write-all\njobs: {}\n", /explicit workflow permission mapping/],
115
+ ["name: bad\non: pull_request_target\npermissions: {contents: read}\njobs: {}\n", /uses pull_request_target/],
116
+ ["name: bad\non: push\npermissions: {contents: read}\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: >-\n actions/checkout@v7\n", /not pinned to a full commit/],
117
+ ["name: bad\non: push\npermissions: {contents: read}\njobs:\n call:\n uses: 'owner/repo/.github/workflows/reuse.yml@main'\n", /not pinned to a full commit/],
118
+ [`name: bad\non: push\npermissions: {contents: read}\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: evil/action@${"a".repeat(40)}\n`, /not on the maintained allowlist/],
62
119
  ];
63
- for (const [index, fixture] of maliciousFixtures.entries()) {
64
- assert.throws(() => validateWorkflow(`malicious-${index}.yml`, parse(fixture)), undefined, `malicious workflow fixture ${index} was accepted`);
120
+ for (const [index, [fixture, expected]] of maliciousFixtures.entries()) {
121
+ assert.throws(() => validateWorkflow(`malicious-${index}.yml`, parse(fixture)), expected, `malicious workflow fixture ${index} was accepted`);
65
122
  }
66
123
 
124
+ for (const { name, workflow } of sources) validateWorkflow(name, workflow);
125
+ validatePublicationPermissions(publish);
126
+
67
127
  const security = sources.find(({ name }) => name === "security.yml")?.text ?? "";
68
128
  assert.match(security, /github\/codeql-action\/init@[a-f0-9]{40}/);
69
129
  assert.match(security, /actions\/dependency-review-action@[a-f0-9]{40}/);
70
130
  assert.match(security, /queries:\s*security-extended/);
71
131
  const scorecard = sources.find(({ name }) => name === "scorecard.yml")?.text ?? "";
132
+ assert.ok(Object.hasOwn(parse(scorecard).on, "workflow_dispatch"), "Scorecard must support manual recovery without a source push");
72
133
  assert.match(scorecard, /ossf\/scorecard-action@[a-f0-9]{40}/);
73
134
  assert.match(scorecard, /publish_results:\s*true/);
74
135
 
@@ -76,4 +137,4 @@ const rootPackage = JSON.parse(readFileSync(join(root, "package.json"), "utf8"))
76
137
  assert.match(rootPackage.scripts["audit:runtime"] ?? "", /npm audit --omit=dev/);
77
138
  assert.match(rootPackage.scripts["check:public"] ?? "", /security:baseline/);
78
139
 
79
- console.log(`PASS: ${workflowFiles.length} workflows and ${maliciousFixtures.length} adversarial fixtures enforce parsed YAML, allowlisted full-SHA actions, bounded triggers, and required security controls`);
140
+ console.log(`PASS: ${workflowFiles.length} workflows, ${maliciousFixtures.length} action/trigger fixtures and ${permissionFixtures} permission mutations enforce parsed YAML, full-SHA actions, read-only defaults and job-scoped publication authority`);