@cortexkit/claustrum-client 0.7.0 → 0.10.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 CHANGED
@@ -28,6 +28,15 @@ credential caching, refresh, account scheduling, and retry policy.
28
28
  no provider ids are set. Since 0.6.2 a daemon too old to send the field decodes the
29
29
  same way, so a newer client still lists against an older daemon. `authMethod` stays
30
30
  optional.
31
+ • Since 0.10.0 `listScoped` also returns `grants` (how many grants the caller holds)
32
+ and `grantTuples` (`{ selectorKind, selector, operation }` per grant). They tell two
33
+ empty listings apart: `rows: []` with `grants === 0` means the caller has no grant;
34
+ `rows: []` with `grants > 0` means it holds a grant that currently reaches no
35
+ credential, usually a category no credential carries yet (the operator's fix is
36
+ `ck auth set-category`, or `ck auth reclassify --from-registry` for credentials created
37
+ before categories existed). Both are `undefined` against a vault too old to send
38
+ them; treat that as unknown, not as zero. A present but malformed value refuses the
39
+ reply like any other malformed field.
31
40
 
32
41
  The client sends `consumerIdentity: null` for every managed request so inherited
33
42
  `SUBC_MODULE_ID` and `SUBC_LAUNCH_NONCE` cannot impersonate a supervising host.
@@ -39,6 +48,32 @@ are exported for tenant plugins consuming the OpenCode handle manifest. The shar
39
48
  `HANDLE_FILE_CONTRACT` is pinned to `maxBytes: 262144`, `mode: 0o600`,
40
49
  `labelRe: /^[a-z0-9][a-z0-9._-]{0,63}$/`, and `handleRe: /^ckh_[A-Za-z0-9_-]{43}$/`.
41
50
 
51
+ ### Private files and Windows support
52
+
53
+ Enrollment tokens support Windows as well as Unix. Reading a handle manifest works on
54
+ Windows; writing one does not: the manifest lock's stale-lock eviction renames a lock
55
+ another process may hold open, which Windows refuses, so `withManifestLock` and
56
+ `writeHandleFileLocked` reject on Windows with code `unsupported_platform` rather than
57
+ risk two writers overlapping. The client uses `@cortexkit/store` 0.2.1 or newer: exclusive private creation
58
+ protects files before any secret bytes are written, and atomic replacement keeps
59
+ that protection. Unix files use mode 0600 and new directories use 0700. On Windows,
60
+ the store's `@cortexkit/private-files` native module creates owner-only ACLs and the
61
+ async path check verifies real security descriptors, not Node's emulated mode bits
62
+ or an AppData-location heuristic. Existing directories are checked, not silently
63
+ repaired. Unsupported filesystems or unavailable native modules refuse the operation.
64
+ Windows requires supported local, ACL-persisting drives; UNC/network paths and
65
+ reparse points are refused. Windows ACL enforcement can only be qualified in CI,
66
+ not by passing Unix tests locally.
67
+
68
+ **Changed in 0.9.0:** Unix ancestors must now be owned by the current effective user
69
+ or root. A foreign-owned ancestor is refused even at mode 0755; a non-root-owned
70
+ sticky ancestor is also refused. Put the files under a folder you own. Root-owned
71
+ sticky directories such as `/tmp` remain usable when the immediate child is your
72
+ own non-group/world-writable directory. The Debian user-private-group exception is
73
+ preserved: an owned, non-sticky group-writable directory (including a 0775
74
+ `~/.config` under umask 0002) is accepted only when local `/etc/passwd` and
75
+ `/etc/group` prove the effective group contains no other users.
76
+
42
77
  ## Testing a consumer of this client
43
78
 
44
79
  Three things on a developer box silently satisfy what a consumer's test is trying to
@@ -5,8 +5,8 @@ export type EnrollmentTokenFile = {
5
5
  /**
6
6
  * Atomically replace a consumer's enrollment credential.
7
7
  *
8
- * The file is always mode 0600. Writable canonical ancestors require sticky or a
9
- * proven user-private group. This is the same rule implemented by the
10
- * shipped `refuse_writable_ancestor` symbol in `opencode_files.rs`.
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>;
@@ -1,7 +1,8 @@
1
- import { acceptsAncestor } from './ancestor-permissions.js';
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 { chmod, mkdir, open, realpath, rename, stat, unlink } from 'node:fs/promises';
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
- let component;
15
- try {
16
- component = await realpath(parent);
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
- * The file is always mode 0600. Writable canonical ancestors require sticky or a
40
- * proven user-private group. This is the same rule implemented by the
41
- * shipped `refuse_writable_ancestor` symbol in `opencode_files.rs`.
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
- await mkdir(parent, { recursive: true, mode: 0o700 });
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 descriptor.writeFile(`${JSON.stringify(value)}\n`, 'utf8');
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, realpath, stat as nodeStat } from 'node:fs/promises';
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 { acceptsAncestor } from './ancestor-permissions.js';
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
- // Group-write counts as well as world-write: a directory writer can unlink and
205
- // replace a mode-0600 file regardless of its file permissions or owner. Owning
206
- // the directory does not exclude other group members. Sticky exempts both bits
207
- // because a writer may unlink only files they own. Without sticky, group-write
208
- // is safe only when local account files prove a user-private effective group;
209
- // world-write is never exempt. Keep this rule identical to Rust's accepts_ancestor
210
- // and parent check in opencode_files.rs.
211
- if (!(await acceptsAncestor(parent))) {
212
- invalid('handle file parent is group- or world-writable without sticky bit');
213
- }
214
- // EVERY ANCESTOR, NOT JUST THIS ONE, OR THE GUARANTEE DOES NOT COMPOSE. The immediate
215
- // parent being 0700 protects nothing when a directory above it is group-writable:
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/index.d.ts CHANGED
@@ -3,5 +3,5 @@ export { storeIdentity, storageFingerprint } from './identity.js';
3
3
  export { writeEnrollmentTokenFile, type EnrollmentTokenFile, } from './enrollment.js';
4
4
  export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, type ManifestHandleAccount, type ManifestHandleFile, type ManifestHandleProvider, type ManifestLockError, type ManifestLockErrorCode, } from './manifest-lock.js';
5
5
  export { ClaustrumCredentialError, credentialErrorAction, ERROR_CLASS_WIRE_SET, type ClaustrumCredentialErrorAction, type ClaustrumCredentialErrorClass, } from './errors.js';
6
- export { ClaustrumClient, type ClaustrumClientOptions, type ClaustrumConnector, type ClaustrumReporterSource, type CredentialStatus, type EnrollmentPollOutcome, type ScopedInventory, type ScopedInventoryRow, type ServedCredential, } from './wire.js';
6
+ export { ClaustrumClient, type ClaustrumClientOptions, type ClaustrumConnector, type ClaustrumReporterSource, type CredentialStatus, type EnrollmentPollOutcome, type ScopedGrantTuple, type ScopedInventory, type ScopedInventoryRow, type ServedCredential, } from './wire.js';
7
7
  export { HANDLE_FILE_CONTRACT, HandleFileValidationError, credentialIdMatchesProvider, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, type HandleAccount, type HandleFileIo, type HandleProvider, type OpenCodeHandleFileV1, } from './handles.js';
@@ -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];
@@ -1,10 +1,10 @@
1
- import { acceptsAncestor } from './ancestor-permissions.js';
2
- import { constants as fsConstants } from 'node:fs';
3
- import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises';
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 = (error) => error?.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
- file = await open(temporary, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
49
- await file.chmod(0o600);
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 mkdir(lock, { mode: 0o700 });
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
- let component;
254
- try {
255
- component = await realpath(parent);
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 mkdir(parent, { recursive: true, mode: 0o700 });
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
- if (process.platform !== 'win32')
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
- handle = await open(temporary, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
307
- await handle.chmod(0o600);
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
- /** Operator assertion, unknown when absent; pricing owns the vocabulary. */
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.
@@ -245,10 +250,38 @@ export interface ScopedInventoryRow {
245
250
  * and deliberately NOT over `record_version`, so a routine token refresh does not move it.
246
251
  * Compare it against the previous call's to decide whether to reconcile at all; it is an
247
252
  * opaque string and nothing but equality is defined on it.
253
+ *
254
+ * `grants` and `grantTuples` say what this caller is GRANTED, independent of what those
255
+ * grants currently reach. They are the only way to tell two empty listings apart:
256
+ *
257
+ * - `rows: []` with `grants === 0`: the caller holds no grant at all. The remedy is a
258
+ * grant (`ck auth grant`).
259
+ * - `rows: []` with `grants > 0`: the caller holds a grant that currently reaches no
260
+ * credential, typically a category no credential carries yet. The remedy is on the
261
+ * credential side (`ck auth set-category`, or `ck auth reclassify --from-registry` for
262
+ * credentials created before categories existed), not another grant.
263
+ *
264
+ * `grantTuples` lists each grant as `{ selectorKind, selector, operation }` (for example
265
+ * `{ selectorKind: 'category', selector: 'openai-native', operation: 'read' }`), so a
266
+ * consumer can name the selector that matched nothing. `grants` is its length.
267
+ *
268
+ * Both are `undefined` when the vault is too old to send them. That means "unknown",
269
+ * not "no grant": do not read an absent count as zero.
248
270
  */
249
271
  export interface ScopedInventory {
250
272
  readonly rows: readonly ScopedInventoryRow[];
251
273
  readonly view: string;
274
+ readonly grants: number | undefined;
275
+ readonly grantTuples: readonly ScopedGrantTuple[] | undefined;
276
+ }
277
+ /** One grant the caller holds, as the vault's `list_scoped` reply names it. */
278
+ export interface ScopedGrantTuple {
279
+ /** `exact` or `category`; decoded as an open string. */
280
+ readonly selectorKind: string;
281
+ /** A credential id for `exact`, a bare category name for `category`. */
282
+ readonly selector: string;
283
+ /** The granted operation, e.g. `read` or `list`; decoded as an open string. */
284
+ readonly operation: string;
252
285
  }
253
286
  /**
254
287
  * Exported for the golden-reply test only; `index.ts` re-exports by name, so this does not
package/dist/wire.js CHANGED
@@ -346,6 +346,7 @@ export class ClaustrumClient {
346
346
  await this.#reconnecting;
347
347
  }
348
348
  }
349
+ const GRANT_TUPLE_KEYS = ['operation', 'selector', 'selector_kind'];
349
350
  /**
350
351
  * Exported for the golden-reply test only; `index.ts` re-exports by name, so this does not
351
352
  * widen the published surface. The test drives THIS function rather than asserting keys,
@@ -407,6 +408,10 @@ export function decodeScopedInventory(response, logUnknownClass) {
407
408
  throw asCredentialError(response, 'invalid_response', logUnknownClass);
408
409
  }
409
410
  const authMethod = row.auth_method;
411
+ const planTierSource = row.plan_tier_source;
412
+ if (planTierSource !== undefined && planTierSource !== 'operator' && planTierSource !== 'detected') {
413
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
414
+ }
410
415
  if (authMethod !== undefined &&
411
416
  authMethod !== 'apikey' && authMethod !== 'chatgpt' &&
412
417
  authMethod !== 'antigravity' && authMethod !== 'oauth') {
@@ -430,6 +435,7 @@ export function decodeScopedInventory(response, logUnknownClass) {
430
435
  providerIds: row.provider_ids === undefined ? [] : strings(row.provider_ids),
431
436
  authMethod,
432
437
  planTier,
438
+ planTierSource,
433
439
  state: row.state,
434
440
  refreshAdapter: row.refresh_adapter,
435
441
  recordVersion,
@@ -440,7 +446,38 @@ export function decodeScopedInventory(response, logUnknownClass) {
440
446
  orgName: row.org_name,
441
447
  };
442
448
  });
443
- return { rows: decoded, view };
449
+ // ABSENT IS UNKNOWN, PRESENT MUST BE WELL-FORMED. A vault from before these fields
450
+ // omits both, and refusing that would fail every listing against a still-supported
451
+ // vault. A present but malformed value is a broken reply, and decoding it leniently
452
+ // would hand the caller a grant count it cannot trust to tell "no grant" from "a grant
453
+ // that reaches nothing".
454
+ const rawGrants = result?.grants;
455
+ let grants;
456
+ if (rawGrants !== undefined) {
457
+ if (typeof rawGrants !== 'number' || !Number.isSafeInteger(rawGrants) || rawGrants < 0) {
458
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
459
+ }
460
+ grants = rawGrants;
461
+ }
462
+ const rawTuples = result?.grant_tuples;
463
+ let grantTuples;
464
+ if (rawTuples !== undefined) {
465
+ if (!Array.isArray(rawTuples))
466
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
467
+ grantTuples = rawTuples.map((tuple) => {
468
+ if (!isRecord(tuple) ||
469
+ Object.keys(tuple).length !== GRANT_TUPLE_KEYS.length ||
470
+ !GRANT_TUPLE_KEYS.every((key) => typeof tuple[key] === 'string')) {
471
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
472
+ }
473
+ return {
474
+ selectorKind: tuple.selector_kind,
475
+ selector: tuple.selector,
476
+ operation: tuple.operation,
477
+ };
478
+ });
479
+ }
480
+ return { rows: decoded, view, grants, grantTuples };
444
481
  }
445
482
  function decodeEnrollmentResult(response, logUnknownClass) {
446
483
  if (hasCredentialError(response))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/claustrum-client",
3
- "version": "0.7.0",
3
+ "version": "0.10.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
- }