@volter/world-core 3.0.69 → 3.0.71

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.
@@ -8,7 +8,7 @@ const { allowsWorldNetworkEgress, isWorldInternalHost, worldNetworkNeedsPathInsp
8
8
 
9
9
  /** The declaration of a guest with no declaration format: an ordinary host process, which can reach what the host can.
10
10
  * Its own loopback is what it observably opened (`facts.ownListener`). A substrate whose guest can declare never uses it. */
11
- const OPEN_DECLARATION = Object.freeze({ network: Object.freeze({ egress: '*', services: '*', host: true }) });
11
+ const OPEN_DECLARATION = Object.freeze({ network: Object.freeze({ egress: '*', services: '*', host: true }), files: Object.freeze({ host: true }) });
12
12
 
13
13
  /** What an unreadable declaration, or one bound to another World, declares: nothing. */
14
14
  const EMPTY_DECLARATION = Object.freeze({ network: Object.freeze({}) });
@@ -216,6 +216,44 @@ function decideEgress(request, context) {
216
216
  return pass(capability, target, policy && worldNetworkUsesPassthrough(policy, url) ? 'passthrough' : 'direct');
217
217
  }
218
218
 
219
+ /** `path` as a normalized absolute POSIX path, or null: `.` and `..` resolved, no symlink followed (that is I/O). */
220
+ function normalPath(path) {
221
+ if (typeof path !== 'string' || !path.startsWith('/') || path.includes('\0')) return null;
222
+ const parts = [];
223
+ for (const segment of path.split('/')) {
224
+ if (!segment || segment === '.') continue;
225
+ if (segment === '..') parts.pop(); else parts.push(segment);
226
+ }
227
+ return `/${parts.join('/')}`;
228
+ }
229
+ function within(path, root) {
230
+ const base = normalPath(root);
231
+ return !!base && (path === base || path.startsWith(base === '/' ? '/' : `${base}/`));
232
+ }
233
+
234
+ /**
235
+ * A file the guest reads (`request.path`). It reads its own tree (`files.tree`, implicit for an image) at `facts.tree`,
236
+ * the World's provided files (`facts.worldFiles`), the measured system paths (`facts.system`) and its declared mounts
237
+ * (`files.mounts`) inside the World's root (`facts.worldRoot`); a host process reads the host's files (`files.host`).
238
+ */
239
+ function decideRead(request, { declaration, facts = {} }) {
240
+ const capability = 'files.read';
241
+ const target = String(request.path);
242
+ const path = normalPath(target);
243
+ const files = (declaration && declaration.files) || {};
244
+ if (!path) return refuse(capability, target, 'declaration', 'read-invalid', `${target} is not an absolute path`, declaration);
245
+ if (files.host === true) return pass(capability, path, 'host');
246
+ if (files.tree === true && facts.tree && within(path, facts.tree)) return pass(capability, path, 'own-tree');
247
+ if ((facts.worldFiles || []).some((root) => within(path, root))) return pass(capability, path, 'world');
248
+ if ((facts.system || []).some((root) => within(path, root))) return pass(capability, path, 'system');
249
+ const mount = (Array.isArray(files.mounts) ? files.mounts : []).find((root) => within(path, root));
250
+ if (mount) {
251
+ return facts.worldRoot && within(normalPath(mount) || '', facts.worldRoot) ? pass(capability, path, 'mount')
252
+ : refuse(capability, path, 'grant', 'mount-outside-world', `the mount ${mount} is outside this World's folder: no World grants a host path it does not own; mount it from inside the World's folder`, declaration);
253
+ }
254
+ return refuse(capability, path, 'declaration', 'read-undeclared', `in Docker this container cannot read ${path}: it sees its own filesystem, the World's files and its declared mounts; declare a mount`, declaration);
255
+ }
256
+
219
257
  /** Whether the World's build-phase grant lists `url` itself, which then wins over a twin of its host. */
220
258
  function publicBuildGrant(grant, url) {
221
259
  return grant.strict !== true && !!grant.policy && grant.policy.phase === 'build' && allowsWorldNetworkEgress(grant.policy, url);
@@ -231,14 +269,15 @@ function decidePersist(request, { declaration }) {
231
269
  }
232
270
 
233
271
  /**
234
- * A connection the decision let through that the run's sandbox refused (a native image run's Seatbelt answers EPERM):
235
- * the enforcer's rendering is narrower than the decision. One text for every reporter: the injector for Node, the
236
- * images CLI's kernel-log reporter for other programs (whose target is `*:<port>`: the kernel masks the address).
272
+ * A connection the decision let through that a native image run's Seatbelt profile refused (EPERM): the native run
273
+ * admits only the World proxy and loopback, so the World sees each request, and a client that dials out directly fails
274
+ * there. One text for every reporter: the injector for Node, the images CLI's kernel-log reporter for other programs
275
+ * (whose target is `*:<port>`: the kernel masks the address). The profile reproduces Docker; it is no containment.
237
276
  */
238
277
  function sandboxRefusal(target) {
239
- return refuse('network.egress', target, 'transport', 'sandbox',
240
- `the run's sandbox refused a connection to ${target} that the World's decision let through: the sandbox admits only `
241
- + 'the World proxy and loopback, so a client reaches other hosts through the World proxy (HTTPS_PROXY)');
278
+ return refuse('network.egress', target, 'transport', 'native-direct',
279
+ `the native run reaches ${target} only through the World proxy, and this client dialled it directly; `
280
+ + 'send it through the World proxy (HTTPS_PROXY)');
242
281
  }
243
282
 
244
283
  /**
@@ -249,12 +288,14 @@ function decideCapability(request, context) {
249
288
  if (request.kind === 'egress') return decideEgress(request, context);
250
289
  if (request.kind === 'loopback') return decideLoopback(request, context);
251
290
  if (request.kind === 'persist') return decidePersist(request, context);
291
+ if (request.kind === 'read') return decideRead(request, context);
252
292
  throw new Error(`Unknown capability request: ${request.kind}`);
253
293
  }
254
294
 
255
295
  /**
256
296
  * The same rules evaluated up front, for an enforcer that renders a profile instead of asking per connection (a native
257
- * image run's Seatbelt profile). `world.endpoints`: [{ host, port, kind: 'door' | 'service', name }].
297
+ * image run's Seatbelt profile). `world.endpoints`: [{ host, port, kind: 'door' | 'service', name }]; `world.files`:
298
+ * { tree, worldFiles, system, worldRoot }, the read facts.
258
299
  * Egress is `proxy` when declared: twin routing and the per-request listing are the World proxy's, which a profile
259
300
  * forces traffic through; `none` when the guest declares none.
260
301
  */
@@ -280,7 +321,20 @@ function decideCapabilitySet(declaration, grant = {}, world = {}) {
280
321
  const answer = decidePersist({ path }, context);
281
322
  if (answer.outcome === 'pass') persist.push(path); else refusals.push(answer);
282
323
  }
283
- return Object.freeze({ network: Object.freeze({ egress, loopback, listen, endpoints }), files: Object.freeze({ persist }), refusals: Object.freeze(refusals) });
324
+ const facts = world.files || {};
325
+ const declared = (declaration && declaration.files) || {};
326
+ const read = [];
327
+ if (declared.host === true) read.push({ path: '/', kind: 'host' });
328
+ else {
329
+ if (declared.tree === true && facts.tree) read.push({ path: normalPath(facts.tree), kind: 'own-tree' });
330
+ for (const root of facts.worldFiles || []) read.push({ path: normalPath(root), kind: 'world' });
331
+ for (const root of facts.system || []) read.push({ path: normalPath(root), kind: 'system' });
332
+ for (const mount of Array.isArray(declared.mounts) ? declared.mounts : []) {
333
+ const answer = decideRead({ path: mount }, { declaration, facts: { worldRoot: facts.worldRoot } });
334
+ if (answer.outcome === 'pass') read.push({ path: normalPath(mount), kind: 'mount' }); else refusals.push(answer);
335
+ }
336
+ }
337
+ return Object.freeze({ network: Object.freeze({ egress, loopback, listen, endpoints }), files: Object.freeze({ persist, read: Object.freeze(read.filter((r) => r.path)) }), refusals: Object.freeze(refusals) });
284
338
  }
285
339
 
286
340
  module.exports = { OPEN_DECLARATION, EMPTY_DECLARATION, WORLD_DECLARATION_ENV, GRANT_FIELDS, worldDeclarationFromEnv, publicBuildGrant, decideCapability, decideCapabilitySet, sandboxRefusal, refusalRecord, refusalSide };
@@ -1,6 +1,6 @@
1
1
  import type { WorldNetworkPolicy } from './network-policy.cjs';
2
2
 
3
- export type CapabilityName = 'network.egress' | 'network.ownLoopback' | 'network.services' | 'network.host' | 'files.persist';
3
+ export type CapabilityName = 'network.egress' | 'network.ownLoopback' | 'network.services' | 'network.host' | 'files.read' | 'files.persist';
4
4
  export type RefusalSide = 'declaration' | 'grant' | 'transport' | 'twin';
5
5
  export type DeclaredBy = { readonly capability: CapabilityName; readonly file: string; readonly line: number };
6
6
 
@@ -13,7 +13,7 @@ export interface CapabilityDeclaration {
13
13
  readonly services?: '*' | readonly string[];
14
14
  readonly host?: boolean;
15
15
  };
16
- readonly files?: { readonly persist?: readonly string[] };
16
+ readonly files?: { readonly persist?: readonly string[]; readonly tree?: boolean; readonly mounts?: readonly string[]; readonly host?: boolean };
17
17
  readonly derivedFrom?: readonly DeclaredBy[];
18
18
  }
19
19
 
@@ -27,16 +27,22 @@ export interface CapabilityFacts {
27
27
  readonly guestProxy?: URL | null;
28
28
  readonly endpoint?: Pick<WorldEndpoint, 'kind' | 'name'> | null;
29
29
  readonly ownListener?: boolean;
30
+ /** read facts: the run's tree, the World's provided files, the measured system paths, the World's root */
31
+ readonly tree?: string;
32
+ readonly worldFiles?: readonly string[];
33
+ readonly system?: readonly string[];
34
+ readonly worldRoot?: string;
30
35
  }
31
36
  export interface CapabilityContext { readonly run?: { readonly guest?: boolean }; readonly declaration?: CapabilityDeclaration; readonly grant?: WorldGrant; readonly facts?: CapabilityFacts }
32
37
 
33
38
  export type CapabilityRequest =
34
39
  | { readonly kind: 'egress'; readonly url: string | URL | null; readonly method?: string; readonly transport?: 'http' | 'upgrade' | 'http2' | 'dispatch' | 'socket' | 'datagram'; readonly port?: number }
35
40
  | { readonly kind: 'loopback'; readonly host: string; readonly port?: number; readonly transport?: 'tcp' | 'udp' }
36
- | { readonly kind: 'persist'; readonly path: string };
41
+ | { readonly kind: 'persist'; readonly path: string }
42
+ | { readonly kind: 'read'; readonly path: string };
37
43
 
38
44
  export type CapabilityAnswer =
39
- | { readonly outcome: 'pass'; readonly capability: CapabilityName; readonly target: string; readonly via: 'direct' | 'inspected' | 'passthrough' | 'guest-proxy' | 'run-scoped' }
45
+ | { readonly outcome: 'pass'; readonly capability: CapabilityName; readonly target: string; readonly via: 'direct' | 'inspected' | 'passthrough' | 'guest-proxy' | 'run-scoped' | 'host' | 'own-tree' | 'world' | 'system' | 'mount' }
40
46
  | { readonly outcome: 'twin'; readonly capability: CapabilityName; readonly target: string; readonly twin: { readonly vendor: string; readonly origin: string } }
41
47
  | { readonly outcome: 'refuse'; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly rule: string; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy; readonly see?: string };
42
48
 
@@ -44,7 +50,7 @@ export interface WorldRefusalRecord { readonly kind: 'volter-world-refusal'; rea
44
50
 
45
51
  export interface CapabilitySet {
46
52
  readonly network: { readonly egress: 'none' | 'proxy'; readonly loopback: 'none' | 'own'; readonly listen: readonly number[]; readonly endpoints: readonly WorldEndpoint[] };
47
- readonly files: { readonly persist: readonly string[] };
53
+ readonly files: { readonly persist: readonly string[]; readonly read: readonly { readonly path: string; readonly kind: 'host' | 'own-tree' | 'world' | 'system' | 'mount' }[] };
48
54
  readonly refusals: readonly Extract<CapabilityAnswer, { outcome: 'refuse' }>[];
49
55
  }
50
56
 
@@ -56,6 +62,6 @@ export function sandboxRefusal(target: string): Extract<CapabilityAnswer, { outc
56
62
  export const GRANT_FIELDS: Readonly<Partial<Record<CapabilityName, string>>>;
57
63
  export function publicBuildGrant(grant: WorldGrant, url: URL): boolean;
58
64
  export function decideCapability(request: CapabilityRequest, context: CapabilityContext): CapabilityAnswer;
59
- export function decideCapabilitySet(declaration: CapabilityDeclaration | undefined, grant?: WorldGrant, world?: { readonly endpoints?: readonly WorldEndpoint[] }): CapabilitySet;
65
+ export function decideCapabilitySet(declaration: CapabilityDeclaration | undefined, grant?: WorldGrant, world?: { readonly endpoints?: readonly WorldEndpoint[]; readonly files?: { readonly tree?: string; readonly worldFiles?: readonly string[]; readonly system?: readonly string[]; readonly worldRoot?: string } }): CapabilitySet;
60
66
  export function refusalRecord(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): WorldRefusalRecord;
61
67
  export function refusalSide(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): string;
@@ -8,7 +8,7 @@ const { allowsWorldNetworkEgress, isWorldInternalHost, worldNetworkNeedsPathInsp
8
8
 
9
9
  /** The declaration of a guest with no declaration format: an ordinary host process, which can reach what the host can.
10
10
  * Its own loopback is what it observably opened (`facts.ownListener`). A substrate whose guest can declare never uses it. */
11
- const OPEN_DECLARATION = Object.freeze({ network: Object.freeze({ egress: '*', services: '*', host: true }) });
11
+ const OPEN_DECLARATION = Object.freeze({ network: Object.freeze({ egress: '*', services: '*', host: true }), files: Object.freeze({ host: true }) });
12
12
 
13
13
  /** What an unreadable declaration, or one bound to another World, declares: nothing. */
14
14
  const EMPTY_DECLARATION = Object.freeze({ network: Object.freeze({}) });
@@ -216,6 +216,44 @@ function decideEgress(request, context) {
216
216
  return pass(capability, target, policy && worldNetworkUsesPassthrough(policy, url) ? 'passthrough' : 'direct');
217
217
  }
218
218
 
219
+ /** `path` as a normalized absolute POSIX path, or null: `.` and `..` resolved, no symlink followed (that is I/O). */
220
+ function normalPath(path) {
221
+ if (typeof path !== 'string' || !path.startsWith('/') || path.includes('\0')) return null;
222
+ const parts = [];
223
+ for (const segment of path.split('/')) {
224
+ if (!segment || segment === '.') continue;
225
+ if (segment === '..') parts.pop(); else parts.push(segment);
226
+ }
227
+ return `/${parts.join('/')}`;
228
+ }
229
+ function within(path, root) {
230
+ const base = normalPath(root);
231
+ return !!base && (path === base || path.startsWith(base === '/' ? '/' : `${base}/`));
232
+ }
233
+
234
+ /**
235
+ * A file the guest reads (`request.path`). It reads its own tree (`files.tree`, implicit for an image) at `facts.tree`,
236
+ * the World's provided files (`facts.worldFiles`), the measured system paths (`facts.system`) and its declared mounts
237
+ * (`files.mounts`) inside the World's root (`facts.worldRoot`); a host process reads the host's files (`files.host`).
238
+ */
239
+ function decideRead(request, { declaration, facts = {} }) {
240
+ const capability = 'files.read';
241
+ const target = String(request.path);
242
+ const path = normalPath(target);
243
+ const files = (declaration && declaration.files) || {};
244
+ if (!path) return refuse(capability, target, 'declaration', 'read-invalid', `${target} is not an absolute path`, declaration);
245
+ if (files.host === true) return pass(capability, path, 'host');
246
+ if (files.tree === true && facts.tree && within(path, facts.tree)) return pass(capability, path, 'own-tree');
247
+ if ((facts.worldFiles || []).some((root) => within(path, root))) return pass(capability, path, 'world');
248
+ if ((facts.system || []).some((root) => within(path, root))) return pass(capability, path, 'system');
249
+ const mount = (Array.isArray(files.mounts) ? files.mounts : []).find((root) => within(path, root));
250
+ if (mount) {
251
+ return facts.worldRoot && within(normalPath(mount) || '', facts.worldRoot) ? pass(capability, path, 'mount')
252
+ : refuse(capability, path, 'grant', 'mount-outside-world', `the mount ${mount} is outside this World's folder: no World grants a host path it does not own; mount it from inside the World's folder`, declaration);
253
+ }
254
+ return refuse(capability, path, 'declaration', 'read-undeclared', `in Docker this container cannot read ${path}: it sees its own filesystem, the World's files and its declared mounts; declare a mount`, declaration);
255
+ }
256
+
219
257
  /** Whether the World's build-phase grant lists `url` itself, which then wins over a twin of its host. */
220
258
  function publicBuildGrant(grant, url) {
221
259
  return grant.strict !== true && !!grant.policy && grant.policy.phase === 'build' && allowsWorldNetworkEgress(grant.policy, url);
@@ -231,14 +269,15 @@ function decidePersist(request, { declaration }) {
231
269
  }
232
270
 
233
271
  /**
234
- * A connection the decision let through that the run's sandbox refused (a native image run's Seatbelt answers EPERM):
235
- * the enforcer's rendering is narrower than the decision. One text for every reporter: the injector for Node, the
236
- * images CLI's kernel-log reporter for other programs (whose target is `*:<port>`: the kernel masks the address).
272
+ * A connection the decision let through that a native image run's Seatbelt profile refused (EPERM): the native run
273
+ * admits only the World proxy and loopback, so the World sees each request, and a client that dials out directly fails
274
+ * there. One text for every reporter: the injector for Node, the images CLI's kernel-log reporter for other programs
275
+ * (whose target is `*:<port>`: the kernel masks the address). The profile reproduces Docker; it is no containment.
237
276
  */
238
277
  function sandboxRefusal(target) {
239
- return refuse('network.egress', target, 'transport', 'sandbox',
240
- `the run's sandbox refused a connection to ${target} that the World's decision let through: the sandbox admits only `
241
- + 'the World proxy and loopback, so a client reaches other hosts through the World proxy (HTTPS_PROXY)');
278
+ return refuse('network.egress', target, 'transport', 'native-direct',
279
+ `the native run reaches ${target} only through the World proxy, and this client dialled it directly; `
280
+ + 'send it through the World proxy (HTTPS_PROXY)');
242
281
  }
243
282
 
244
283
  /**
@@ -249,12 +288,14 @@ function decideCapability(request, context) {
249
288
  if (request.kind === 'egress') return decideEgress(request, context);
250
289
  if (request.kind === 'loopback') return decideLoopback(request, context);
251
290
  if (request.kind === 'persist') return decidePersist(request, context);
291
+ if (request.kind === 'read') return decideRead(request, context);
252
292
  throw new Error(`Unknown capability request: ${request.kind}`);
253
293
  }
254
294
 
255
295
  /**
256
296
  * The same rules evaluated up front, for an enforcer that renders a profile instead of asking per connection (a native
257
- * image run's Seatbelt profile). `world.endpoints`: [{ host, port, kind: 'door' | 'service', name }].
297
+ * image run's Seatbelt profile). `world.endpoints`: [{ host, port, kind: 'door' | 'service', name }]; `world.files`:
298
+ * { tree, worldFiles, system, worldRoot }, the read facts.
258
299
  * Egress is `proxy` when declared: twin routing and the per-request listing are the World proxy's, which a profile
259
300
  * forces traffic through; `none` when the guest declares none.
260
301
  */
@@ -280,7 +321,20 @@ function decideCapabilitySet(declaration, grant = {}, world = {}) {
280
321
  const answer = decidePersist({ path }, context);
281
322
  if (answer.outcome === 'pass') persist.push(path); else refusals.push(answer);
282
323
  }
283
- return Object.freeze({ network: Object.freeze({ egress, loopback, listen, endpoints }), files: Object.freeze({ persist }), refusals: Object.freeze(refusals) });
324
+ const facts = world.files || {};
325
+ const declared = (declaration && declaration.files) || {};
326
+ const read = [];
327
+ if (declared.host === true) read.push({ path: '/', kind: 'host' });
328
+ else {
329
+ if (declared.tree === true && facts.tree) read.push({ path: normalPath(facts.tree), kind: 'own-tree' });
330
+ for (const root of facts.worldFiles || []) read.push({ path: normalPath(root), kind: 'world' });
331
+ for (const root of facts.system || []) read.push({ path: normalPath(root), kind: 'system' });
332
+ for (const mount of Array.isArray(declared.mounts) ? declared.mounts : []) {
333
+ const answer = decideRead({ path: mount }, { declaration, facts: { worldRoot: facts.worldRoot } });
334
+ if (answer.outcome === 'pass') read.push({ path: normalPath(mount), kind: 'mount' }); else refusals.push(answer);
335
+ }
336
+ }
337
+ return Object.freeze({ network: Object.freeze({ egress, loopback, listen, endpoints }), files: Object.freeze({ persist, read: Object.freeze(read.filter((r) => r.path)) }), refusals: Object.freeze(refusals) });
284
338
  }
285
339
 
286
340
  module.exports = { OPEN_DECLARATION, EMPTY_DECLARATION, WORLD_DECLARATION_ENV, GRANT_FIELDS, worldDeclarationFromEnv, publicBuildGrant, decideCapability, decideCapabilitySet, sandboxRefusal, refusalRecord, refusalSide };
@@ -1,6 +1,6 @@
1
1
  import type { WorldNetworkPolicy } from './network-policy.cjs';
2
2
 
3
- export type CapabilityName = 'network.egress' | 'network.ownLoopback' | 'network.services' | 'network.host' | 'files.persist';
3
+ export type CapabilityName = 'network.egress' | 'network.ownLoopback' | 'network.services' | 'network.host' | 'files.read' | 'files.persist';
4
4
  export type RefusalSide = 'declaration' | 'grant' | 'transport' | 'twin';
5
5
  export type DeclaredBy = { readonly capability: CapabilityName; readonly file: string; readonly line: number };
6
6
 
@@ -13,7 +13,7 @@ export interface CapabilityDeclaration {
13
13
  readonly services?: '*' | readonly string[];
14
14
  readonly host?: boolean;
15
15
  };
16
- readonly files?: { readonly persist?: readonly string[] };
16
+ readonly files?: { readonly persist?: readonly string[]; readonly tree?: boolean; readonly mounts?: readonly string[]; readonly host?: boolean };
17
17
  readonly derivedFrom?: readonly DeclaredBy[];
18
18
  }
19
19
 
@@ -27,16 +27,22 @@ export interface CapabilityFacts {
27
27
  readonly guestProxy?: URL | null;
28
28
  readonly endpoint?: Pick<WorldEndpoint, 'kind' | 'name'> | null;
29
29
  readonly ownListener?: boolean;
30
+ /** read facts: the run's tree, the World's provided files, the measured system paths, the World's root */
31
+ readonly tree?: string;
32
+ readonly worldFiles?: readonly string[];
33
+ readonly system?: readonly string[];
34
+ readonly worldRoot?: string;
30
35
  }
31
36
  export interface CapabilityContext { readonly run?: { readonly guest?: boolean }; readonly declaration?: CapabilityDeclaration; readonly grant?: WorldGrant; readonly facts?: CapabilityFacts }
32
37
 
33
38
  export type CapabilityRequest =
34
39
  | { readonly kind: 'egress'; readonly url: string | URL | null; readonly method?: string; readonly transport?: 'http' | 'upgrade' | 'http2' | 'dispatch' | 'socket' | 'datagram'; readonly port?: number }
35
40
  | { readonly kind: 'loopback'; readonly host: string; readonly port?: number; readonly transport?: 'tcp' | 'udp' }
36
- | { readonly kind: 'persist'; readonly path: string };
41
+ | { readonly kind: 'persist'; readonly path: string }
42
+ | { readonly kind: 'read'; readonly path: string };
37
43
 
38
44
  export type CapabilityAnswer =
39
- | { readonly outcome: 'pass'; readonly capability: CapabilityName; readonly target: string; readonly via: 'direct' | 'inspected' | 'passthrough' | 'guest-proxy' | 'run-scoped' }
45
+ | { readonly outcome: 'pass'; readonly capability: CapabilityName; readonly target: string; readonly via: 'direct' | 'inspected' | 'passthrough' | 'guest-proxy' | 'run-scoped' | 'host' | 'own-tree' | 'world' | 'system' | 'mount' }
40
46
  | { readonly outcome: 'twin'; readonly capability: CapabilityName; readonly target: string; readonly twin: { readonly vendor: string; readonly origin: string } }
41
47
  | { readonly outcome: 'refuse'; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly rule: string; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy; readonly see?: string };
42
48
 
@@ -44,7 +50,7 @@ export interface WorldRefusalRecord { readonly kind: 'volter-world-refusal'; rea
44
50
 
45
51
  export interface CapabilitySet {
46
52
  readonly network: { readonly egress: 'none' | 'proxy'; readonly loopback: 'none' | 'own'; readonly listen: readonly number[]; readonly endpoints: readonly WorldEndpoint[] };
47
- readonly files: { readonly persist: readonly string[] };
53
+ readonly files: { readonly persist: readonly string[]; readonly read: readonly { readonly path: string; readonly kind: 'host' | 'own-tree' | 'world' | 'system' | 'mount' }[] };
48
54
  readonly refusals: readonly Extract<CapabilityAnswer, { outcome: 'refuse' }>[];
49
55
  }
50
56
 
@@ -56,6 +62,6 @@ export function sandboxRefusal(target: string): Extract<CapabilityAnswer, { outc
56
62
  export const GRANT_FIELDS: Readonly<Partial<Record<CapabilityName, string>>>;
57
63
  export function publicBuildGrant(grant: WorldGrant, url: URL): boolean;
58
64
  export function decideCapability(request: CapabilityRequest, context: CapabilityContext): CapabilityAnswer;
59
- export function decideCapabilitySet(declaration: CapabilityDeclaration | undefined, grant?: WorldGrant, world?: { readonly endpoints?: readonly WorldEndpoint[] }): CapabilitySet;
65
+ export function decideCapabilitySet(declaration: CapabilityDeclaration | undefined, grant?: WorldGrant, world?: { readonly endpoints?: readonly WorldEndpoint[]; readonly files?: { readonly tree?: string; readonly worldFiles?: readonly string[]; readonly system?: readonly string[]; readonly worldRoot?: string } }): CapabilitySet;
60
66
  export function refusalRecord(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): WorldRefusalRecord;
61
67
  export function refusalSide(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): string;
@@ -67,6 +67,7 @@ export { acceptH2Connection, type H2Handler, type Http1Handler } from './h2.js';
67
67
  export { placeholderPng } from './placeholder-image.js';
68
68
  export { presignSigV4, signSigV4, verifySigV4, type SigV4Verdict } from './sigv4.js';
69
69
  export * as redis from './redis/index.js';
70
+ export * as mysql from './mysql/index.js';
70
71
  export * as git from './git/index.js';
71
72
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError } from './scenario.js';
72
73
  export type { PackScenarioAdapter, ScenarioDecision, ScenarioDocument, ScenarioExtractorSpec, ScenarioFault, ScenarioFaultResult, ScenarioFeatures, ScenarioHandler, ScenarioMatcher, ScenarioMissRecord, ScenarioStatus } from './scenario.js';
package/dist/src/index.js CHANGED
@@ -59,6 +59,7 @@ export { acceptH2Connection } from "./h2.js";
59
59
  export { placeholderPng } from "./placeholder-image.js";
60
60
  export { presignSigV4, signSigV4, verifySigV4 } from "./sigv4.js";
61
61
  export * as redis from "./redis/index.js";
62
+ export * as mysql from "./mysql/index.js";
62
63
  export * as git from "./git/index.js";
63
64
  // ── the scenario grammar (architecture, "Behaviour") ───────────────────────────────────────────
64
65
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError } from "./scenario.js";
@@ -0,0 +1,28 @@
1
+ import { type MysqlError } from './stream.js';
2
+ /** Sends bytes and returns every byte the server answered to them. */
3
+ export type MysqlExchange = (bytes: Uint8Array) => Promise<Uint8Array>;
4
+ export type MysqlResultSet = {
5
+ columns: {
6
+ name: string;
7
+ type: number;
8
+ flags: number;
9
+ }[];
10
+ rows: (string | null)[][];
11
+ };
12
+ export type MysqlAnswer = MysqlResultSet | {
13
+ affectedRows: number;
14
+ insertId: number;
15
+ } | {
16
+ error: MysqlError;
17
+ };
18
+ /** Sign in: the server's greeting, then the handshake response (mysql_native_password), following an auth switch. */
19
+ export declare function mysqlSignIn(exchange: MysqlExchange, greeting: Uint8Array, user: string, password: string, database: string | null): Promise<{
20
+ ok: true;
21
+ } | {
22
+ error: MysqlError;
23
+ }>;
24
+ /** A text query (COM_QUERY): the first result of a script, as mysql_async reads it. */
25
+ export declare function mysqlQuery(exchange: MysqlExchange, sql: string): Promise<MysqlAnswer>;
26
+ /** A prepared statement executed once with text-typed parameters (COM_STMT_PREPARE, EXECUTE, CLOSE), as Prisma sends a
27
+ * parameterised query: binary parameters and rows. */
28
+ export declare function mysqlExecute(exchange: MysqlExchange, sql: string, params: (string | null)[]): Promise<MysqlAnswer>;
@@ -0,0 +1,220 @@
1
+ // A MySQL client over any byte exchange: the other side of ./stream.ts, so a journey speaks the wire as an application's
2
+ // driver does (mysql_async under Prisma's engines): protocol v10 with mysql_native_password, text queries, and prepared
3
+ // statements with binary parameters and rows. It reads what the protocol answers; it decides nothing.
4
+ // source: https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basics.html
5
+ import { MysqlType, MysqlFlag, nativePasswordToken } from "./stream.js";
6
+ const enc = new TextEncoder();
7
+ const dec = new TextDecoder();
8
+ const CLIENT_CAPS = 0x1 | 0x4 | 0x8 | 0x200 | 0x2000 | 0x8000 | 0x10000 | 0x20000 | 0x40000 | 0x80000 | 0x200000;
9
+ function packet(seq, payload) {
10
+ const out = new Uint8Array(4 + payload.length);
11
+ out[0] = payload.length & 0xff;
12
+ out[1] = (payload.length >> 8) & 0xff;
13
+ out[2] = (payload.length >> 16) & 0xff;
14
+ out[3] = seq;
15
+ out.set(payload, 4);
16
+ return out;
17
+ }
18
+ function packets(bytes) {
19
+ const out = [];
20
+ let at = 0;
21
+ while (at + 4 <= bytes.length) {
22
+ const len = bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16);
23
+ out.push(bytes.subarray(at + 4, at + 4 + len));
24
+ at += 4 + len;
25
+ }
26
+ return out;
27
+ }
28
+ class Cursor {
29
+ b;
30
+ at = 0;
31
+ constructor(b) {
32
+ this.b = b;
33
+ }
34
+ u8() { return this.b[this.at++]; }
35
+ u16() { return this.u8() | (this.u8() << 8); }
36
+ u32() { return (this.u16() | (this.u16() << 16)) >>> 0; }
37
+ u64() { let n = 0n; for (let i = 0n; i < 8n; i++)
38
+ n |= BigInt(this.u8()) << (8n * i); return n; }
39
+ take(n) { const out = this.b.subarray(this.at, this.at + n); this.at += n; return out; }
40
+ nul() { const end = this.b.indexOf(0, this.at); const s = dec.decode(this.b.subarray(this.at, end)); this.at = end + 1; return s; }
41
+ lenenc() { const f = this.u8(); if (f < 0xfb)
42
+ return f; if (f === 0xfc)
43
+ return this.u16(); if (f === 0xfd)
44
+ return this.u16() | (this.u8() << 16); return Number(this.u64()); }
45
+ lenencStr() { return dec.decode(this.take(this.lenenc())); }
46
+ }
47
+ const errorOf = (p) => {
48
+ const c = new Cursor(p);
49
+ c.u8();
50
+ const errno = c.u16();
51
+ c.u8(); // '#'
52
+ const sqlstate = dec.decode(c.take(5));
53
+ return { errno, sqlstate, message: dec.decode(c.take(p.length - c.at)) };
54
+ };
55
+ function column(p) {
56
+ const c = new Cursor(p);
57
+ c.lenencStr();
58
+ c.lenencStr();
59
+ c.lenencStr();
60
+ c.lenencStr(); // catalog, schema, table, org_table
61
+ const name = c.lenencStr();
62
+ c.lenencStr();
63
+ c.lenenc(); // org_name, fixed-length 0x0c
64
+ c.u16();
65
+ c.u32();
66
+ const type = c.u8();
67
+ const flags = c.u16();
68
+ return { name, type, flags };
69
+ }
70
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
71
+ /** A binary row's value as MySQL's text protocol writes it, so a journey reads both protocols alike. */
72
+ function binaryValue(c, type, flags) {
73
+ const unsigned = (flags & MysqlFlag.UNSIGNED) !== 0;
74
+ switch (type) {
75
+ case MysqlType.TINY: {
76
+ const v = c.u8();
77
+ return String(unsigned ? v : (v << 24) >> 24);
78
+ }
79
+ case MysqlType.SHORT:
80
+ case MysqlType.YEAR: {
81
+ const v = c.u16();
82
+ return String(unsigned ? v : (v << 16) >> 16);
83
+ }
84
+ case MysqlType.LONG:
85
+ case MysqlType.INT24: {
86
+ const v = c.u32();
87
+ return String(unsigned ? v : v | 0);
88
+ }
89
+ case MysqlType.LONGLONG: {
90
+ const v = c.u64();
91
+ return String(unsigned ? v : BigInt.asIntN(64, v));
92
+ }
93
+ case MysqlType.FLOAT: {
94
+ const b = c.take(4);
95
+ return String(new DataView(b.buffer, b.byteOffset, 4).getFloat32(0, true));
96
+ }
97
+ case MysqlType.DOUBLE: {
98
+ const b = c.take(8);
99
+ return String(new DataView(b.buffer, b.byteOffset, 8).getFloat64(0, true));
100
+ }
101
+ case MysqlType.DATE:
102
+ case MysqlType.DATETIME:
103
+ case MysqlType.TIMESTAMP: {
104
+ const len = c.u8();
105
+ if (!len)
106
+ return type === MysqlType.DATE ? '0000-00-00' : '0000-00-00 00:00:00';
107
+ const y = c.u16(), m = c.u8(), d = c.u8();
108
+ const [h, i, s] = len >= 7 ? [c.u8(), c.u8(), c.u8()] : [0, 0, 0];
109
+ const us = len >= 11 ? c.u32() : 0;
110
+ const day = `${pad(y, 4)}-${pad(m)}-${pad(d)}`;
111
+ return type === MysqlType.DATE ? day : `${day} ${pad(h)}:${pad(i)}:${pad(s)}${us ? `.${pad(us, 6)}` : ''}`;
112
+ }
113
+ case MysqlType.TIME: {
114
+ const len = c.u8();
115
+ if (!len)
116
+ return '00:00:00';
117
+ const neg = c.u8(), days = c.u32(), h = c.u8(), i = c.u8(), s = c.u8();
118
+ const us = len >= 12 ? c.u32() : 0;
119
+ return `${neg ? '-' : ''}${pad(days * 24 + h)}:${pad(i)}:${pad(s)}${us ? `.${pad(us, 6)}` : ''}`;
120
+ }
121
+ default: return c.lenencStr();
122
+ }
123
+ }
124
+ /** One command's answer: an OK, an ERR, or a result set (text or binary rows). */
125
+ function answer(payloads, binary) {
126
+ const first = payloads[0];
127
+ if (!first)
128
+ return { error: { errno: 2013, sqlstate: 'HY000', message: 'Lost connection to MySQL server during query' } };
129
+ if (first[0] === 0xff)
130
+ return { error: errorOf(first) };
131
+ // a result set begins with its column count (never 0): 0x00 is an OK
132
+ if (first[0] === 0x00) {
133
+ const c = new Cursor(first);
134
+ c.u8();
135
+ return { affectedRows: c.lenenc(), insertId: c.lenenc() };
136
+ }
137
+ const c = new Cursor(first);
138
+ const count = c.lenenc();
139
+ const columns = payloads.slice(1, 1 + count).map(column);
140
+ const rows = [];
141
+ for (const p of payloads.slice(2 + count)) {
142
+ if (p[0] === 0xfe && p.length < 9)
143
+ break; // the closing EOF
144
+ if (p[0] === 0xff)
145
+ return { error: errorOf(p) };
146
+ const r = new Cursor(p);
147
+ if (!binary) {
148
+ rows.push(columns.map(() => (r.b[r.at] === 0xfb ? (r.at++, null) : r.lenencStr())));
149
+ continue;
150
+ }
151
+ r.u8();
152
+ const bitmap = r.take(Math.floor((columns.length + 9) / 8));
153
+ rows.push(columns.map((col, i) => ((bitmap[Math.floor((i + 2) / 8)] >> ((i + 2) % 8)) & 1 ? null : binaryValue(r, col.type, col.flags))));
154
+ }
155
+ return { columns, rows };
156
+ }
157
+ /** Sign in: the server's greeting, then the handshake response (mysql_native_password), following an auth switch. */
158
+ export async function mysqlSignIn(exchange, greeting, user, password, database) {
159
+ const g = new Cursor(packets(greeting)[0]);
160
+ g.u8();
161
+ g.nul();
162
+ g.u32();
163
+ const part1 = g.take(8);
164
+ g.u8();
165
+ g.u16();
166
+ g.u8();
167
+ g.u16();
168
+ g.u16();
169
+ g.u8();
170
+ g.take(10);
171
+ const part2 = g.take(12);
172
+ const scramble = new Uint8Array([...part1, ...part2]);
173
+ const token = nativePasswordToken(password, scramble);
174
+ const body = [...new Uint8Array([CLIENT_CAPS & 0xff, (CLIENT_CAPS >> 8) & 0xff, (CLIENT_CAPS >> 16) & 0xff, (CLIENT_CAPS >> 24) & 0xff, 0, 0, 0, 1, 45]), ...new Array(23).fill(0),
175
+ ...enc.encode(user), 0, token.length, ...token, ...(database ? [...enc.encode(database), 0] : [0]), ...enc.encode('mysql_native_password'), 0];
176
+ let reply = packets(await exchange(packet(1, Uint8Array.from(body))))[0];
177
+ if (reply?.[0] === 0xfe) { // the server asks for another plugin's answer: mysql_native_password with its scramble
178
+ const c = new Cursor(reply);
179
+ c.u8();
180
+ c.nul();
181
+ reply = packets(await exchange(packet(3, nativePasswordToken(password, c.take(20)))))[0];
182
+ }
183
+ if (!reply || reply[0] === 0xff)
184
+ return { error: reply ? errorOf(reply) : { errno: 2013, sqlstate: 'HY000', message: 'Lost connection to MySQL server during handshake' } };
185
+ return { ok: true };
186
+ }
187
+ /** A text query (COM_QUERY): the first result of a script, as mysql_async reads it. */
188
+ export async function mysqlQuery(exchange, sql) {
189
+ return answer(packets(await exchange(packet(0, Uint8Array.from([0x03, ...enc.encode(sql)])))), false);
190
+ }
191
+ /** A prepared statement executed once with text-typed parameters (COM_STMT_PREPARE, EXECUTE, CLOSE), as Prisma sends a
192
+ * parameterised query: binary parameters and rows. */
193
+ export async function mysqlExecute(exchange, sql, params) {
194
+ const prepared = packets(await exchange(packet(0, Uint8Array.from([0x16, ...enc.encode(sql)]))));
195
+ if (prepared[0]?.[0] === 0xff)
196
+ return { error: errorOf(prepared[0]) };
197
+ const c = new Cursor(prepared[0]);
198
+ c.u8();
199
+ const id = c.u32();
200
+ c.u16();
201
+ const count = c.u16();
202
+ const head = [0x17, id & 0xff, (id >> 8) & 0xff, (id >> 16) & 0xff, (id >>> 24) & 0xff, 0, 1, 0, 0, 0];
203
+ const values = [];
204
+ if (count) {
205
+ const bitmap = new Array(Math.floor((count + 7) / 8)).fill(0);
206
+ params.forEach((v, i) => { if (v === null)
207
+ bitmap[Math.floor(i / 8)] |= 1 << (i % 8); });
208
+ head.push(...bitmap, 1);
209
+ for (let i = 0; i < count; i++)
210
+ head.push(params[i] === null ? MysqlType.NULL : MysqlType.VAR_STRING, 0);
211
+ for (const v of params)
212
+ if (v !== null) {
213
+ const b = enc.encode(v);
214
+ values.push(...(b.length < 251 ? [b.length] : [0xfc, b.length & 0xff, b.length >> 8]), ...b);
215
+ }
216
+ }
217
+ const result = answer(packets(await exchange(packet(0, Uint8Array.from([...head, ...values])))), true);
218
+ await exchange(packet(0, Uint8Array.from([0x19, id & 0xff, (id >> 8) & 0xff, (id >> 16) & 0xff, (id >>> 24) & 0xff])));
219
+ return result;
220
+ }
@@ -0,0 +1,2 @@
1
+ export * from './stream.js';
2
+ export * from './client.js';
@@ -0,0 +1,5 @@
1
+ // THE KERNEL'S MYSQL WIRE (`@volter/world-core/mysql`): MySQL's client/server protocol as a TwinStream. A pack that serves
2
+ // MySQL passes what a statement means (its engine) and whom it admits; the kernel names no vendor.
3
+ // docs/contributing/architecture.md, "Other wires", the MySQL wire.
4
+ export * from "./stream.js";
5
+ export * from "./client.js";