@cortexkit/claustrum-client 0.6.0 → 0.6.1

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.
@@ -0,0 +1,20 @@
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>;
@@ -0,0 +1,44 @@
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
+ }
@@ -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 and every canonical ancestor is rejected when it is
9
- * group- or world-writable without sticky. This is the same rule implemented by the
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
10
  * shipped `refuse_writable_ancestor` symbol in `opencode_files.rs`.
11
11
  */
12
12
  export declare function writeEnrollmentTokenFile(path: string, value: EnrollmentTokenFile): Promise<void>;
@@ -1,3 +1,4 @@
1
+ import { acceptsAncestor } from './ancestor-permissions.js';
1
2
  import { randomBytes } from 'node:crypto';
2
3
  import { chmod, mkdir, open, realpath, rename, stat, unlink } from 'node:fs/promises';
3
4
  import { basename, dirname, join } from 'node:path';
@@ -23,7 +24,7 @@ async function refuseWritableAncestor(parent) {
23
24
  const metadata = await stat(component).catch(() => undefined);
24
25
  // Canonicalisation already traversed this component. If a later stat races with a
25
26
  // rename, let the create/rename operation below report the concrete failure.
26
- if (metadata && (metadata.mode & 0o022) !== 0 && (metadata.mode & 0o1000) === 0) {
27
+ if (metadata && !(await acceptsAncestor(metadata))) {
27
28
  throw new Error(`enrollment token ancestor ${component} is group- or world-writable without sticky bit`);
28
29
  }
29
30
  const next = dirname(component);
@@ -35,8 +36,8 @@ async function refuseWritableAncestor(parent) {
35
36
  /**
36
37
  * Atomically replace a consumer's enrollment credential.
37
38
  *
38
- * The file is always mode 0600 and every canonical ancestor is rejected when it is
39
- * group- or world-writable without sticky. This is the same rule implemented by the
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
40
41
  * shipped `refuse_writable_ancestor` symbol in `opencode_files.rs`.
41
42
  */
42
43
  export async function writeEnrollmentTokenFile(path, value) {
package/dist/handles.d.ts CHANGED
@@ -33,6 +33,7 @@ type HandleFileStat = {
33
33
  isSymbolicLink?(): boolean;
34
34
  mode: number;
35
35
  size?: number;
36
+ gid?: number;
36
37
  uid?: number;
37
38
  mtimeMs?: number;
38
39
  };
package/dist/handles.js CHANGED
@@ -3,6 +3,7 @@ import { constants } from 'node:fs';
3
3
  import { lstat as nodeLstat, open as nodeOpen, readFile, realpath, stat as nodeStat } from 'node:fs/promises';
4
4
  import { userInfo } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
+ import { acceptsAncestor } from './ancestor-permissions.js';
6
7
  export const HANDLE_FILE_CONTRACT = {
7
8
  maxBytes: 256 * 1024,
8
9
  mode: 0o600,
@@ -200,16 +201,14 @@ async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
200
201
  if (expectedUid !== undefined && parent.uid !== undefined && parent.uid !== expectedUid) {
201
202
  invalid('handle file parent is not owned by the current uid');
202
203
  }
203
- // GROUP-WRITABLE COUNTS, NOT ONLY WORLD-WRITABLE. Directory write permission governs
204
- // unlink and create, so anyone who can write the parent can replace a mode-0600 file
205
- // wholesale however tightly the file itself is locked. The owner check above does not
206
- // close it: a directory I own can still be 0770, and then any other uid in that group
207
- // can swap the handle file for one of theirs. A cross-uid attacker is not conceded by
208
- // this threat model the way a same-uid one is.
209
- //
210
- // Sticky exempts both bits for one reason: with it set, a writer may only unlink files
211
- // they own. Mirrors the Rust check in opencode_files.rs; the two must not drift.
212
- if ((parent.mode & 0o022) !== 0 && (parent.mode & 0o1000) === 0) {
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))) {
213
212
  invalid('handle file parent is group- or world-writable without sticky bit');
214
213
  }
215
214
  // EVERY ANCESTOR, NOT JUST THIS ONE, OR THE GUARANTEE DOES NOT COMPOSE. The immediate
@@ -244,7 +243,7 @@ async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
244
243
  catch {
245
244
  ancestor = undefined;
246
245
  }
247
- if (ancestor && (ancestor.mode & 0o022) !== 0 && (ancestor.mode & 0o1000) === 0) {
246
+ if (ancestor && !(await acceptsAncestor(ancestor))) {
248
247
  invalid(`handle file ancestor ${component} is group- or world-writable without sticky bit`);
249
248
  }
250
249
  const next = dirname(component);
@@ -1,3 +1,4 @@
1
+ import { acceptsAncestor } from './ancestor-permissions.js';
1
2
  import { constants as fsConstants } from 'node:fs';
2
3
  import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises';
3
4
  import { randomBytes, randomInt } from 'node:crypto';
@@ -263,7 +264,7 @@ async function refuseWritableAncestors(parent) {
263
264
  }
264
265
  for (;;) {
265
266
  const metadata = await stat(component);
266
- if ((metadata.mode & 0o022) !== 0 && (metadata.mode & 0o1000) === 0)
267
+ if (!(await acceptsAncestor(metadata)))
267
268
  throw new Error('handle file ancestor is group- or world-writable without sticky bit');
268
269
  const next = dirname(component);
269
270
  if (next === component)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/claustrum-client",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",