@cortexkit/common-auth 0.9.3 → 0.9.5

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.
@@ -91,6 +91,16 @@ export declare function hostEnrollmentPaths(input: {
91
91
  override?: string;
92
92
  cwd?: string;
93
93
  }): ClaustrumEnrollmentPaths;
94
+ /** Account-file paths are injectable for isolated setup tests; no environment overrides. */
95
+ export interface EnrollmentAncestorOptions {
96
+ passwdPath?: string;
97
+ groupPath?: string;
98
+ }
99
+ /**
100
+ * Protect setup writes from directory replacement. Group write access is safe
101
+ * only when local account records prove that the effective user's group is private.
102
+ */
103
+ export declare function refuseWritableAncestor(parent: string, options?: EnrollmentAncestorOptions): Promise<void>;
94
104
  export declare function readClaustrumEnrollmentStatus(paths: ClaustrumEnrollmentPaths, proposedName: string): Promise<ClaustrumEnrollmentStatus>;
95
105
  /**
96
106
  * Read fresh bearer material for one scoped operation; never publish it.
@@ -111,6 +121,7 @@ export declare class ClaustrumEnrollmentManager {
111
121
  now?: () => number;
112
122
  mintSecret?: () => string;
113
123
  writeTokenFile?: typeof writeEnrollmentTokenFile;
124
+ ancestorOptions?: EnrollmentAncestorOptions;
114
125
  });
115
126
  status(): Promise<ClaustrumEnrollmentStatus>;
116
127
  resetTerminal(): Promise<ClaustrumEnrollmentResetResult>;
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
2
  import { constants as fsConstants } from 'node:fs';
3
- import { chmod, mkdir, open, realpath, rename, stat, unlink, } from 'node:fs/promises';
3
+ import { chmod, mkdir, open, readFile, realpath, rename, stat, unlink, } from 'node:fs/promises';
4
4
  import { basename, dirname, extname, isAbsolute, join, resolve, } from 'node:path';
5
5
  import { ClaustrumCredentialError, ClaustrumClient as ClaustrumWireClient, writeEnrollmentTokenFile, } from '@cortexkit/claustrum-client';
6
6
  import { acquireRefreshFileLock } from '../fs/index.js';
@@ -183,7 +183,7 @@ function validateReadableSecretFile(metadata) {
183
183
  if ((metadata.mode & 0o077) !== 0) {
184
184
  throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file must be owner-only');
185
185
  }
186
- const expectedUid = process.getuid?.();
186
+ const expectedUid = process.geteuid?.();
187
187
  if (expectedUid !== undefined && metadata.uid !== expectedUid) {
188
188
  throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file must be owned by the current user');
189
189
  }
@@ -221,11 +221,61 @@ async function readBoundedJson(path) {
221
221
  await descriptor.close();
222
222
  }
223
223
  }
224
+ function accountRecords(text, fields) {
225
+ const records = [];
226
+ for (const line of text.split('\n')) {
227
+ if (line.trim() === '' || line.startsWith('#'))
228
+ continue;
229
+ const parts = line.split(':');
230
+ if (parts.length !== fields ||
231
+ !/^[^\s:,]+$/.test(parts[0] ?? '') ||
232
+ !/^\d+$/.test(parts[2] ?? '') ||
233
+ !Number.isSafeInteger(Number(parts[2])) ||
234
+ (fields === 7 &&
235
+ (!/^\d+$/.test(parts[3] ?? '') ||
236
+ !Number.isSafeInteger(Number(parts[3])))) ||
237
+ (fields === 4 &&
238
+ parts[3] !== '' &&
239
+ !/^[^\s:,]+(?:,[^\s:,]+)*$/.test(parts[3] ?? '')))
240
+ return undefined;
241
+ records.push(parts);
242
+ }
243
+ return records;
244
+ }
245
+ async function isPrivateGroup(uid, gid, options) {
246
+ try {
247
+ const [passwdText, groupText] = await Promise.all([
248
+ readFile(options.passwdPath ?? '/etc/passwd', 'utf8'),
249
+ readFile(options.groupPath ?? '/etc/group', 'utf8'),
250
+ ]);
251
+ const users = accountRecords(passwdText, 7);
252
+ const groups = accountRecords(groupText, 4);
253
+ if (!users || !groups)
254
+ return false;
255
+ const owners = users.filter((user) => Number(user[2]) === uid);
256
+ const matchingGroups = groups.filter((group) => Number(group[2]) === gid);
257
+ if (owners.length !== 1 || matchingGroups.length !== 1)
258
+ return false;
259
+ const name = owners[0]?.[0];
260
+ const members = matchingGroups[0]?.[3] ?? '';
261
+ return ((members === '' ||
262
+ members.split(',').every((member) => member === name)) &&
263
+ !users.some((user) => Number(user[2]) !== uid && Number(user[3]) === gid));
264
+ }
265
+ catch {
266
+ // Missing local records (including NSS-only accounts) cannot prove exclusivity.
267
+ return false;
268
+ }
269
+ }
224
270
  /**
225
- * Refuse a path below any group- or world-writable directory without the
226
- * sticky bit: another user could swap the file out from under us there.
271
+ * Protect setup writes from directory replacement. Group write access is safe
272
+ * only when local account records prove that the effective user's group is private.
227
273
  */
228
- async function refuseWritableAncestor(parent) {
274
+ export async function refuseWritableAncestor(parent, options = {}) {
275
+ const uid = process.geteuid?.();
276
+ if (uid === undefined)
277
+ return;
278
+ const gid = process.getegid?.();
229
279
  let component;
230
280
  try {
231
281
  component = await realpath(parent);
@@ -233,12 +283,23 @@ async function refuseWritableAncestor(parent) {
233
283
  catch {
234
284
  return;
235
285
  }
286
+ // Load lazily, once per traversal: account changes take effect on the next write.
287
+ let privateGroup;
236
288
  for (;;) {
237
289
  const metadata = await stat(component).catch(() => undefined);
238
290
  if (metadata &&
239
291
  (metadata.mode & 0o022) !== 0 &&
240
292
  (metadata.mode & 0o1000) === 0) {
241
- throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment path has an unsafe writable ancestor');
293
+ if (privateGroup === undefined) {
294
+ privateGroup =
295
+ gid !== undefined && (await isPrivateGroup(uid, gid, options));
296
+ }
297
+ if ((metadata.mode & 0o002) !== 0 ||
298
+ metadata.uid !== uid ||
299
+ metadata.gid !== gid ||
300
+ !privateGroup) {
301
+ throw new ClaustrumConsumerError('unsafe-file', `Claustrum enrollment path has a writable ancestor: ${component}. Another user could replace it. Run: chmod g-w,o-w ${component}`);
302
+ }
242
303
  }
243
304
  const next = dirname(component);
244
305
  if (next === component)
@@ -254,17 +315,17 @@ async function refuseWritableAncestor(parent) {
254
315
  async function ensurePrivateDirectory(directory) {
255
316
  await mkdir(directory, { recursive: true, mode: 0o700 });
256
317
  const metadata = await stat(directory);
257
- const uid = process.getuid?.();
318
+ const uid = process.geteuid?.();
258
319
  if (uid !== undefined && metadata.uid !== uid) {
259
320
  throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment directory must be owned by the current user');
260
321
  }
261
322
  if ((metadata.mode & 0o077) !== 0)
262
323
  await chmod(directory, 0o700);
263
324
  }
264
- async function writeStateAtomic(path, state) {
325
+ async function writeStateAtomic(path, state, ancestorOptions = {}) {
265
326
  const parent = dirname(path);
266
327
  await ensurePrivateDirectory(parent);
267
- await refuseWritableAncestor(parent);
328
+ await refuseWritableAncestor(parent, ancestorOptions);
268
329
  const bytes = `${JSON.stringify(state)}\n`;
269
330
  if (Buffer.byteLength(bytes) > ENROLLMENT_FILE_MAX_BYTES) {
270
331
  throw new ClaustrumConsumerError('invalid-state', 'Claustrum enrollment state is too large');
@@ -321,7 +382,10 @@ export async function readClaustrumEnrollmentStatus(paths, proposedName) {
321
382
  * Re-reading per operation is what lets an operator reissue a token on disk.
322
383
  */
323
384
  export async function readClaustrumEnrollmentToken(tokenPath) {
324
- await refuseWritableAncestor(tokenPath);
385
+ // Reads rely on O_NOFOLLOW and regular, owner-only, effective-uid-owned files:
386
+ // another user cannot plant a file owned by us. Ancestor checks protect writes
387
+ // from placing our secret in a swapped directory, not reads; keep their
388
+ // realpath/stat and account-file I/O off the authorization path.
325
389
  const value = await readBoundedJson(tokenPath);
326
390
  if (value === undefined)
327
391
  throw new ClaustrumConsumerError('not-enrolled', 'Claustrum enrollment is not configured');
@@ -368,7 +432,9 @@ export class ClaustrumEnrollmentManager {
368
432
  #now;
369
433
  #mintSecret;
370
434
  #writeTokenFile;
435
+ #ancestorOptions;
371
436
  constructor(options) {
437
+ this.#ancestorOptions = options.ancestorOptions ?? {};
372
438
  this.#client = options.client;
373
439
  this.#paths = options.paths;
374
440
  this.#proposedName = options.proposedName;
@@ -392,7 +458,7 @@ export class ClaustrumEnrollmentManager {
392
458
  errorCode: code,
393
459
  updatedAt: this.#now(),
394
460
  };
395
- await writeStateAtomic(this.#paths.statePath, blocked);
461
+ await writeStateAtomic(this.#paths.statePath, blocked, this.#ancestorOptions);
396
462
  return statusFromState(blocked);
397
463
  }
398
464
  async reconcile() {
@@ -421,7 +487,7 @@ export class ClaustrumEnrollmentManager {
421
487
  tokenGeneration: token.token_generation,
422
488
  updatedAt: this.#now(),
423
489
  };
424
- await writeStateAtomic(this.#paths.statePath, approved);
490
+ await writeStateAtomic(this.#paths.statePath, approved, this.#ancestorOptions);
425
491
  state = approved;
426
492
  }
427
493
  return {
@@ -442,7 +508,7 @@ export class ClaustrumEnrollmentManager {
442
508
  errorCode: 'missing_token',
443
509
  updatedAt: this.#now(),
444
510
  };
445
- await writeStateAtomic(this.#paths.statePath, blocked);
511
+ await writeStateAtomic(this.#paths.statePath, blocked, this.#ancestorOptions);
446
512
  return statusFromState(blocked);
447
513
  }
448
514
  if (state && state.phase !== 'pending')
@@ -462,7 +528,7 @@ export class ClaustrumEnrollmentManager {
462
528
  // The secret is on disk before the proposal leaves the process: the
463
529
  // vault answers a repeated proposal with the same secret with the same
464
530
  // request id, so a crash between propose and saving the id costs nothing.
465
- await writeStateAtomic(this.#paths.statePath, state);
531
+ await writeStateAtomic(this.#paths.statePath, state, this.#ancestorOptions);
466
532
  }
467
533
  if (!state.requestId) {
468
534
  try {
@@ -478,7 +544,7 @@ export class ClaustrumEnrollmentManager {
478
544
  requestId: proposed.requestId,
479
545
  updatedAt: this.#now(),
480
546
  };
481
- await writeStateAtomic(this.#paths.statePath, state);
547
+ await writeStateAtomic(this.#paths.statePath, state, this.#ancestorOptions);
482
548
  }
483
549
  catch (error) {
484
550
  const refusal = classifyEnrollmentError(error);
@@ -511,11 +577,13 @@ export class ClaustrumEnrollmentManager {
511
577
  proposedName: state.proposedName,
512
578
  updatedAt: this.#now(),
513
579
  };
514
- await writeStateAtomic(this.#paths.statePath, denied);
580
+ await writeStateAtomic(this.#paths.statePath, denied, this.#ancestorOptions);
515
581
  return statusFromState(denied);
516
582
  }
517
583
  // The vault returns the token exactly once: it reaches disk before the
518
584
  // pending metadata (and its secret) is replaced.
585
+ await ensurePrivateDirectory(dirname(this.#paths.tokenPath));
586
+ await refuseWritableAncestor(dirname(this.#paths.tokenPath), this.#ancestorOptions);
519
587
  await this.#writeTokenFile(this.#paths.tokenPath, {
520
588
  token: outcome.token,
521
589
  token_generation: outcome.tokenGeneration,
@@ -528,7 +596,7 @@ export class ClaustrumEnrollmentManager {
528
596
  tokenGeneration: outcome.tokenGeneration,
529
597
  updatedAt: this.#now(),
530
598
  };
531
- await writeStateAtomic(this.#paths.statePath, approved);
599
+ await writeStateAtomic(this.#paths.statePath, approved, this.#ancestorOptions);
532
600
  return statusFromState(approved);
533
601
  }
534
602
  catch (error) {
@@ -7,7 +7,7 @@ export { type ClaustrumEnrollmentClient, type ClaustrumEnrollmentConnection, Cla
7
7
  export { ClaustrumConsumerError, type ClaustrumConsumerFailureKind, type ClaustrumLogger, } from './errors.js';
8
8
  export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, type HostSlotContent, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
9
9
  export { acceptAccount, type DeclinedAccount, declineAccount, isDeclined, } from './interlock.js';
10
- export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, readVaultRoster, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterRow, vaultRoutingRows, } from './roster.js';
10
+ export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, readVaultRoster, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterLockContext, type VaultRosterRow, vaultRoutingRows, } from './roster.js';
11
11
  /**
12
12
  * Connect the client that lists and fetches this consumer's vault credentials
13
13
  * on the request path. `connectionFile` is required: this library
@@ -86,11 +86,16 @@ export declare function projectVaultRoster(previous: VaultRosterFile | undefined
86
86
  */
87
87
  export declare function vaultRoutingRows(roster: VaultRosterFile | undefined): RoutingRow[];
88
88
  export declare function readVaultRoster(path: string): Promise<VaultRosterFile | undefined>;
89
+ /** The ownership assertion available to a roster mutation callback. */
90
+ export interface VaultRosterLockContext {
91
+ assertOwned: () => Promise<void>;
92
+ }
89
93
  /**
90
94
  * Read, change and write the roster under its write lock. `change` returns
91
- * undefined to leave the file as it is.
95
+ * undefined to leave the file as it is. Call `assertOwned()` immediately
96
+ * before any rename performed inside `change`; if it throws, do not rename.
92
97
  */
93
- export declare function mutateVaultRoster<T>(path: string, change: (current: VaultRosterFile | undefined) => Promise<{
98
+ export declare function mutateVaultRoster<T>(path: string, change: (current: VaultRosterFile | undefined, context: VaultRosterLockContext) => Promise<{
94
99
  next?: VaultRosterFile;
95
100
  result: T;
96
101
  }> | {
@@ -313,12 +313,15 @@ const WRITE_LOCK = {
313
313
  const DISCOVERY_LEASE = { name: 'claustrum-roster', ttlMs: 30_000 };
314
314
  /**
315
315
  * Read, change and write the roster under its write lock. `change` returns
316
- * undefined to leave the file as it is.
316
+ * undefined to leave the file as it is. Call `assertOwned()` immediately
317
+ * before any rename performed inside `change`; if it throws, do not rename.
317
318
  */
318
319
  export async function mutateVaultRoster(path, change) {
319
320
  return withLock(path, WRITE_LOCK, async (lock) => {
320
321
  const current = await readVaultRoster(path);
321
- const { next, result } = await change(current);
322
+ const { next, result } = await change(current, {
323
+ assertOwned: () => lock.assertOwned(),
324
+ });
322
325
  if (next) {
323
326
  await lock.assertOwned();
324
327
  await writeJsonAtomic(path, next);
@@ -61,6 +61,10 @@ async function withDeadline(work, ms) {
61
61
  let timer;
62
62
  const deadline = new Promise((_, reject) => {
63
63
  timer = setTimeout(() => reject(new ApplyDeadlineError('apply deadline exceeded')), ms);
64
+ // The deadline only bounds a reply. While a request is open its socket
65
+ // keeps the process alive anyway; after stop() closes it, a handler that
66
+ // never settles must not hold the process open until the deadline.
67
+ timer.unref?.();
64
68
  });
65
69
  // A handler that fails after its deadline has nobody left to answer.
66
70
  work.catch(() => { });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -94,7 +94,7 @@
94
94
  "lint": "biome check .",
95
95
  "prepublishOnly": "bun run build",
96
96
  "check:ranges": "bun scripts/check-installed-ranges.mjs",
97
- "check:claustrum-040": "bun scripts/check-claustrum-040.mjs",
97
+ "check:claustrum-floor": "bun scripts/check-claustrum-floor.mjs",
98
98
  "check:opentui-0511": "bun scripts/check-opentui-0511.mjs"
99
99
  },
100
100
  "dependencies": {
@@ -104,7 +104,7 @@
104
104
  "@opentui/core": ">=0.5.11",
105
105
  "@opentui/solid": ">=0.5.11",
106
106
  "solid-js": "1.9.12",
107
- "@cortexkit/claustrum-client": ">=0.4.0",
107
+ "@cortexkit/claustrum-client": ">=0.6.2",
108
108
  "@opencode/plugin": ">=2.0.21"
109
109
  },
110
110
  "peerDependenciesMeta": {
@@ -126,7 +126,7 @@
126
126
  },
127
127
  "devDependencies": {
128
128
  "@biomejs/biome": "2.5.14",
129
- "@cortexkit/claustrum-client": "0.5.0",
129
+ "@cortexkit/claustrum-client": "0.6.2",
130
130
  "@opencode/plugin": "2.0.21",
131
131
  "@opentui/core": ">=0.5.12",
132
132
  "@opentui/solid": ">=0.5.12",