@cortexkit/claustrum-client 0.7.0 → 0.9.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/README.md +26 -0
- package/dist/enrollment.d.ts +3 -3
- package/dist/enrollment.js +26 -31
- package/dist/handles.js +18 -56
- package/dist/manifest-lock.d.ts +1 -1
- package/dist/manifest-lock.js +36 -40
- package/dist/private-path.d.ts +16 -0
- package/dist/private-path.js +51 -0
- package/dist/wire.d.ts +6 -1
- package/dist/wire.js +5 -0
- package/package.json +2 -1
- package/dist/ancestor-permissions.d.ts +0 -20
- package/dist/ancestor-permissions.js +0 -44
package/README.md
CHANGED
|
@@ -39,6 +39,32 @@ are exported for tenant plugins consuming the OpenCode handle manifest. The shar
|
|
|
39
39
|
`HANDLE_FILE_CONTRACT` is pinned to `maxBytes: 262144`, `mode: 0o600`,
|
|
40
40
|
`labelRe: /^[a-z0-9][a-z0-9._-]{0,63}$/`, and `handleRe: /^ckh_[A-Za-z0-9_-]{43}$/`.
|
|
41
41
|
|
|
42
|
+
### Private files and Windows support
|
|
43
|
+
|
|
44
|
+
Enrollment tokens support Windows as well as Unix. Reading a handle manifest works on
|
|
45
|
+
Windows; writing one does not: the manifest lock's stale-lock eviction renames a lock
|
|
46
|
+
another process may hold open, which Windows refuses, so `withManifestLock` and
|
|
47
|
+
`writeHandleFileLocked` reject on Windows with code `unsupported_platform` rather than
|
|
48
|
+
risk two writers overlapping. The client uses `@cortexkit/store` 0.2.1 or newer: exclusive private creation
|
|
49
|
+
protects files before any secret bytes are written, and atomic replacement keeps
|
|
50
|
+
that protection. Unix files use mode 0600 and new directories use 0700. On Windows,
|
|
51
|
+
the store's `@cortexkit/private-files` native module creates owner-only ACLs and the
|
|
52
|
+
async path check verifies real security descriptors, not Node's emulated mode bits
|
|
53
|
+
or an AppData-location heuristic. Existing directories are checked, not silently
|
|
54
|
+
repaired. Unsupported filesystems or unavailable native modules refuse the operation.
|
|
55
|
+
Windows requires supported local, ACL-persisting drives; UNC/network paths and
|
|
56
|
+
reparse points are refused. Windows ACL enforcement can only be qualified in CI,
|
|
57
|
+
not by passing Unix tests locally.
|
|
58
|
+
|
|
59
|
+
**Changed in 0.9.0:** Unix ancestors must now be owned by the current effective user
|
|
60
|
+
or root. A foreign-owned ancestor is refused even at mode 0755; a non-root-owned
|
|
61
|
+
sticky ancestor is also refused. Put the files under a folder you own. Root-owned
|
|
62
|
+
sticky directories such as `/tmp` remain usable when the immediate child is your
|
|
63
|
+
own non-group/world-writable directory. The Debian user-private-group exception is
|
|
64
|
+
preserved: an owned, non-sticky group-writable directory (including a 0775
|
|
65
|
+
`~/.config` under umask 0002) is accepted only when local `/etc/passwd` and
|
|
66
|
+
`/etc/group` prove the effective group contains no other users.
|
|
67
|
+
|
|
42
68
|
## Testing a consumer of this client
|
|
43
69
|
|
|
44
70
|
Three things on a developer box silently satisfy what a consumer's test is trying to
|
package/dist/enrollment.d.ts
CHANGED
|
@@ -5,8 +5,8 @@ export type EnrollmentTokenFile = {
|
|
|
5
5
|
/**
|
|
6
6
|
* Atomically replace a consumer's enrollment credential.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Creation uses owner-only Unix permissions or native Windows ACLs before writing
|
|
9
|
+
* any bytes. @cortexkit/store checks that ancestors cannot be replaced by other
|
|
10
|
+
* users, and on Windows also verifies the completed parent's owner-only ACL.
|
|
11
11
|
*/
|
|
12
12
|
export declare function writeEnrollmentTokenFile(path: string, value: EnrollmentTokenFile): Promise<void>;
|
package/dist/enrollment.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createPrivateFile } from '@cortexkit/store';
|
|
2
|
+
import { checkPrivatePath, createPrivateParents, notPrivate, privateCreationCode } from './private-path.js';
|
|
2
3
|
import { randomBytes } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
import { open, rename, unlink } from 'node:fs/promises';
|
|
5
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
6
|
const TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
6
7
|
function validateTokenFile(value) {
|
|
7
8
|
if (!TOKEN_RE.test(value.token))
|
|
@@ -11,49 +12,43 @@ function validateTokenFile(value) {
|
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
async function refuseWritableAncestor(parent) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
catch {
|
|
19
|
-
// The following file operation reports the precise filesystem error. A permissions
|
|
20
|
-
// verdict about a path that could not be resolved would hide that useful failure.
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
for (;;) {
|
|
24
|
-
const metadata = await stat(component).catch(() => undefined);
|
|
25
|
-
// Canonicalisation already traversed this component. If a later stat races with a
|
|
26
|
-
// rename, let the create/rename operation below report the concrete failure.
|
|
27
|
-
if (metadata && !(await acceptsAncestor(metadata))) {
|
|
28
|
-
throw new Error(`enrollment token ancestor ${component} is group- or world-writable without sticky bit`);
|
|
29
|
-
}
|
|
30
|
-
const next = dirname(component);
|
|
31
|
-
if (next === component)
|
|
32
|
-
return;
|
|
33
|
-
component = next;
|
|
34
|
-
}
|
|
15
|
+
const result = await checkPrivatePath(parent);
|
|
16
|
+
if (!result.ok)
|
|
17
|
+
throw new Error(notPrivate('enrollment token folder', result));
|
|
35
18
|
}
|
|
36
19
|
/**
|
|
37
20
|
* Atomically replace a consumer's enrollment credential.
|
|
38
21
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
22
|
+
* Creation uses owner-only Unix permissions or native Windows ACLs before writing
|
|
23
|
+
* any bytes. @cortexkit/store checks that ancestors cannot be replaced by other
|
|
24
|
+
* users, and on Windows also verifies the completed parent's owner-only ACL.
|
|
42
25
|
*/
|
|
43
26
|
export async function writeEnrollmentTokenFile(path, value) {
|
|
44
27
|
validateTokenFile(value);
|
|
28
|
+
path = resolve(path);
|
|
45
29
|
const parent = dirname(path);
|
|
46
|
-
|
|
30
|
+
// Native Windows creation rejects ancestors that other users can replace. A missing
|
|
31
|
+
// private parent can be created under a safe but non-private folder; afterwards the
|
|
32
|
+
// async check also requires the completed parent's protected owner-only ACL.
|
|
33
|
+
if (process.platform !== 'win32')
|
|
34
|
+
await refuseWritableAncestor(parent);
|
|
35
|
+
await createPrivateParents(parent);
|
|
47
36
|
await refuseWritableAncestor(parent);
|
|
48
37
|
const temporary = join(parent, `.${basename(path)}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`);
|
|
49
38
|
let created = false;
|
|
50
39
|
try {
|
|
51
|
-
const descriptor = await open(temporary, 'wx', 0o600);
|
|
52
40
|
created = true;
|
|
53
41
|
try {
|
|
54
|
-
await
|
|
42
|
+
await createPrivateFile(temporary, `${JSON.stringify(value)}\n`);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (privateCreationCode(error) === 'EEXIST')
|
|
46
|
+
created = false;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
const descriptor = await open(temporary, 'r+');
|
|
50
|
+
try {
|
|
55
51
|
await descriptor.sync();
|
|
56
|
-
await chmod(temporary, 0o600);
|
|
57
52
|
}
|
|
58
53
|
finally {
|
|
59
54
|
await descriptor.close();
|
package/dist/handles.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { lstat as nodeLstat, open as nodeOpen, readFile,
|
|
3
|
+
import { lstat as nodeLstat, open as nodeOpen, readFile, stat as nodeStat } from 'node:fs/promises';
|
|
4
4
|
import { userInfo } from 'node:os';
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
6
|
-
import {
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { checkPrivatePath, notPrivate } from './private-path.js';
|
|
7
7
|
export const HANDLE_FILE_CONTRACT = {
|
|
8
8
|
maxBytes: 256 * 1024,
|
|
9
9
|
mode: 0o600,
|
|
@@ -137,7 +137,7 @@ export function defaultHandleFilePath(env = process.env) {
|
|
|
137
137
|
return join(configHome, 'cortexkit', 'opencode-handles.json');
|
|
138
138
|
}
|
|
139
139
|
function currentUid() {
|
|
140
|
-
return process.getuid?.() ?? userInfo().uid;
|
|
140
|
+
return process.platform === 'win32' ? undefined : process.getuid?.() ?? userInfo().uid;
|
|
141
141
|
}
|
|
142
142
|
async function readBounded(descriptor, cap) {
|
|
143
143
|
if (!descriptor.read)
|
|
@@ -153,6 +153,7 @@ async function readBounded(descriptor, cap) {
|
|
|
153
153
|
return { buffer, bytes: total > cap ? -1 : total };
|
|
154
154
|
}
|
|
155
155
|
async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
|
|
156
|
+
path = resolve(path);
|
|
156
157
|
const stat = io.stat ?? nodeStat;
|
|
157
158
|
const lstat = io.lstat ?? nodeLstat;
|
|
158
159
|
const read = io.readFile ?? readFile;
|
|
@@ -182,7 +183,7 @@ async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
|
|
|
182
183
|
invalid('handle file must be a regular file');
|
|
183
184
|
if ((metadata.size ?? 0) > HANDLE_FILE_CONTRACT.maxBytes)
|
|
184
185
|
invalid('handle file exceeds 256 KiB');
|
|
185
|
-
if ((metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
|
|
186
|
+
if (process.platform !== 'win32' && (metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
|
|
186
187
|
invalid('handle file mode must be exactly 0600');
|
|
187
188
|
const uid = io.currentUid ?? currentUid;
|
|
188
189
|
const expectedUid = uid();
|
|
@@ -201,57 +202,18 @@ async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
|
|
|
201
202
|
if (expectedUid !== undefined && parent.uid !== undefined && parent.uid !== expectedUid) {
|
|
202
203
|
invalid('handle file parent is not owned by the current uid');
|
|
203
204
|
}
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
// anyone who can create and unlink there renames it aside and substitutes their own
|
|
217
|
-
// tree. Walk to '/' rather than $HOME -- a stopping point read from the environment is
|
|
218
|
-
// attacker-influenceable and undefined when unset.
|
|
219
|
-
//
|
|
220
|
-
// realpath FIRST: an unresolved walk is defeated by a symlink component pointing
|
|
221
|
-
// somewhere permissive, and every individual stat still passes while the loop is about
|
|
222
|
-
// a path we never read through.
|
|
223
|
-
//
|
|
224
|
-
// Mirrors refuse_writable_ancestor in subc-transport 0.7.0 and in opencode_files.rs;
|
|
225
|
-
// three implementations of one rule must not drift.
|
|
226
|
-
let resolved;
|
|
227
|
-
try {
|
|
228
|
-
resolved = await realpath(dirname(path));
|
|
229
|
-
}
|
|
230
|
-
catch {
|
|
231
|
-
// Unresolvable: the read that follows reports the real errno, and refusing here
|
|
232
|
-
// would replace a precise failure with a permissions verdict about a path we could
|
|
233
|
-
// not resolve.
|
|
234
|
-
resolved = undefined;
|
|
235
|
-
}
|
|
236
|
-
if (resolved !== undefined) {
|
|
237
|
-
let component = resolved;
|
|
238
|
-
for (;;) {
|
|
239
|
-
let ancestor;
|
|
240
|
-
try {
|
|
241
|
-
ancestor = await stat(component);
|
|
242
|
-
}
|
|
243
|
-
catch {
|
|
244
|
-
ancestor = undefined;
|
|
245
|
-
}
|
|
246
|
-
if (ancestor && !(await acceptsAncestor(ancestor))) {
|
|
247
|
-
invalid(`handle file ancestor ${component} is group- or world-writable without sticky bit`);
|
|
248
|
-
}
|
|
249
|
-
const next = dirname(component);
|
|
250
|
-
if (next === component)
|
|
251
|
-
break;
|
|
252
|
-
component = next;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
205
|
+
// HandleFileIo's injected stat supplies ancestor metadata as well as the immediate
|
|
206
|
+
// parent. Pass that metadata to @cortexkit/store's Unix path check.
|
|
207
|
+
const fixtureStat = io.stat && (async (candidate) => {
|
|
208
|
+
const value = await io.stat(candidate);
|
|
209
|
+
return { ...value, isDirectory: () => value.isDirectory?.() ?? false };
|
|
210
|
+
});
|
|
211
|
+
const protection = await checkPrivatePath(process.platform === 'win32' ? path : dirname(path), fixtureStat ? {
|
|
212
|
+
euid: expectedUid,
|
|
213
|
+
fs: { stat: fixtureStat, lstat: fixtureStat },
|
|
214
|
+
} : undefined);
|
|
215
|
+
if (!protection.ok)
|
|
216
|
+
invalid(notPrivate('handle file folder', protection));
|
|
255
217
|
let source;
|
|
256
218
|
try {
|
|
257
219
|
if (descriptor) {
|
package/dist/manifest-lock.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export declare const MANIFEST_LOCK: {
|
|
|
4
4
|
renewEveryMs: number;
|
|
5
5
|
ownerKeys: readonly ['tenant', 'pid', 'claimed_at_ms', 'nonce'];
|
|
6
6
|
staleTargetRe: RegExp;
|
|
7
|
-
errorCodes: readonly ['lock_busy', 'owner_invalid', 'renewal_failed'];
|
|
7
|
+
errorCodes: readonly ['lock_busy', 'owner_invalid', 'renewal_failed', 'unsupported_platform'];
|
|
8
8
|
};
|
|
9
9
|
/** Thrown by the lock. Branch on `code`; the message is diagnostic and may be reworded. */
|
|
10
10
|
export type ManifestLockErrorCode = (typeof MANIFEST_LOCK.errorCodes)[number];
|
package/dist/manifest-lock.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { createPrivateDirectory, createPrivateFile } from '@cortexkit/store';
|
|
2
|
+
import { checkPrivatePath, createPrivateParents, notPrivate, privateCreationCode } from './private-path.js';
|
|
3
|
+
import { lstat, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
4
4
|
import { randomBytes, randomInt } from 'node:crypto';
|
|
5
|
-
import { basename as pathBasename, dirname, join } from 'node:path';
|
|
5
|
+
import { basename as pathBasename, dirname, join, resolve } from 'node:path';
|
|
6
6
|
import { HANDLE_FILE_CONTRACT, parseHandleFile } from './handles.js';
|
|
7
|
-
export const MANIFEST_LOCK = { ttlMs: 30_000, renewEveryMs: 10_000, ownerKeys: ['tenant', 'pid', 'claimed_at_ms', 'nonce'], staleTargetRe: /^\.lock\.stale-\d+-(?!\.{1,2}$)(?!.*[. ]$)[^/\\\x00-\x1f:*?"<>|]{1,128}$/, errorCodes: ['lock_busy', 'owner_invalid', 'renewal_failed'] };
|
|
7
|
+
export const MANIFEST_LOCK = { ttlMs: 30_000, renewEveryMs: 10_000, ownerKeys: ['tenant', 'pid', 'claimed_at_ms', 'nonce'], staleTargetRe: /^\.lock\.stale-\d+-(?!\.{1,2}$)(?!.*[. ]$)[^/\\\x00-\x1f:*?"<>|]{1,128}$/, errorCodes: ['lock_busy', 'owner_invalid', 'renewal_failed', 'unsupported_platform'] };
|
|
8
8
|
// The claim deadline bounds when the last stale-lock rename is issued, not when it lands:
|
|
9
9
|
// a rename issued at deadline-1ms can complete afterwards, by tens to hundreds of ms on a
|
|
10
10
|
// loaded host. The retry deadline and staleness window both read `ttl` today; if they split,
|
|
@@ -15,12 +15,13 @@ export function __setManifestLockTestOptions(options) { testOptions = options; }
|
|
|
15
15
|
// 16 CSPRNG bytes: a collision needs both the same millisecond and the same nonce. Do not
|
|
16
16
|
// simplify this to a counter, pid+timestamp, or a short token.
|
|
17
17
|
const token = () => randomBytes(16).toString('base64url');
|
|
18
|
-
const code =
|
|
18
|
+
const code = privateCreationCode;
|
|
19
19
|
const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
20
|
// A tenant classifying a failure must not string-match our prose: a copy-edit would
|
|
21
21
|
// silently reclassify a retryable busy-lock as an unknown error, with nothing failing
|
|
22
22
|
// loudly. The code is the contract; the message is free to change.
|
|
23
23
|
const lockError = (code, message) => Object.assign(new Error(message), { code });
|
|
24
|
+
const unsupportedOnWindows = () => lockError('unsupported_platform', 'handle-file manifest locking is not supported on Windows');
|
|
24
25
|
function parseOwner(source) {
|
|
25
26
|
let value;
|
|
26
27
|
try {
|
|
@@ -45,9 +46,8 @@ async function writeOwner(lock, owner) {
|
|
|
45
46
|
const temporary = join(lock, `owner.${process.pid}.${token()}.tmp`);
|
|
46
47
|
let file;
|
|
47
48
|
try {
|
|
48
|
-
|
|
49
|
-
await
|
|
50
|
-
await file.writeFile(`${JSON.stringify(owner)}\n`);
|
|
49
|
+
await createPrivateFile(temporary, `${JSON.stringify(owner)}\n`);
|
|
50
|
+
file = await open(temporary, 'r+');
|
|
51
51
|
await file.sync();
|
|
52
52
|
await file.close();
|
|
53
53
|
file = undefined;
|
|
@@ -104,11 +104,18 @@ export async function withManifestLock(path, tenant, fn) {
|
|
|
104
104
|
return withLockCommit(path, tenant, async () => fn());
|
|
105
105
|
}
|
|
106
106
|
async function withLockCommit(path, tenant, fn) {
|
|
107
|
+
// Stale-lock eviction renames the lock directory while another process may hold it
|
|
108
|
+
// open. That is safe under POSIX rename; on Windows CI two evictors both won and a
|
|
109
|
+
// rename of an open lock failed with EPERM. Until the protocol is proven there,
|
|
110
|
+
// refuse rather than risk two writers overlapping on the shared handle file.
|
|
111
|
+
if (process.platform === 'win32')
|
|
112
|
+
throw unsupportedOnWindows();
|
|
113
|
+
path = resolve(path);
|
|
107
114
|
const lock = `${path}.lock`, ownerPath = join(lock, 'owner'), ttl = testOptions?.ttlMs ?? MANIFEST_LOCK.ttlMs, renewEvery = testOptions?.renewEveryMs ?? MANIFEST_LOCK.renewEveryMs, retryMin = testOptions?.retryMinMs ?? 25, retryMax = testOptions?.retryMaxMs ?? 75;
|
|
108
115
|
const nonce = token(), started = Date.now(), deadline = started + ttl;
|
|
109
116
|
while (true) {
|
|
110
117
|
try {
|
|
111
|
-
await
|
|
118
|
+
await createPrivateDirectory(lock);
|
|
112
119
|
await writeOwner(lock, { tenant, pid: process.pid, claimed_at_ms: Date.now(), nonce });
|
|
113
120
|
await testOptions?.afterClaim?.();
|
|
114
121
|
break;
|
|
@@ -241,8 +248,11 @@ async function readManifest(path) {
|
|
|
241
248
|
}
|
|
242
249
|
if (metadata.isSymbolicLink() || !metadata.isFile())
|
|
243
250
|
throw new Error('handle file must be a regular file');
|
|
244
|
-
if ((metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
|
|
251
|
+
if (process.platform !== 'win32' && (metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
|
|
245
252
|
throw new Error('handle file mode must be exactly 0600');
|
|
253
|
+
const protection = await checkPrivatePath(path);
|
|
254
|
+
if (!protection.ok)
|
|
255
|
+
throw new Error(notPrivate('handle file folder', protection));
|
|
246
256
|
const source = await readFile(path);
|
|
247
257
|
if (source.byteLength > HANDLE_FILE_CONTRACT.maxBytes)
|
|
248
258
|
throw new Error('handle file exceeds 256 KiB');
|
|
@@ -250,27 +260,9 @@ async function readManifest(path) {
|
|
|
250
260
|
}
|
|
251
261
|
const foreign = (file, tenant) => file.providers.filter((provider) => provider.serve !== tenant).map((provider) => JSON.stringify(provider));
|
|
252
262
|
async function refuseWritableAncestors(parent) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
catch (error) {
|
|
258
|
-
if (code(error) !== 'ENOENT')
|
|
259
|
-
throw error;
|
|
260
|
-
const next = dirname(parent);
|
|
261
|
-
if (next !== parent)
|
|
262
|
-
await refuseWritableAncestors(next);
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
for (;;) {
|
|
266
|
-
const metadata = await stat(component);
|
|
267
|
-
if (!(await acceptsAncestor(metadata)))
|
|
268
|
-
throw new Error('handle file ancestor is group- or world-writable without sticky bit');
|
|
269
|
-
const next = dirname(component);
|
|
270
|
-
if (next === component)
|
|
271
|
-
return;
|
|
272
|
-
component = next;
|
|
273
|
-
}
|
|
263
|
+
const protection = await checkPrivatePath(parent);
|
|
264
|
+
if (!protection.ok)
|
|
265
|
+
throw new Error(notPrivate('handle file folder', protection));
|
|
274
266
|
}
|
|
275
267
|
async function prepareParent(path) {
|
|
276
268
|
const parent = dirname(path);
|
|
@@ -281,14 +273,13 @@ async function prepareParent(path) {
|
|
|
281
273
|
throw error; return undefined; });
|
|
282
274
|
if (existing && typeof process.getuid === 'function' && existing.uid !== process.getuid())
|
|
283
275
|
throw new Error('handle file parent is not owned by the current uid');
|
|
284
|
-
await
|
|
276
|
+
await createPrivateParents(parent);
|
|
285
277
|
const metadata = await stat(parent);
|
|
286
278
|
if (!metadata.isDirectory())
|
|
287
279
|
throw new Error('handle file parent must be a directory');
|
|
288
280
|
if (typeof process.getuid === 'function' && metadata.uid !== process.getuid())
|
|
289
281
|
throw new Error('handle file parent is not owned by the current uid');
|
|
290
|
-
|
|
291
|
-
await refuseWritableAncestors(parent);
|
|
282
|
+
await refuseWritableAncestors(parent);
|
|
292
283
|
}
|
|
293
284
|
async function writeAtomic(path, file, commit) {
|
|
294
285
|
const bytes = Buffer.from(JSON.stringify(file));
|
|
@@ -303,13 +294,11 @@ async function writeAtomic(path, file, commit) {
|
|
|
303
294
|
const temporary = join(dirname(path), `.${pathBasename(path)}.${process.pid}.${token()}.tmp`);
|
|
304
295
|
let handle;
|
|
305
296
|
try {
|
|
306
|
-
|
|
307
|
-
await
|
|
308
|
-
await handle.writeFile(bytes);
|
|
297
|
+
await createPrivateFile(temporary, bytes);
|
|
298
|
+
handle = await open(temporary, 'r+');
|
|
309
299
|
await handle.sync();
|
|
310
300
|
await handle.close();
|
|
311
301
|
handle = undefined;
|
|
312
|
-
await chmod(temporary, 0o600);
|
|
313
302
|
await testOptions?.beforeManifestRename?.(`${path}.lock`);
|
|
314
303
|
await commit();
|
|
315
304
|
await rename(temporary, path);
|
|
@@ -320,13 +309,20 @@ async function writeAtomic(path, file, commit) {
|
|
|
320
309
|
}
|
|
321
310
|
}
|
|
322
311
|
export async function writeHandleFileLocked(path, tenant, mutate) {
|
|
312
|
+
// Refuse before any path check, so a Windows caller gets the platform reason rather
|
|
313
|
+
// than a private-path refusal from the parent check that runs next.
|
|
314
|
+
if (process.platform === 'win32')
|
|
315
|
+
throw unsupportedOnWindows();
|
|
316
|
+
path = resolve(path);
|
|
323
317
|
await prepareParent(path);
|
|
324
318
|
await withLockCommit(path, tenant, async (commit) => {
|
|
325
319
|
const before = await readManifest(path), working = structuredClone(before), result = await mutate(working), next = parseHandleFile(result ?? working), beforeForeign = foreign(before, tenant);
|
|
326
320
|
if (JSON.stringify(foreign(next, tenant)) !== JSON.stringify(beforeForeign))
|
|
327
321
|
throw new Error('manifest mutation changed another tenant block');
|
|
328
322
|
await writeAtomic(path, next, commit);
|
|
329
|
-
if (((await lstat(path)).mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
|
|
323
|
+
if (process.platform !== 'win32' && ((await lstat(path)).mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
|
|
324
|
+
throw new Error('manifest readback mode is not 0600');
|
|
325
|
+
if (!(await checkPrivatePath(path)).ok)
|
|
330
326
|
throw new Error('manifest readback mode is not 0600');
|
|
331
327
|
const readback = await readManifest(path);
|
|
332
328
|
if (JSON.stringify(readback) !== JSON.stringify(next))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { type OwnerOnlyPathAsyncOptions, type OwnerOnlyPathResult } from '@cortexkit/store';
|
|
3
|
+
export declare const privatePathContext: AsyncLocalStorage<OwnerOnlyPathAsyncOptions>;
|
|
4
|
+
export declare const checkPrivatePath: (path: string, options?: OwnerOnlyPathAsyncOptions) => Promise<OwnerOnlyPathResult>;
|
|
5
|
+
/**
|
|
6
|
+
* The refusal text for a path the store judged not private. It names the folder and the
|
|
7
|
+
* store's own reason (foreign owner, world-writable, a sticky folder without an
|
|
8
|
+
* owner-only child, a Windows ACL problem, ...), so the operator sees the actual cause.
|
|
9
|
+
*/
|
|
10
|
+
export declare function notPrivate(subject: string, result: Extract<OwnerOnlyPathResult, {
|
|
11
|
+
ok: false;
|
|
12
|
+
}>): string;
|
|
13
|
+
/** Native creation errors use Win32 numbers rather than Node's exclusive-create codes. */
|
|
14
|
+
export declare function privateCreationCode(error: unknown): string | undefined;
|
|
15
|
+
/** Create missing components privately without repairing permissions on existing directories. */
|
|
16
|
+
export declare function createPrivateParents(path: string): Promise<void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { checkOwnerOnlyPathAsync, createPrivateDirectory } from '@cortexkit/store';
|
|
5
|
+
// Tests run the ownership check against fixture uids and account files through this
|
|
6
|
+
// context, isolated per concurrent test. It is internal: the package `exports` map
|
|
7
|
+
// exposes only the index, so consumers cannot reach it.
|
|
8
|
+
export const privatePathContext = new AsyncLocalStorage();
|
|
9
|
+
export const checkPrivatePath = (path, options) => checkOwnerOnlyPathAsync(resolve(path), privatePathContext.getStore() ?? options);
|
|
10
|
+
/**
|
|
11
|
+
* The refusal text for a path the store judged not private. It names the folder and the
|
|
12
|
+
* store's own reason (foreign owner, world-writable, a sticky folder without an
|
|
13
|
+
* owner-only child, a Windows ACL problem, ...), so the operator sees the actual cause.
|
|
14
|
+
*/
|
|
15
|
+
export function notPrivate(subject, result) {
|
|
16
|
+
return `${subject} ${result.path} is not private: ${result.reason}`;
|
|
17
|
+
}
|
|
18
|
+
/** Native creation errors use Win32 numbers rather than Node's exclusive-create codes. */
|
|
19
|
+
export function privateCreationCode(error) {
|
|
20
|
+
const value = error;
|
|
21
|
+
if (value?.code === 'ERR_PRIVATE_FILES_SECURITY' && value.reason === 'io-error') {
|
|
22
|
+
if (value.win32Code === 80 || value.win32Code === 183)
|
|
23
|
+
return 'EEXIST';
|
|
24
|
+
if (value.win32Code === 2 || value.win32Code === 3)
|
|
25
|
+
return 'ENOENT';
|
|
26
|
+
}
|
|
27
|
+
return value?.code;
|
|
28
|
+
}
|
|
29
|
+
/** Create missing components privately without repairing permissions on existing directories. */
|
|
30
|
+
export async function createPrivateParents(path) {
|
|
31
|
+
try {
|
|
32
|
+
await stat(path);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code !== 'ENOENT')
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
const parent = dirname(path);
|
|
40
|
+
if (parent !== path)
|
|
41
|
+
await createPrivateParents(parent);
|
|
42
|
+
try {
|
|
43
|
+
await createPrivateDirectory(path);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (privateCreationCode(error) !== 'EEXIST')
|
|
47
|
+
throw error;
|
|
48
|
+
if (!(await stat(path)).isDirectory())
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/wire.d.ts
CHANGED
|
@@ -212,8 +212,13 @@ export interface ScopedInventoryRow {
|
|
|
212
212
|
* which is also what an unmapped credential reports.
|
|
213
213
|
*/
|
|
214
214
|
readonly providerIds: readonly string[];
|
|
215
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* Subscription tier to price this account by; absent means unknown. An operator's
|
|
217
|
+
* `ck auth set-plan` value takes precedence over the tier detected from the provider;
|
|
218
|
+
* `planTierSource` says which one this is.
|
|
219
|
+
*/
|
|
216
220
|
readonly planTier?: string;
|
|
221
|
+
readonly planTierSource?: 'operator' | 'detected';
|
|
217
222
|
readonly authMethod?: 'apikey' | 'chatgpt' | 'antigravity' | 'oauth';
|
|
218
223
|
/**
|
|
219
224
|
* Which provider protocol this credential speaks. Absent for static keys.
|
package/dist/wire.js
CHANGED
|
@@ -407,6 +407,10 @@ export function decodeScopedInventory(response, logUnknownClass) {
|
|
|
407
407
|
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
408
408
|
}
|
|
409
409
|
const authMethod = row.auth_method;
|
|
410
|
+
const planTierSource = row.plan_tier_source;
|
|
411
|
+
if (planTierSource !== undefined && planTierSource !== 'operator' && planTierSource !== 'detected') {
|
|
412
|
+
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
413
|
+
}
|
|
410
414
|
if (authMethod !== undefined &&
|
|
411
415
|
authMethod !== 'apikey' && authMethod !== 'chatgpt' &&
|
|
412
416
|
authMethod !== 'antigravity' && authMethod !== 'oauth') {
|
|
@@ -430,6 +434,7 @@ export function decodeScopedInventory(response, logUnknownClass) {
|
|
|
430
434
|
providerIds: row.provider_ids === undefined ? [] : strings(row.provider_ids),
|
|
431
435
|
authMethod,
|
|
432
436
|
planTier,
|
|
437
|
+
planTierSource,
|
|
433
438
|
state: row.state,
|
|
434
439
|
refreshAdapter: row.refresh_adapter,
|
|
435
440
|
recordVersion,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/claustrum-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
+
"@cortexkit/store": "^0.2.1",
|
|
30
31
|
"@cortexkit/subc-client": "^0.16.0"
|
|
31
32
|
}
|
|
32
33
|
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
-
export type AncestorIdentity = {
|
|
3
|
-
euid: number | undefined;
|
|
4
|
-
egid: number | undefined;
|
|
5
|
-
passwdPath: string;
|
|
6
|
-
groupPath: string;
|
|
7
|
-
};
|
|
8
|
-
export declare const ancestorIdentityContext: AsyncLocalStorage<AncestorIdentity>;
|
|
9
|
-
/**
|
|
10
|
-
* Debian OpenSSH's user-group-modes rule: ancestors without group/world-write, or with sticky, are safe.
|
|
11
|
-
* Without sticky, group-write requires effective uid/gid ownership and local passwd/group
|
|
12
|
-
* files proving the group contains only this user, with no other primary-group users.
|
|
13
|
-
* World-write never qualifies; anything the local account files cannot prove refuses.
|
|
14
|
-
* The twin accepts_ancestor in opencode_files.rs must keep the identical permission rule.
|
|
15
|
-
*/
|
|
16
|
-
export declare function acceptsAncestor(metadata: {
|
|
17
|
-
mode: number;
|
|
18
|
-
uid?: number;
|
|
19
|
-
gid?: number;
|
|
20
|
-
}, identity?: AncestorIdentity): Promise<boolean>;
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
-
export const ancestorIdentityContext = new AsyncLocalStorage();
|
|
4
|
-
function records(source, count) {
|
|
5
|
-
return source.split('\n').filter((line) => line.trim() !== '' && !line.trimStart().startsWith('#')).map((line) => {
|
|
6
|
-
const fields = line.split(':');
|
|
7
|
-
if (fields.length !== count || !fields[0] || /\s/.test(fields[0]) || !/^\d+$/.test(fields[2]) || !Number.isSafeInteger(Number(fields[2])) || Number(fields[2]) > 0xffffffff || (count === 7 && (!/^\d+$/.test(fields[3]) || !Number.isSafeInteger(Number(fields[3])) || Number(fields[3]) > 0xffffffff))) {
|
|
8
|
-
throw new Error('malformed account record');
|
|
9
|
-
}
|
|
10
|
-
if (count === 4 && fields[3] !== '' && fields[3].split(',').some((name) => name === '' || /\s/.test(name)))
|
|
11
|
-
throw new Error('malformed group members');
|
|
12
|
-
return fields;
|
|
13
|
-
});
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* Debian OpenSSH's user-group-modes rule: ancestors without group/world-write, or with sticky, are safe.
|
|
17
|
-
* Without sticky, group-write requires effective uid/gid ownership and local passwd/group
|
|
18
|
-
* files proving the group contains only this user, with no other primary-group users.
|
|
19
|
-
* World-write never qualifies; anything the local account files cannot prove refuses.
|
|
20
|
-
* The twin accepts_ancestor in opencode_files.rs must keep the identical permission rule.
|
|
21
|
-
*/
|
|
22
|
-
export async function acceptsAncestor(metadata, identity = ancestorIdentityContext.getStore() ?? {
|
|
23
|
-
euid: process.geteuid?.(), egid: process.getegid?.(), passwdPath: '/etc/passwd', groupPath: '/etc/group',
|
|
24
|
-
}) {
|
|
25
|
-
if ((metadata.mode & 0o022) === 0 || (metadata.mode & 0o1000) !== 0)
|
|
26
|
-
return true;
|
|
27
|
-
if ((metadata.mode & 0o002) !== 0 || identity.euid === undefined || identity.egid === undefined || metadata.uid !== identity.euid || metadata.gid !== identity.egid)
|
|
28
|
-
return false;
|
|
29
|
-
try {
|
|
30
|
-
const passwd = records(new TextDecoder('utf-8', { fatal: true }).decode(await readFile(identity.passwdPath)), 7);
|
|
31
|
-
const groups = records(new TextDecoder('utf-8', { fatal: true }).decode(await readFile(identity.groupPath)), 4);
|
|
32
|
-
const users = passwd.filter((entry) => Number(entry[2]) === identity.euid);
|
|
33
|
-
const matchingGroups = groups.filter((entry) => Number(entry[2]) === identity.egid);
|
|
34
|
-
if (users.length !== 1 || matchingGroups.length !== 1)
|
|
35
|
-
return false;
|
|
36
|
-
const name = users[0][0];
|
|
37
|
-
return (matchingGroups[0][3] === '' || matchingGroups[0][3].split(',').every((member) => member === name)) &&
|
|
38
|
-
!passwd.some((entry) => Number(entry[2]) !== identity.euid && Number(entry[3]) === identity.egid);
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
// Local files must prove exclusivity; unavailable or malformed NSS data cannot grant an exception.
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
}
|