@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.
- package/CHANGELOG.md +35 -0
- package/docs/context-packs.md +19 -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 +22 -4
- package/docs/learning/video-scripts/V28-security-assurance.md +20 -0
- package/docs/releasing.md +39 -5
- package/docs/security-assurance.md +12 -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-state.js +93 -0
- package/mcp-memory-server/dist/context-pack.js +16 -55
- 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,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
|
package/docs/context-packs.md
CHANGED
|
@@ -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
|
|
@@ -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.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.
|
|
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.
|
|
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.
|
|
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.
|
|
50
|
+
npm install --global @cairnkeep/cli@2.22.2
|
|
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.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.
|
|
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.
|
|
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
|
|
|
@@ -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.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.
|
|
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
|
|
73
|
-
|
|
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.
|
|
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,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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
607
|
-
|
|
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
|
-
|
|
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.
|
|
652
|
+
if (!entry.name.endsWith(".json"))
|
|
688
653
|
continue;
|
|
689
654
|
const path = join(directory, entry.name);
|
|
690
|
-
const
|
|
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
|
|
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 =
|
|
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
|
|
1241
|
-
|
|
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
|
@@ -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`);
|