@cairnkeep/cli 2.20.1 → 2.21.0
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/README.md +6 -3
- package/SECURITY.md +13 -0
- package/containers/entrypoint.sh +2 -1
- package/docs/README.md +2 -0
- package/docs/learning/CURRICULUM-MAP.md +4 -2
- package/docs/learning/FEATURE-GUIDE.md +6 -1
- package/docs/learning/README.md +1 -0
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +1 -1
- package/docs/learning/lessons/L02-installation.md +1 -1
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +1 -1
- 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 +3 -0
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +104 -0
- package/docs/learning/video-scripts/V28-security-assurance.md +72 -0
- package/docs/operating.md +40 -5
- package/docs/privacy-and-data-flow.md +14 -0
- package/docs/releasing.md +9 -1
- package/docs/security-assurance.md +122 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/artifact-schema.js +2 -7
- package/mcp-memory-server/dist/context-pack.js +152 -31
- package/mcp-memory-server/dist/eval-schema.js +13 -12
- package/mcp-memory-server/dist/http-security.js +104 -0
- package/mcp-memory-server/dist/index.js +50 -12
- package/mcp-memory-server/dist/okf.js +35 -9
- package/mcp-memory-server/dist/path-security.js +49 -0
- package/mcp-memory-server/dist/platform-security.js +154 -31
- package/mcp-memory-server/dist/project-environment.js +132 -0
- package/mcp-memory-server/dist/security-cli.js +46 -0
- package/mcp-memory-server/dist/security-doctor.js +211 -0
- package/mcp-memory-server/package.json +2 -1
- package/package.json +3 -2
- package/scripts/bootstrap.sh +1 -0
- package/scripts/cairn-cli.mjs +2 -0
- package/scripts/completion.sh +9 -3
- package/scripts/setup-reconcile.mjs +3 -0
- package/scripts/test-bootstrap-untracked.sh +2 -0
- package/scripts/test-cli-dispatch.sh +16 -1
- package/scripts/test-completion.sh +3 -0
- package/scripts/test-container-runner.sh +7 -0
- package/scripts/test-launcher-env-precedence.sh +30 -0
- package/scripts/test-learning-docs.sh +19 -0
- package/scripts/test-security-baseline.sh +4 -0
- package/scripts/verify-security-baseline.mjs +79 -0
- package/scripts/windows-platform.mjs +4 -2
- package/templates/start-claude.sh.template +3 -0
- package/templates/start-codex.sh.template +3 -0
- package/templates/start-kimi.sh.template +3 -0
- package/templates/start-omp.sh.template +3 -0
- package/templates/start-opencode.sh.template +3 -0
- package/templates/start-pi.sh.template +3 -0
- package/templates/start-qwen.sh.template +3 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Security assurance and threat model
|
|
2
|
+
|
|
3
|
+
This document describes Cairnkeep's security boundaries, the controls that
|
|
4
|
+
enforce them, and the risks that remain. It is an engineering contract, not a
|
|
5
|
+
certification. Report suspected vulnerabilities through the private process in
|
|
6
|
+
[SECURITY.md](../SECURITY.md).
|
|
7
|
+
|
|
8
|
+
## Security objectives
|
|
9
|
+
|
|
10
|
+
Cairnkeep aims to:
|
|
11
|
+
|
|
12
|
+
- keep project memory, local evidence, credentials, and approvals scoped to the
|
|
13
|
+
intended project and operator;
|
|
14
|
+
- make network exposure explicit, authenticated, bounded, and closed by
|
|
15
|
+
default;
|
|
16
|
+
- prevent persisted portable paths from changing meaning across POSIX and
|
|
17
|
+
Windows;
|
|
18
|
+
- separate read-only observation from mutation and require explicit enablement
|
|
19
|
+
for optional authority;
|
|
20
|
+
- make releases traceable to one reviewed Git tree with pinned build actions,
|
|
21
|
+
dependency checks, an SBOM, and provenance.
|
|
22
|
+
|
|
23
|
+
Availability of the host, secrecy after an operating-system account compromise,
|
|
24
|
+
and correctness of an external model or harness are outside those objectives.
|
|
25
|
+
|
|
26
|
+
## Assets and trust boundaries
|
|
27
|
+
|
|
28
|
+
| Asset | Boundary | Principal controls |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| Project memory and local evidence | Project root and `.agentfs/` | Canonical project identity, contained paths, private files, scoped stores |
|
|
31
|
+
| Credentials and endpoint configuration | Process environment and `.ai/.env` | Local-only defaults, private-file checks, redacted diagnostics |
|
|
32
|
+
| MCP mutation authority | Effective tool catalog | Complete tool annotations, capability gates, read-only/custom profiles |
|
|
33
|
+
| Remote MCP transport | Authenticated HTTP listener | Fail-closed bearer auth, Host allowlist, opt-in CORS, body/header/time limits |
|
|
34
|
+
| Context-pack and skill content | Immutable digest-pinned objects | Portable path validation, content digests, explicit enablement and skill approval |
|
|
35
|
+
| Release artifacts | Reviewed Git tree and GitHub Actions | SHA-pinned actions, dependency review, CodeQL, SBOM, checksums and provenance |
|
|
36
|
+
|
|
37
|
+
The local stdio server trusts the account that launches it and the selected
|
|
38
|
+
project directory. An authenticated HTTP deployment is one trusted storage
|
|
39
|
+
domain: project-routing headers select a store but are not a user ACL. Harness
|
|
40
|
+
plugins and external retrieval/model services are separate principals and must
|
|
41
|
+
be enabled and evaluated independently.
|
|
42
|
+
|
|
43
|
+
## Threats and mitigations
|
|
44
|
+
|
|
45
|
+
| Threat | Mitigation and verification | Residual risk |
|
|
46
|
+
|---|---|---|
|
|
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
|
+
| 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
|
+
| 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
|
+
| 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
|
+
| 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 |
|
|
52
|
+
| Credential leakage through diagnostics | Security diagnostics report only presence, strength, file safety, and transport posture; tests assert token values never appear | A compromised process or account can read its own environment and files |
|
|
53
|
+
| Dependency or CI compromise | Production audits, dependency review, CodeQL, advisory Scorecard, selected Actions, mandatory full-SHA pins, release-candidate checks, SBOMs and attestations | These controls reduce risk but do not prove dependency or publisher authenticity |
|
|
54
|
+
|
|
55
|
+
## Local posture check
|
|
56
|
+
|
|
57
|
+
Run the read-only check from a configured project:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
cairn security doctor --project .
|
|
61
|
+
cairn security doctor --project . --json
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The command inspects project-root canonicalization and its immediate replacement
|
|
65
|
+
boundary, private managed files, the
|
|
66
|
+
effective MCP profile, HTTP token strength and token-file permissions, Host
|
|
67
|
+
allowlisting, least authority, and remote-transport encryption. It reads token
|
|
68
|
+
length, repetition, and header-safe syntax only; it never emits token content. `FAIL` makes the
|
|
69
|
+
command exit non-zero. `WARN` is an explicit risk decision and does not fail;
|
|
70
|
+
`SKIP` means the corresponding optional surface is disabled or absent.
|
|
71
|
+
|
|
72
|
+
HTTP Bearer values must use the RFC-compatible ASCII token alphabet
|
|
73
|
+
(`A-Z`, `a-z`, digits, `-._~+/`, with optional trailing `=`). Controls,
|
|
74
|
+
whitespace, Unicode, and multiline values fail closed before the listener starts.
|
|
75
|
+
|
|
76
|
+
The selected project's `.ai/.env` is parsed as literal assignments without
|
|
77
|
+
execution through a descriptor-bound 64 KiB read; ambient variables override
|
|
78
|
+
file values, and the generated launchers preserve that precedence. Relative
|
|
79
|
+
token-file paths are resolved from the selected project.
|
|
80
|
+
Symlinked `.ai` directories, replaceable project roots, unsafe private files,
|
|
81
|
+
and ambiguous shell expressions fail closed. POSIX checks require trusted
|
|
82
|
+
ownership as well as restrictive mode bits; an unquoted value is limited to a
|
|
83
|
+
small non-shell metacharacter alphabet, so quote values containing spaces or
|
|
84
|
+
punctuation.
|
|
85
|
+
|
|
86
|
+
`cairn doctor` remains the operational dependency/store check. Run both before
|
|
87
|
+
exposing HTTP, after changing tool authority, and before a security-sensitive
|
|
88
|
+
release.
|
|
89
|
+
|
|
90
|
+
## Repository and release controls
|
|
91
|
+
|
|
92
|
+
The public repository enables dependency vulnerability alerts and automated
|
|
93
|
+
security updates, secret scanning with push protection, selected GitHub
|
|
94
|
+
Actions, and required full-length action SHA pins. Pull requests run CodeQL and
|
|
95
|
+
dependency review. OpenSSF Scorecard runs on the default branch as advisory
|
|
96
|
+
evidence; it is not an authorization oracle.
|
|
97
|
+
|
|
98
|
+
The release workflow consumes the already-tested candidate for the exact Git
|
|
99
|
+
tree. It verifies the candidate manifest and checksums, publishes npm with
|
|
100
|
+
provenance, produces CycloneDX 1.6 and container SBOMs, and attests container
|
|
101
|
+
provenance. A digest proves content integrity, not publisher identity; verify
|
|
102
|
+
the repository, release tag, registry namespace, and provenance together.
|
|
103
|
+
|
|
104
|
+
## Deliberate non-goals and residual risks
|
|
105
|
+
|
|
106
|
+
- Cairnkeep is not a sandbox, endpoint-security product, secret manager, TLS
|
|
107
|
+
terminator, or multi-user authorization service.
|
|
108
|
+
- It cannot make a malicious harness, model, plugin, repository, or retrieved
|
|
109
|
+
document trustworthy.
|
|
110
|
+
- Local databases are protected by operating-system permissions, not
|
|
111
|
+
application-level encryption.
|
|
112
|
+
- Same-account processes and host administrators remain trusted.
|
|
113
|
+
- Security checks are point-in-time evidence. Re-run them after configuration,
|
|
114
|
+
dependency, network, or filesystem changes.
|
|
115
|
+
|
|
116
|
+
## Verification evidence
|
|
117
|
+
|
|
118
|
+
The maintained gates are `npm run security:baseline`, both production
|
|
119
|
+
`npm audit` checks, `scripts/test-security-baseline.sh`, the memory-server
|
|
120
|
+
security-assurance and HTTP-guard smoke tests, native-Windows tests, and the
|
|
121
|
+
complete public test matrix. The baseline gate also rejects
|
|
122
|
+
`pull_request_target` and every unpinned non-local GitHub Action.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"identifier":"cairn-offline-fake-v1","package_version":"2.
|
|
1
|
+
{"identifier":"cairn-offline-fake-v1","package_version":"2.21.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { isAbsolute } from "node:path";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
import { nodePathSchema } from "./node-schema.js";
|
|
4
|
+
import { portableRelativePathIssue } from "./path-security.js";
|
|
5
5
|
export const ARTIFACT_SCHEMA_VERSION = 1;
|
|
6
6
|
export const ARTIFACT_KINDS = [
|
|
7
7
|
"compaction_summary",
|
|
@@ -63,13 +63,8 @@ export const testOutputContentSchema = z.object({
|
|
|
63
63
|
status: z.enum(["passed", "failed", "unknown"]).optional(),
|
|
64
64
|
}).strict();
|
|
65
65
|
export const generatedFilePathLabelSchema = z.string().min(1).max(1024).superRefine((value, context) => {
|
|
66
|
-
if (
|
|
66
|
+
if (portableRelativePathIssue(value)) {
|
|
67
67
|
context.addIssue({ code: "custom", message: "Generated-file path label must be project-relative." });
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
const segments = value.split("/");
|
|
71
|
-
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
72
|
-
context.addIssue({ code: "custom", message: "Generated-file path label contains an invalid segment." });
|
|
73
68
|
}
|
|
74
69
|
});
|
|
75
70
|
export const generatedFileInputContentSchema = z.object({
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
-
import { chmod,
|
|
3
|
+
import { constants, existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
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";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
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
|
-
import { indexOkfBundle, validateOkfBundle } from "./okf.js";
|
|
11
|
+
import { indexOkfBundle, validateOkfBundle, validatedOkfFileContent } from "./okf.js";
|
|
12
12
|
import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
|
|
13
|
+
import { portablePathCollisionKey, portableRelativePathIssue } from "./path-security.js";
|
|
13
14
|
const execFileAsync = promisify(execFile);
|
|
14
15
|
export const CONTEXT_PACK_MANIFEST = "context-pack.json";
|
|
15
16
|
export const CONTEXT_PACK_MAX_FILE_BYTES = 1024 * 1024;
|
|
@@ -41,6 +42,7 @@ export const contextPackManifestSchema = z.object({
|
|
|
41
42
|
}).strict().optional(),
|
|
42
43
|
files: z.array(contextPackFileSchema).max(CONTEXT_PACK_MAX_ENTRIES),
|
|
43
44
|
}).strict();
|
|
45
|
+
const validatedPackContent = new WeakMap();
|
|
44
46
|
function sha256(...parts) {
|
|
45
47
|
const hash = createHash("sha256");
|
|
46
48
|
for (const part of parts)
|
|
@@ -57,7 +59,7 @@ function objectRoot(digest) {
|
|
|
57
59
|
return join(packBaseDir(), "objects", digest);
|
|
58
60
|
}
|
|
59
61
|
function normalizePackPath(value) {
|
|
60
|
-
if (
|
|
62
|
+
if (portableRelativePathIssue(value)) {
|
|
61
63
|
throw new Error(`Unsafe context pack path: ${value}`);
|
|
62
64
|
}
|
|
63
65
|
return value;
|
|
@@ -116,18 +118,43 @@ async function walkFiles(root, directory = root) {
|
|
|
116
118
|
}
|
|
117
119
|
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
|
|
118
120
|
const absolute = join(directory, entry.name);
|
|
119
|
-
const
|
|
121
|
+
const rawPath = relative(root, absolute).split(sep).join("/");
|
|
122
|
+
const canonicalPath = rawPath.normalize("NFC");
|
|
120
123
|
if (entry.isSymbolicLink())
|
|
121
|
-
throw new Error(`Context packs may not contain symlinks: ${
|
|
124
|
+
throw new Error(`Context packs may not contain symlinks: ${canonicalPath}`);
|
|
122
125
|
if (entry.isDirectory())
|
|
123
126
|
result.push(...await walkFiles(root, absolute));
|
|
124
127
|
else if (entry.isFile())
|
|
125
|
-
result.push(
|
|
128
|
+
result.push({ raw_path: rawPath, canonical_path: canonicalPath });
|
|
126
129
|
else
|
|
127
|
-
throw new Error(`Context packs may contain regular files only: ${
|
|
130
|
+
throw new Error(`Context packs may contain regular files only: ${canonicalPath}`);
|
|
128
131
|
}
|
|
129
132
|
return result;
|
|
130
133
|
}
|
|
134
|
+
async function physicalPackInventory(root) {
|
|
135
|
+
const files = await walkFiles(root);
|
|
136
|
+
if (files.length > CONTEXT_PACK_MAX_ENTRIES + 1)
|
|
137
|
+
throw new Error("Context pack exceeds the entry limit.");
|
|
138
|
+
const byCanonical = new Map();
|
|
139
|
+
const portable = new Map();
|
|
140
|
+
for (const physical of files) {
|
|
141
|
+
if (portableRelativePathIssue(physical.canonical_path))
|
|
142
|
+
throw new Error(`Unsafe context pack path: ${physical.canonical_path}`);
|
|
143
|
+
const portableKey = portablePathCollisionKey(physical.canonical_path);
|
|
144
|
+
const previous = portable.get(portableKey);
|
|
145
|
+
if (previous)
|
|
146
|
+
throw new Error(`Duplicate portable context pack path: ${previous}, ${physical.raw_path}`);
|
|
147
|
+
portable.set(portableKey, physical.raw_path);
|
|
148
|
+
byCanonical.set(physical.canonical_path, physical);
|
|
149
|
+
}
|
|
150
|
+
return { files, by_canonical: byCanonical };
|
|
151
|
+
}
|
|
152
|
+
function physicalPath(root, file) {
|
|
153
|
+
const absolute = resolve(root, ...file.raw_path.split("/"));
|
|
154
|
+
if (!contained(root, absolute))
|
|
155
|
+
throw new Error(`Unsafe context pack path: ${file.canonical_path}`);
|
|
156
|
+
return absolute;
|
|
157
|
+
}
|
|
131
158
|
export async function validateContextPack(directory) {
|
|
132
159
|
const requestedRoot = resolve(directory);
|
|
133
160
|
const requestedInfo = lstatSync(requestedRoot);
|
|
@@ -140,28 +167,32 @@ export async function validateContextPack(directory) {
|
|
|
140
167
|
if (!rootInfo.isDirectory() || lstatSync(root).isSymbolicLink())
|
|
141
168
|
throw new Error("Context pack source must be a real directory.");
|
|
142
169
|
const manifestPath = join(root, CONTEXT_PACK_MANIFEST);
|
|
143
|
-
const
|
|
144
|
-
if (!manifestInfo.isFile() || manifestInfo.isSymbolicLink() || manifestInfo.size > CONTEXT_PACK_MAX_FILE_BYTES)
|
|
145
|
-
throw new Error("Context pack manifest is unsafe.");
|
|
146
|
-
const manifestBytes = await readFile(manifestPath);
|
|
170
|
+
const manifestBytes = await readBoundedPackFile(manifestPath, CONTEXT_PACK_MANIFEST);
|
|
147
171
|
assertUtf8(manifestBytes, CONTEXT_PACK_MANIFEST);
|
|
148
172
|
const manifest = contextPackManifestSchema.parse(JSON.parse(manifestBytes.toString("utf8")));
|
|
173
|
+
const inventory = await physicalPackInventory(root);
|
|
174
|
+
const physicalByCanonical = inventory.by_canonical;
|
|
149
175
|
const declared = new Set();
|
|
176
|
+
const portableDeclared = new Set();
|
|
150
177
|
let totalBytes = manifestBytes.byteLength;
|
|
151
178
|
const hash = createHash("sha256").update(manifestBytes).update("\0");
|
|
152
179
|
const content = new Map();
|
|
153
180
|
for (const file of manifest.files) {
|
|
154
181
|
const normalized = normalizePackPath(file.path);
|
|
155
|
-
|
|
182
|
+
const portableKey = portablePathCollisionKey(normalized);
|
|
183
|
+
if (portableKey === portablePathCollisionKey(CONTEXT_PACK_MANIFEST)
|
|
184
|
+
|| declared.has(normalized) || portableDeclared.has(portableKey)) {
|
|
156
185
|
throw new Error(`Duplicate or reserved context pack path: ${normalized}`);
|
|
186
|
+
}
|
|
157
187
|
declared.add(normalized);
|
|
158
|
-
|
|
188
|
+
portableDeclared.add(portableKey);
|
|
189
|
+
const physical = physicalByCanonical.get(normalized);
|
|
190
|
+
if (!physical)
|
|
191
|
+
throw new Error(`Context pack file declaration mismatch; missing: ${normalized}`);
|
|
192
|
+
const absolute = physicalPath(root, physical);
|
|
159
193
|
if (!contained(root, absolute))
|
|
160
194
|
throw new Error(`Unsafe context pack path: ${normalized}`);
|
|
161
|
-
const
|
|
162
|
-
if (!info.isFile() || info.isSymbolicLink() || info.size > CONTEXT_PACK_MAX_FILE_BYTES)
|
|
163
|
-
throw new Error(`Context pack file is unsafe or too large: ${normalized}`);
|
|
164
|
-
const bytes = await readFile(absolute);
|
|
195
|
+
const bytes = await readBoundedPackFile(absolute, normalized);
|
|
165
196
|
assertUtf8(bytes, normalized);
|
|
166
197
|
if (sha256(bytes) !== file.sha256)
|
|
167
198
|
throw new Error(`Context pack digest mismatch: ${normalized}`);
|
|
@@ -170,16 +201,18 @@ export async function validateContextPack(directory) {
|
|
|
170
201
|
throw new Error("Context pack exceeds the total size limit.");
|
|
171
202
|
content.set(normalized, bytes);
|
|
172
203
|
}
|
|
173
|
-
const actual = (
|
|
204
|
+
const actual = inventory.files.map(({ canonical_path }) => canonical_path).filter((path) => path !== CONTEXT_PACK_MANIFEST);
|
|
174
205
|
const undeclared = actual.filter((path) => !declared.has(path));
|
|
175
|
-
const missing = [...declared].filter((path) => !
|
|
206
|
+
const missing = [...declared].filter((path) => !physicalByCanonical.has(path));
|
|
176
207
|
if (undeclared.length || missing.length)
|
|
177
208
|
throw new Error(`Context pack file declaration mismatch${undeclared.length ? `; undeclared: ${undeclared.join(", ")}` : ""}${missing.length ? `; missing: ${missing.join(", ")}` : ""}`);
|
|
178
209
|
for (const path of [...declared].sort((a, b) => a.localeCompare(b, "en"))) {
|
|
179
210
|
const bytes = content.get(path);
|
|
180
211
|
hash.update(path, "utf8").update("\0").update(bytes).update("\0");
|
|
181
212
|
}
|
|
182
|
-
|
|
213
|
+
const validated = { root, manifest, manifest_bytes: manifestBytes, digest: hash.digest("hex"), total_bytes: totalBytes };
|
|
214
|
+
validatedPackContent.set(validated, content);
|
|
215
|
+
return validated;
|
|
183
216
|
}
|
|
184
217
|
async function atomicJson(path, value) {
|
|
185
218
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
@@ -210,6 +243,41 @@ async function atomicJson(path, value) {
|
|
|
210
243
|
await rm(temporary, { force: true });
|
|
211
244
|
}
|
|
212
245
|
}
|
|
246
|
+
async function readBoundedPackFile(path, label) {
|
|
247
|
+
const unsafeMessage = label === CONTEXT_PACK_MANIFEST
|
|
248
|
+
? "Context pack manifest is unsafe or too large."
|
|
249
|
+
: `Context pack file is unsafe or too large: ${label}`;
|
|
250
|
+
let handle;
|
|
251
|
+
try {
|
|
252
|
+
const nonBlocking = process.platform === "win32" ? 0 : (constants.O_NONBLOCK ?? 0);
|
|
253
|
+
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | nonBlocking);
|
|
254
|
+
const opened = await handle.stat();
|
|
255
|
+
if (!opened.isFile() || opened.size > CONTEXT_PACK_MAX_FILE_BYTES)
|
|
256
|
+
throw new Error(unsafeMessage);
|
|
257
|
+
const buffer = Buffer.allocUnsafe(opened.size + 1);
|
|
258
|
+
let offset = 0;
|
|
259
|
+
while (offset < buffer.byteLength) {
|
|
260
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, null);
|
|
261
|
+
if (bytesRead === 0)
|
|
262
|
+
break;
|
|
263
|
+
offset += bytesRead;
|
|
264
|
+
}
|
|
265
|
+
const bytes = buffer.subarray(0, offset);
|
|
266
|
+
const afterRead = await handle.stat();
|
|
267
|
+
const named = lstatSync(path);
|
|
268
|
+
if (named.isSymbolicLink() || !named.isFile()
|
|
269
|
+
|| opened.dev !== afterRead.dev || opened.ino !== afterRead.ino || opened.size !== afterRead.size
|
|
270
|
+
|| opened.mtimeMs !== afterRead.mtimeMs || opened.ctimeMs !== afterRead.ctimeMs
|
|
271
|
+
|| opened.dev !== named.dev || opened.ino !== named.ino
|
|
272
|
+
|| bytes.byteLength !== afterRead.size || bytes.byteLength > CONTEXT_PACK_MAX_FILE_BYTES) {
|
|
273
|
+
throw new Error(`Context pack file changed while it was being read: ${label}`);
|
|
274
|
+
}
|
|
275
|
+
return bytes;
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
await handle?.close();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
213
281
|
async function makeImmutable(root) {
|
|
214
282
|
const directories = [];
|
|
215
283
|
const walk = async (directory) => {
|
|
@@ -344,7 +412,18 @@ async function publishContextPack(pack, source) {
|
|
|
344
412
|
const temporary = join(objects, `.${pack.digest}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
345
413
|
let published = true;
|
|
346
414
|
try {
|
|
347
|
-
await
|
|
415
|
+
await mkdir(temporary, { mode: 0o700 });
|
|
416
|
+
await writeFile(join(temporary, CONTEXT_PACK_MANIFEST), pack.manifest_bytes, { mode: 0o600, flag: "wx" });
|
|
417
|
+
const content = validatedPackContent.get(pack);
|
|
418
|
+
if (!content)
|
|
419
|
+
throw new Error("Context pack validation bytes are unavailable.");
|
|
420
|
+
for (const [path, bytes] of content) {
|
|
421
|
+
const target = resolve(temporary, ...path.split("/"));
|
|
422
|
+
if (!contained(temporary, target))
|
|
423
|
+
throw new Error(`Unsafe context pack publication path: ${path}`);
|
|
424
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
425
|
+
await writeFile(target, bytes, { mode: 0o600, flag: "wx" });
|
|
426
|
+
}
|
|
348
427
|
const copied = await validateContextPack(temporary);
|
|
349
428
|
if (copied.digest !== pack.digest)
|
|
350
429
|
throw new Error("Context pack changed during installation.");
|
|
@@ -388,12 +467,11 @@ async function prepareOkfContextPack(sourceRoot, options) {
|
|
|
388
467
|
const okf = await validateOkfBundle(sourceRoot);
|
|
389
468
|
await mkdir(staging, { recursive: true, mode: 0o700 });
|
|
390
469
|
for (const file of okf.files) {
|
|
391
|
-
const source = resolve(okf.root, ...file.path.split("/"));
|
|
392
470
|
const target = resolve(staging, ...file.path.split("/"));
|
|
393
|
-
if (!contained(
|
|
471
|
+
if (!contained(staging, target))
|
|
394
472
|
throw new Error(`Unsafe OKF path: ${file.path}`);
|
|
395
473
|
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
396
|
-
await
|
|
474
|
+
await writeFile(target, validatedOkfFileContent(okf, file.path), { mode: 0o600, flag: "wx" });
|
|
397
475
|
}
|
|
398
476
|
const title = options.title?.trim() || okf.files.find(({ path }) => path === "index.md")?.title || options.id;
|
|
399
477
|
const description = options.description?.trim() || `Imported Open Knowledge Format ${okf.version} bundle.`;
|
|
@@ -1216,30 +1294,73 @@ export async function initializeContextPack(directory, options) {
|
|
|
1216
1294
|
if (existsSync(manifestPath))
|
|
1217
1295
|
throw new Error("Context pack manifest already exists.");
|
|
1218
1296
|
const id = options.id ?? basename(root).toLocaleLowerCase("en").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1219
|
-
const
|
|
1297
|
+
const inventory = await physicalPackInventory(root);
|
|
1298
|
+
const files = inventory.files.filter(({ canonical_path }) => canonical_path !== CONTEXT_PACK_MANIFEST);
|
|
1299
|
+
if (files.length > CONTEXT_PACK_MAX_ENTRIES)
|
|
1300
|
+
throw new Error("Context pack exceeds the entry limit.");
|
|
1220
1301
|
const entries = [];
|
|
1221
|
-
|
|
1222
|
-
|
|
1302
|
+
let totalBytes = 0;
|
|
1303
|
+
for (const file of files) {
|
|
1304
|
+
const path = file.canonical_path;
|
|
1305
|
+
if (portablePathCollisionKey(path) === portablePathCollisionKey(CONTEXT_PACK_MANIFEST)) {
|
|
1306
|
+
throw new Error(`Duplicate or reserved context pack path: ${path}`);
|
|
1307
|
+
}
|
|
1308
|
+
const source = physicalPath(root, file);
|
|
1309
|
+
const bytes = await readBoundedPackFile(source, path);
|
|
1223
1310
|
assertUtf8(bytes, path);
|
|
1311
|
+
totalBytes += bytes.byteLength;
|
|
1312
|
+
if (totalBytes > CONTEXT_PACK_MAX_TOTAL_BYTES)
|
|
1313
|
+
throw new Error("Context pack exceeds the total size limit.");
|
|
1224
1314
|
entries.push({ path, kind: "document", title: basename(path).replace(/\.[^.]+$/, ""), description: "", keywords: [], sha256: sha256(bytes) });
|
|
1225
1315
|
}
|
|
1226
1316
|
const manifest = contextPackManifestSchema.parse({ schema_version: 1, id, version: options.version ?? "0.1.0", title: options.title ?? id, description: options.description ?? `Context pack ${id}`, license: options.license ?? "UNLICENSED", files: entries });
|
|
1317
|
+
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
1318
|
+
if (manifestBytes.byteLength > CONTEXT_PACK_MAX_FILE_BYTES || totalBytes + manifestBytes.byteLength > CONTEXT_PACK_MAX_TOTAL_BYTES) {
|
|
1319
|
+
throw new Error("Context pack manifest or total pack exceeds the size limit.");
|
|
1320
|
+
}
|
|
1227
1321
|
await atomicJson(manifestPath, manifest);
|
|
1228
1322
|
return manifest;
|
|
1229
1323
|
}
|
|
1230
1324
|
export async function lockContextPack(directory) {
|
|
1231
|
-
const
|
|
1232
|
-
const
|
|
1325
|
+
const root = resolve(directory);
|
|
1326
|
+
const path = join(root, CONTEXT_PACK_MANIFEST);
|
|
1327
|
+
const manifestBytes = await readBoundedPackFile(path, CONTEXT_PACK_MANIFEST);
|
|
1328
|
+
const value = contextPackManifestSchema.parse(JSON.parse(manifestBytes.toString("utf8")));
|
|
1329
|
+
const inventory = await physicalPackInventory(root);
|
|
1330
|
+
const actual = new Set(inventory.files.map(({ canonical_path }) => canonical_path).filter((candidate) => candidate !== CONTEXT_PACK_MANIFEST));
|
|
1331
|
+
const declared = new Set();
|
|
1332
|
+
const portableDeclared = new Set();
|
|
1233
1333
|
const files = [];
|
|
1334
|
+
let contentBytes = 0;
|
|
1234
1335
|
for (const entry of value.files) {
|
|
1235
1336
|
const normalized = normalizePackPath(entry.path);
|
|
1236
|
-
const
|
|
1337
|
+
const portableKey = portablePathCollisionKey(normalized);
|
|
1338
|
+
if (portableKey === portablePathCollisionKey(CONTEXT_PACK_MANIFEST) || declared.has(normalized) || portableDeclared.has(portableKey)) {
|
|
1339
|
+
throw new Error(`Duplicate or reserved context pack path: ${normalized}`);
|
|
1340
|
+
}
|
|
1341
|
+
declared.add(normalized);
|
|
1342
|
+
portableDeclared.add(portableKey);
|
|
1343
|
+
const physical = inventory.by_canonical.get(normalized);
|
|
1344
|
+
if (!physical)
|
|
1345
|
+
throw new Error(`Context pack file declaration mismatch; missing: ${normalized}`);
|
|
1346
|
+
const source = physicalPath(root, physical);
|
|
1347
|
+
const bytes = await readBoundedPackFile(source, normalized);
|
|
1237
1348
|
assertUtf8(bytes, normalized);
|
|
1238
1349
|
if (bytes.byteLength > CONTEXT_PACK_MAX_FILE_BYTES)
|
|
1239
1350
|
throw new Error(`Context pack file is too large: ${normalized}`);
|
|
1351
|
+
contentBytes += bytes.byteLength;
|
|
1352
|
+
if (contentBytes > CONTEXT_PACK_MAX_TOTAL_BYTES)
|
|
1353
|
+
throw new Error("Context pack exceeds the total size limit.");
|
|
1240
1354
|
files.push({ ...entry, sha256: sha256(bytes) });
|
|
1241
1355
|
}
|
|
1356
|
+
const undeclared = [...actual].filter((candidate) => !declared.has(candidate));
|
|
1357
|
+
if (undeclared.length)
|
|
1358
|
+
throw new Error(`Context pack file declaration mismatch; undeclared: ${undeclared.join(", ")}`);
|
|
1242
1359
|
const locked = { ...value, files };
|
|
1360
|
+
const lockedBytes = Buffer.from(`${JSON.stringify(locked, null, 2)}\n`, "utf8");
|
|
1361
|
+
if (lockedBytes.byteLength > CONTEXT_PACK_MAX_FILE_BYTES || contentBytes + lockedBytes.byteLength > CONTEXT_PACK_MAX_TOTAL_BYTES) {
|
|
1362
|
+
throw new Error("Context pack manifest or total pack exceeds the size limit.");
|
|
1363
|
+
}
|
|
1243
1364
|
await atomicJson(path, locked);
|
|
1244
1365
|
await validateContextPack(directory);
|
|
1245
1366
|
return locked;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { isAbsolute } from "node:path";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
import { CAPABILITY_IDS, capabilityIdSchema } from "./capability-schema.js";
|
|
4
|
+
import { portablePathCollisionKey, portableRelativePathIssue } from "./path-security.js";
|
|
5
5
|
export const EVAL_SCHEMA_VERSION = 1;
|
|
6
6
|
export const EVAL_ADAPTER_RESULT_STATUSES = ["completed", "adapter_error"];
|
|
7
7
|
export const EVAL_OBSERVATION_TERMINAL_STATES = [
|
|
@@ -30,13 +30,13 @@ const boundedTextSchema = z.string().max(MAX_TEXT_BYTES);
|
|
|
30
30
|
const nonnegativeIntegerSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
|
|
31
31
|
const positiveIntegerSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER);
|
|
32
32
|
export const evalRelativePathSchema = z.string().min(1).max(1024).superRefine((value, context) => {
|
|
33
|
-
if (
|
|
33
|
+
if (portableRelativePathIssue(value, { allowCurrentDirectory: true })) {
|
|
34
34
|
context.addIssue({ code: "custom", message: "Evaluation paths must be relative canonical paths." });
|
|
35
|
-
return;
|
|
36
35
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
});
|
|
37
|
+
export const evalFileRelativePathSchema = z.string().min(1).max(1024).superRefine((value, context) => {
|
|
38
|
+
if (portableRelativePathIssue(value)) {
|
|
39
|
+
context.addIssue({ code: "custom", message: "Evaluation file paths must be relative canonical file paths." });
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
42
|
export const evalCommandSchema = z.strictObject({
|
|
@@ -53,7 +53,7 @@ const gitSourceSchema = z.strictObject({
|
|
|
53
53
|
revision: commitSchema,
|
|
54
54
|
});
|
|
55
55
|
const bundledFileSchema = z.strictObject({
|
|
56
|
-
path:
|
|
56
|
+
path: evalFileRelativePathSchema,
|
|
57
57
|
content: z.string().max(256 * 1024),
|
|
58
58
|
});
|
|
59
59
|
const bundledFakeSourceSchema = z.strictObject({
|
|
@@ -62,10 +62,11 @@ const bundledFakeSourceSchema = z.strictObject({
|
|
|
62
62
|
files: z.array(bundledFileSchema).min(1).max(1024).superRefine((files, context) => {
|
|
63
63
|
const seen = new Set();
|
|
64
64
|
for (const [index, file] of files.entries()) {
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
const key = portablePathCollisionKey(file.path);
|
|
66
|
+
if (seen.has(key)) {
|
|
67
|
+
context.addIssue({ code: "custom", path: [index, "path"], message: "Bundled file paths must be portably unique." });
|
|
67
68
|
}
|
|
68
|
-
seen.add(
|
|
69
|
+
seen.add(key);
|
|
69
70
|
}
|
|
70
71
|
}),
|
|
71
72
|
});
|
|
@@ -116,7 +117,7 @@ export const evalAdapterRequestSchema = z.strictObject({
|
|
|
116
117
|
limits: evalLimitsSchema,
|
|
117
118
|
seed: z.string().min(1).max(256),
|
|
118
119
|
expected_capability_digest: digestSchema,
|
|
119
|
-
output_path:
|
|
120
|
+
output_path: evalFileRelativePathSchema,
|
|
120
121
|
});
|
|
121
122
|
const usageSchema = z.strictObject({
|
|
122
123
|
input_tokens: nonnegativeIntegerSchema.optional(),
|
|
@@ -168,7 +169,7 @@ const verifierObservationSchema = z.strictObject({
|
|
|
168
169
|
reason: valueFreeCodeSchema.nullable(),
|
|
169
170
|
});
|
|
170
171
|
const noteSnapshotEntrySchema = z.strictObject({
|
|
171
|
-
path:
|
|
172
|
+
path: evalFileRelativePathSchema,
|
|
172
173
|
digest: digestSchema,
|
|
173
174
|
bytes: nonnegativeIntegerSchema,
|
|
174
175
|
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { privatePathIsSafe } from "./platform-security.js";
|
|
4
|
+
export function bearerTokenIsSafe(token) {
|
|
5
|
+
return /^[A-Za-z0-9._~+/-]+=*$/.test(token);
|
|
6
|
+
}
|
|
7
|
+
export function normalizeHttpHost(raw) {
|
|
8
|
+
return raw?.trim() || "127.0.0.1";
|
|
9
|
+
}
|
|
10
|
+
export function parseHttpPort(raw) {
|
|
11
|
+
if (raw === undefined || raw === "")
|
|
12
|
+
return { enabled: false };
|
|
13
|
+
if (!/^[1-9][0-9]{0,4}$/.test(raw))
|
|
14
|
+
return { enabled: false, error: "invalid" };
|
|
15
|
+
const port = Number(raw);
|
|
16
|
+
return port <= 65535 ? { enabled: true, port } : { enabled: false, error: "invalid" };
|
|
17
|
+
}
|
|
18
|
+
export function formatHostAuthority(host, port) {
|
|
19
|
+
const normalized = host.trim();
|
|
20
|
+
return `${normalized.includes(":") && !normalized.startsWith("[") ? `[${normalized}]` : normalized}:${port}`;
|
|
21
|
+
}
|
|
22
|
+
export function normalizeHostAuthority(raw, defaultPort) {
|
|
23
|
+
if (!raw || raw.trim() !== raw || /[/\\@]/.test(raw))
|
|
24
|
+
return undefined;
|
|
25
|
+
const explicit = /^\[([^\]]+)\]:([0-9]+)$/.exec(raw) ?? /^([^:]+):([0-9]+)$/.exec(raw);
|
|
26
|
+
const implicit = defaultPort === undefined ? undefined : /^\[([^\]]+)\]$/.exec(raw) ?? /^([^:]+)$/.exec(raw);
|
|
27
|
+
const match = explicit ?? implicit;
|
|
28
|
+
if (!match)
|
|
29
|
+
return undefined;
|
|
30
|
+
const port = parseHttpPort(explicit ? match[2] : String(defaultPort));
|
|
31
|
+
if (!port.enabled || "error" in port)
|
|
32
|
+
return undefined;
|
|
33
|
+
try {
|
|
34
|
+
const bracketed = raw.startsWith("[");
|
|
35
|
+
const parsed = new URL(`http://${bracketed ? `[${match[1]}]` : match[1]}/`);
|
|
36
|
+
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash)
|
|
37
|
+
return undefined;
|
|
38
|
+
return `${parsed.hostname}:${port.port}`;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function readBoundedDescriptor(descriptor, approvedSize) {
|
|
45
|
+
const buffer = Buffer.allocUnsafe(approvedSize + 1);
|
|
46
|
+
let offset = 0;
|
|
47
|
+
while (offset < buffer.byteLength) {
|
|
48
|
+
const bytesRead = readSync(descriptor, buffer, offset, buffer.byteLength - offset, null);
|
|
49
|
+
if (bytesRead === 0)
|
|
50
|
+
break;
|
|
51
|
+
offset += bytesRead;
|
|
52
|
+
}
|
|
53
|
+
return buffer.subarray(0, offset);
|
|
54
|
+
}
|
|
55
|
+
export function resolveHttpToken(env, options = {}) {
|
|
56
|
+
const direct = env.CAIRN_MEMORY_HTTP_TOKEN?.trim();
|
|
57
|
+
const rawFile = env.CAIRN_MEMORY_HTTP_TOKEN_FILE?.trim();
|
|
58
|
+
if (direct && rawFile)
|
|
59
|
+
return { ok: false, reason: "multiple", file: rawFile };
|
|
60
|
+
if (direct)
|
|
61
|
+
return bearerTokenIsSafe(direct)
|
|
62
|
+
? { ok: true, source: "environment", token: direct }
|
|
63
|
+
: { ok: false, reason: "token-unsafe" };
|
|
64
|
+
if (!rawFile)
|
|
65
|
+
return { ok: false, reason: "missing" };
|
|
66
|
+
const file = resolve(options.baseDirectory ?? process.cwd(), rawFile);
|
|
67
|
+
let descriptor;
|
|
68
|
+
try {
|
|
69
|
+
const nonBlocking = process.platform === "win32" ? 0 : (constants.O_NONBLOCK ?? 0);
|
|
70
|
+
descriptor = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | nonBlocking);
|
|
71
|
+
const opened = fstatSync(descriptor);
|
|
72
|
+
if (!opened.isFile() || opened.size > 64 * 1024)
|
|
73
|
+
return { ok: false, reason: "file-unsafe", file };
|
|
74
|
+
const tokenBytes = readBoundedDescriptor(descriptor, opened.size);
|
|
75
|
+
const afterRead = fstatSync(descriptor);
|
|
76
|
+
const named = lstatSync(file);
|
|
77
|
+
if (named.isSymbolicLink() || !named.isFile()
|
|
78
|
+
|| opened.dev !== afterRead.dev || opened.ino !== afterRead.ino || opened.size !== afterRead.size
|
|
79
|
+
|| opened.mtimeMs !== afterRead.mtimeMs || opened.ctimeMs !== afterRead.ctimeMs
|
|
80
|
+
|| opened.dev !== named.dev || opened.ino !== named.ino
|
|
81
|
+
|| tokenBytes.byteLength !== afterRead.size || tokenBytes.byteLength > 64 * 1024) {
|
|
82
|
+
return { ok: false, reason: "file-changed", file };
|
|
83
|
+
}
|
|
84
|
+
if (!privatePathIsSafe(file))
|
|
85
|
+
return { ok: false, reason: "file-unsafe", file };
|
|
86
|
+
const namedAfterAcl = lstatSync(file);
|
|
87
|
+
if (namedAfterAcl.dev !== opened.dev || namedAfterAcl.ino !== opened.ino || namedAfterAcl.isSymbolicLink()) {
|
|
88
|
+
return { ok: false, reason: "file-changed", file };
|
|
89
|
+
}
|
|
90
|
+
const token = tokenBytes.toString("utf8").trim();
|
|
91
|
+
return bearerTokenIsSafe(token)
|
|
92
|
+
? { ok: true, source: "file", token, file }
|
|
93
|
+
: { ok: false, reason: "token-unsafe", file };
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error.code === "ENOENT")
|
|
97
|
+
return { ok: false, reason: "file-missing", file };
|
|
98
|
+
return { ok: false, reason: "file-unreadable", file };
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
if (descriptor !== undefined)
|
|
102
|
+
closeSync(descriptor);
|
|
103
|
+
}
|
|
104
|
+
}
|