@cortexkit/claustrum-client 0.6.0 → 0.6.2
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 +5 -0
- package/dist/ancestor-permissions.d.ts +20 -0
- package/dist/ancestor-permissions.js +44 -0
- package/dist/enrollment.d.ts +2 -2
- package/dist/enrollment.js +4 -3
- package/dist/handles.d.ts +1 -0
- package/dist/handles.js +10 -11
- package/dist/manifest-lock.js +2 -1
- package/dist/wire.d.ts +5 -1
- package/dist/wire.js +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,6 +23,11 @@ credential caching, refresh, account scheduling, and retry policy.
|
|
|
23
23
|
as a version older than every real one.
|
|
24
24
|
• Unknown server error classes are bounded to `transient`; callers must retry them
|
|
25
25
|
rather than treating a forward-compatible class as permanent.
|
|
26
|
+
• `listScoped` rows always carry `providerIds` (a required field since 0.6.0, so code
|
|
27
|
+
that builds rows by hand, such as test fixtures, must set it). An empty array means
|
|
28
|
+
no provider ids are set. Since 0.6.2 a daemon too old to send the field decodes the
|
|
29
|
+
same way, so a newer client still lists against an older daemon. `authMethod` stays
|
|
30
|
+
optional.
|
|
26
31
|
|
|
27
32
|
The client sends `consumerIdentity: null` for every managed request so inherited
|
|
28
33
|
`SUBC_MODULE_ID` and `SUBC_LAUNCH_NONCE` cannot impersonate a supervising host.
|
|
@@ -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
|
+
}
|
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
|
-
* The file is always mode 0600
|
|
9
|
-
*
|
|
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>;
|
package/dist/enrollment.js
CHANGED
|
@@ -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 && (
|
|
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
|
|
39
|
-
*
|
|
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
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
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
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 && (
|
|
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);
|
package/dist/manifest-lock.js
CHANGED
|
@@ -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 ((
|
|
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/dist/wire.d.ts
CHANGED
|
@@ -206,7 +206,11 @@ export interface ScopedInventoryRow {
|
|
|
206
206
|
readonly categories: readonly string[];
|
|
207
207
|
readonly credentialType: string;
|
|
208
208
|
readonly serves: readonly string[];
|
|
209
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* Operator-assigned catalog ids; empty when none are set. Always present on this type.
|
|
211
|
+
* A daemon older than the field omits it from the wire, and that decodes as empty,
|
|
212
|
+
* which is also what an unmapped credential reports.
|
|
213
|
+
*/
|
|
210
214
|
readonly providerIds: readonly string[];
|
|
211
215
|
readonly authMethod?: 'apikey' | 'chatgpt' | 'antigravity' | 'oauth';
|
|
212
216
|
/**
|
package/dist/wire.js
CHANGED
|
@@ -418,7 +418,11 @@ export function decodeScopedInventory(response, logUnknownClass) {
|
|
|
418
418
|
categories: strings(row.categories),
|
|
419
419
|
credentialType: row.type,
|
|
420
420
|
serves: strings(row.serves),
|
|
421
|
-
|
|
421
|
+
// ABSENT DECODES AS EMPTY, PRESENT MUST BE AN ARRAY. Daemons before provider ids
|
|
422
|
+
// existed omit the key, and refusing that would fail the whole listing against a
|
|
423
|
+
// still-supported daemon. Absent and empty mean the same thing to every consumer:
|
|
424
|
+
// no ids set. A present non-array is a malformed reply and still refuses.
|
|
425
|
+
providerIds: row.provider_ids === undefined ? [] : strings(row.provider_ids),
|
|
422
426
|
authMethod,
|
|
423
427
|
state: row.state,
|
|
424
428
|
refreshAdapter: row.refresh_adapter,
|