@volter/world-core 3.0.64 → 3.0.65
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/capability-decision.cjs +238 -0
- package/capability-decision.d.cts +56 -0
- package/dist/capability-decision.cjs +238 -0
- package/dist/capability-decision.d.cts +56 -0
- package/dist/inject.cjs +130 -98
- package/dist/network-policy.cjs +19 -15
- package/dist/network-policy.d.cts +1 -1
- package/inject.cjs +130 -98
- package/network-policy.cjs +19 -15
- package/network-policy.d.cts +1 -1
- package/package.json +7 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// THE WORLD'S CAPABILITY DECISION (docs/contributing/architecture.md, "Capability decisions"): what a guest's request
|
|
3
|
+
// gets, PASS, TWIN or REFUSE, from what the guest declares and what the World grants. Every substrate (the injector, the
|
|
4
|
+
// scoped proxy, a tab's relay, a native image run's Seatbelt profile) enforces this answer and decides nothing itself.
|
|
5
|
+
// Pure: no I/O, no environment and nothing at import. What only the substrate can observe (the twin route, the vendors
|
|
6
|
+
// sharing a host, which loopback ports are the World's doors or the run's own listeners) arrives as `facts`.
|
|
7
|
+
const { allowsWorldNetworkEgress, isWorldInternalHost, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough } = require('./network-policy.cjs');
|
|
8
|
+
|
|
9
|
+
/** The declaration of a guest with no declaration format: an ordinary host process, which can reach what the host can.
|
|
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 }) });
|
|
12
|
+
|
|
13
|
+
/** The World field that would grant each capability; absent where the World has no grant for it yet. */
|
|
14
|
+
const GRANT_FIELDS = Object.freeze({
|
|
15
|
+
'network.egress': 'runtime.network.egress',
|
|
16
|
+
'network.ownLoopback': 'runtime.network.ownLoopback',
|
|
17
|
+
'network.services': 'services',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const MISSING_WORDS = Object.freeze({
|
|
21
|
+
declaration: 'the guest does not declare it',
|
|
22
|
+
grant: 'the World does not grant it',
|
|
23
|
+
transport: 'this transport cannot carry what the World grants',
|
|
24
|
+
twin: 'no twin in this World serves it',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function pass(capability, target, via) {
|
|
28
|
+
return Object.freeze({ outcome: 'pass', capability, target, via });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function refuse(capability, target, missing, rule, reason, declaration) {
|
|
32
|
+
const declaredBy = declaration && Array.isArray(declaration.derivedFrom)
|
|
33
|
+
? declaration.derivedFrom.find(entry => entry && entry.capability === capability) : undefined;
|
|
34
|
+
return Object.freeze({
|
|
35
|
+
outcome: 'refuse', capability, target, missing, rule, reason,
|
|
36
|
+
// the field that would grant it, named only where the World's grant is what is missing
|
|
37
|
+
...(missing === 'grant' && GRANT_FIELDS[capability] ? { grantField: GRANT_FIELDS[capability] } : {}),
|
|
38
|
+
...(declaredBy ? { declaredBy } : {}),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A refusal as the one record every substrate reports. */
|
|
43
|
+
function refusalRecord(answer) {
|
|
44
|
+
const { capability, target, missing, reason, grantField, declaredBy } = answer;
|
|
45
|
+
return Object.freeze({ kind: 'volter-world-refusal', version: 1, capability, target, missing, reason,
|
|
46
|
+
...(grantField ? { grantField } : {}), ...(declaredBy ? { declaredBy } : {}) });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The side that refused, in words, for the end of an error message. */
|
|
50
|
+
function refusalSide(answer) {
|
|
51
|
+
const words = MISSING_WORDS[answer.missing] || answer.missing;
|
|
52
|
+
return answer.missing === 'grant' && answer.grantField ? `${words}: ${answer.grantField}` : words;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function declaresEgress(declaration, url) {
|
|
56
|
+
const egress = declaration && declaration.network && declaration.network.egress;
|
|
57
|
+
if (egress === '*') return true;
|
|
58
|
+
if (!Array.isArray(egress)) return false;
|
|
59
|
+
return egress.some((entry) => {
|
|
60
|
+
const named = String(entry);
|
|
61
|
+
if (!named.includes('://')) return named.toLowerCase() === url.hostname.toLowerCase();
|
|
62
|
+
try { return new URL(named).origin === url.origin; } catch { return false; }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function declaresService(declaration, name) {
|
|
67
|
+
const services = declaration && declaration.network && declaration.network.services;
|
|
68
|
+
return services === '*' || (Array.isArray(services) && services.includes(name));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The run's own listener: observed by the substrate (a listener the run holds), or declared. A declared `ownLoopback`
|
|
72
|
+
* covers every loopback port, as a container's own loopback does (worker IPC on ephemeral ports); `listen` is inbound
|
|
73
|
+
* exposure, a separate question. */
|
|
74
|
+
function declaresOwnListener(declaration, facts) {
|
|
75
|
+
if (facts.ownListener === true) return true;
|
|
76
|
+
return !!(declaration && declaration.network && declaration.network.ownLoopback === true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A loopback or private destination. `request`: { host, port, transport: 'tcp' | 'udp' }. A host process is on the
|
|
81
|
+
* host's network. A guest reaches its World's doors (`facts.endpoint.kind: 'door'`: its proxy, a twin, the World's own
|
|
82
|
+
* origin), the World's services it declares (`kind: 'service'`, by `name`) and the listeners it opened itself.
|
|
83
|
+
*/
|
|
84
|
+
function decideLoopback(request, context) {
|
|
85
|
+
const { run = {}, declaration, grant = {}, facts = {} } = context;
|
|
86
|
+
const target = `${request.host}${request.port === undefined ? '' : `:${request.port}`}`;
|
|
87
|
+
if (!run.guest) {
|
|
88
|
+
return declaration && declaration.network && declaration.network.host === true ? pass('network.host', target, 'direct')
|
|
89
|
+
: refuse('network.host', target, 'declaration', 'host-undeclared', `the guest does not declare the host's network (${target})`, declaration);
|
|
90
|
+
}
|
|
91
|
+
if (facts.endpoint) {
|
|
92
|
+
if (facts.endpoint.kind !== 'service' || declaresService(declaration, facts.endpoint.name)) return pass('network.services', target, 'direct');
|
|
93
|
+
return refuse('network.services', target, 'declaration', 'service-undeclared', `the guest does not declare the World's service ${facts.endpoint.name} at ${target}`, declaration);
|
|
94
|
+
}
|
|
95
|
+
if ((request.transport ?? 'tcp') === 'tcp' && declaresOwnListener(declaration, facts)) {
|
|
96
|
+
return grant.policy && grant.policy.ownLoopback === false
|
|
97
|
+
? refuse('network.ownLoopback', target, 'grant', 'own-loopback-ungranted', `this World does not let a run reach its own listener at ${target}`, declaration)
|
|
98
|
+
: pass('network.ownLoopback', target, 'direct');
|
|
99
|
+
}
|
|
100
|
+
// neither a door of the World nor a listener this run opened: a service of the host, which no World grants a guest
|
|
101
|
+
return refuse('network.host', target, 'grant', 'guest-private', `a guest reaches only its World's doors, its declared services and its own listeners, not ${target}`, declaration);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The World's listing of an external destination: sealed refuses, an unreadable policy refuses, a policy refuses what
|
|
105
|
+
* it does not list. null when listed, or when the World restricts nothing. */
|
|
106
|
+
function unlisted(url, target, method, { declaration, grant = {} }) {
|
|
107
|
+
const capability = 'network.egress';
|
|
108
|
+
if (grant.strict === true) return refuse(capability, target, 'grant', 'sealed', `this sealed World refuses untwinned host ${url.hostname}`, declaration);
|
|
109
|
+
if (grant.unreadable) return refuse(capability, target, 'grant', 'policy-unreadable', `the World network policy is unreadable (${grant.unreadable}); refusing ${url.hostname}`, declaration);
|
|
110
|
+
if (grant.policy && !allowsWorldNetworkEgress(grant.policy, url, method ?? 'GET')) {
|
|
111
|
+
return refuse(capability, target, 'grant', 'not-granted', `the World network policy does not allow ${String(method ?? 'GET').toUpperCase()} ${url.origin}`, declaration);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* An outbound destination. `request`: { url, method?, transport?, port? }. `transport` is `http` (the default),
|
|
118
|
+
* `upgrade`, `http2` (a session: no path at connect), `dispatch` (an undici dispatcher hop), `socket` or `datagram`.
|
|
119
|
+
* A method is given only where the enforcer sees it; without one only the listing is decided, and the inspecting layer
|
|
120
|
+
* the answer sends it through (`via: 'inspected'`) decides the method.
|
|
121
|
+
* `context.facts`: { twin, twinnedHostVendors, twinOrigin, invalid, guestProxy, endpoint, ownListener }.
|
|
122
|
+
*/
|
|
123
|
+
function decideEgress(request, context) {
|
|
124
|
+
const { run = {}, declaration, grant = {}, facts = {} } = context;
|
|
125
|
+
const capability = 'network.egress';
|
|
126
|
+
const transport = request.transport ?? 'http';
|
|
127
|
+
const policy = grant.policy;
|
|
128
|
+
const strict = grant.strict === true;
|
|
129
|
+
let url = null;
|
|
130
|
+
try { url = request.url instanceof URL ? request.url : new URL(String(request.url)); } catch { url = null; }
|
|
131
|
+
const target = url ? url.href : String(request.url);
|
|
132
|
+
// an effective authority or path the World cannot read is refused where the World lists anything
|
|
133
|
+
if (facts.invalid && (policy || grant.unreadable)) {
|
|
134
|
+
return refuse(capability, target, 'grant', 'invalid', 'the request has an invalid effective authority or path', declaration);
|
|
135
|
+
}
|
|
136
|
+
if (!url) return pass(capability, target, 'direct');
|
|
137
|
+
const internal = isWorldInternalHost(url.hostname);
|
|
138
|
+
const port = request.port ?? Number(url.port || (url.protocol === 'https:' || url.protocol === 'wss:' ? 443 : 80));
|
|
139
|
+
const guestMediated = run.guest && facts.guestProxy && !facts.endpoint;
|
|
140
|
+
|
|
141
|
+
// a local guest's HTTP leaves through its guest listener, which decides it again; the World's own doors stay direct
|
|
142
|
+
if (guestMediated && (transport === 'http' || transport === 'upgrade' || (transport === 'http2' && !facts.twin))) {
|
|
143
|
+
if (internal && declaresOwnListener(declaration, facts)) return decideLoopback({ host: url.hostname, port, transport: 'tcp' }, context);
|
|
144
|
+
if (transport === 'upgrade') return refuse(capability, target, 'transport', 'guest-upgrade', 'a local guest upgrade requires a mediated transport', declaration);
|
|
145
|
+
if (transport === 'http2') return refuse(capability, target, 'transport', 'guest-http2', 'local guest HTTP/2 requires a mediated transport', declaration);
|
|
146
|
+
return pass(capability, target, 'guest-proxy');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (transport === 'socket' || transport === 'datagram') {
|
|
150
|
+
if (internal && run.guest && facts.guestProxy) {
|
|
151
|
+
return decideLoopback({ host: url.hostname, port: request.port, transport: transport === 'datagram' ? 'udp' : 'tcp' }, context);
|
|
152
|
+
}
|
|
153
|
+
if (internal || facts.twinOrigin) return pass(capability, target, 'direct');
|
|
154
|
+
if (!declaresEgress(declaration, url)) return refuse(capability, target, 'declaration', 'undeclared', `the guest does not declare egress to ${url.origin}`, declaration);
|
|
155
|
+
if (!strict && policy && worldNetworkUsesPassthrough(policy, url)) return pass(capability, target, 'passthrough');
|
|
156
|
+
if (facts.twinnedHostVendors && facts.twinnedHostVendors.length > 0) {
|
|
157
|
+
return refuse(capability, target, 'transport', 'raw-socket-twinned', `${url.hostname} is twinned in this World (${facts.twinnedHostVendors.join(', ')}); a raw socket bypasses the twin`, declaration);
|
|
158
|
+
}
|
|
159
|
+
if (facts.twin && facts.twin.vendor === 'app') {
|
|
160
|
+
return refuse(capability, target, 'transport', 'raw-socket-app', `${url.hostname} is routed to the application; a raw socket cannot be routed`, declaration);
|
|
161
|
+
}
|
|
162
|
+
if (!strict && policy && worldNetworkNeedsPathInspection(policy, url)) {
|
|
163
|
+
return refuse(capability, target, 'transport', 'inspected-transport', `inspected egress requires a mediated HTTP request to ${url.host}`, declaration);
|
|
164
|
+
}
|
|
165
|
+
if (facts.twin) return pass(capability, target, 'direct');
|
|
166
|
+
return unlisted(url, target, undefined, context) ?? pass(capability, target, 'direct');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!internal && !declaresEgress(declaration, url)) return refuse(capability, target, 'declaration', 'undeclared', `the guest does not declare egress to ${url.origin}`, declaration);
|
|
170
|
+
// an explicit public build grant wins over a twin: that exact path is inspected and sent to its real origin
|
|
171
|
+
const publicBuild = !strict && !!policy && policy.phase === 'build' && allowsWorldNetworkEgress(policy, url);
|
|
172
|
+
if (!publicBuild && facts.twin) return Object.freeze({ outcome: 'twin', capability, target, twin: facts.twin });
|
|
173
|
+
if (!publicBuild && facts.twinnedHostVendors && facts.twinnedHostVendors.length > 0) {
|
|
174
|
+
return refuse(capability, target, 'twin', 'unclaimed-path', `no twin in this World claims ${url.pathname} on ${url.hostname} (${facts.twinnedHostVendors.join(', ')})`, declaration);
|
|
175
|
+
}
|
|
176
|
+
if (internal || facts.twinOrigin) return pass(capability, target, 'direct');
|
|
177
|
+
const refused = unlisted(url, target, request.method, context);
|
|
178
|
+
if (refused) return refused;
|
|
179
|
+
if (policy && worldNetworkNeedsPathInspection(policy, url)) {
|
|
180
|
+
if (transport !== 'http') {
|
|
181
|
+
const what = transport === 'upgrade' ? 'an upgraded transport' : transport === 'http2' ? 'HTTP/2' : 'an unmediated undici hop';
|
|
182
|
+
return refuse(capability, target, 'transport', 'inspected-transport', `inspected egress cannot carry ${what} to ${url.host}`, declaration);
|
|
183
|
+
}
|
|
184
|
+
return pass(capability, target, 'inspected');
|
|
185
|
+
}
|
|
186
|
+
return pass(capability, target, policy && worldNetworkUsesPassthrough(policy, url) ? 'passthrough' : 'direct');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A path whose writes outlive the run: declared by the guest; the World has no grant for it yet. */
|
|
190
|
+
function decidePersist(request, { declaration }) {
|
|
191
|
+
const persist = declaration && declaration.files && declaration.files.persist;
|
|
192
|
+
if (!Array.isArray(persist) || !persist.includes(request.path)) return refuse('files.persist', request.path, 'declaration', 'persist-undeclared', `the guest does not declare ${request.path} as persistent`, declaration);
|
|
193
|
+
return refuse('files.persist', request.path, 'grant', 'persist-ungranted', `this World has no grant for persistent paths (${request.path})`, declaration);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The one decision. `request.kind`: `egress` | `loopback` | `persist`.
|
|
198
|
+
* `context`: { run: { guest }, declaration, grant: { policy, strict, unreadable }, facts }.
|
|
199
|
+
*/
|
|
200
|
+
function decideCapability(request, context) {
|
|
201
|
+
if (request.kind === 'egress') return decideEgress(request, context);
|
|
202
|
+
if (request.kind === 'loopback') return decideLoopback(request, context);
|
|
203
|
+
if (request.kind === 'persist') return decidePersist(request, context);
|
|
204
|
+
throw new Error(`Unknown capability request: ${request.kind}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The same rules evaluated up front, for an enforcer that renders a profile instead of asking per connection (a native
|
|
209
|
+
* image run's Seatbelt profile). `world.endpoints`: [{ host, port, kind: 'door' | 'service', name }].
|
|
210
|
+
* Egress is `proxy` when declared: twin routing and the per-request listing are the World proxy's, which a profile
|
|
211
|
+
* forces traffic through; `none` when the guest declares none.
|
|
212
|
+
*/
|
|
213
|
+
function decideCapabilitySet(declaration, grant = {}, world = {}) {
|
|
214
|
+
const network = (declaration && declaration.network) || {};
|
|
215
|
+
const refusals = [];
|
|
216
|
+
const context = { run: { guest: true }, declaration, grant, facts: {} };
|
|
217
|
+
const endpoints = (world.endpoints || []).filter((endpoint) => {
|
|
218
|
+
const answer = decideLoopback({ host: endpoint.host, port: endpoint.port }, { ...context, facts: { endpoint } });
|
|
219
|
+
if (answer.outcome !== 'pass') refusals.push(answer);
|
|
220
|
+
return answer.outcome === 'pass';
|
|
221
|
+
});
|
|
222
|
+
const listen = Array.isArray(network.listen) ? [...network.listen] : [];
|
|
223
|
+
let loopback = 'none';
|
|
224
|
+
if (network.ownLoopback === true) {
|
|
225
|
+
const answer = decideLoopback({ host: 'localhost', transport: 'tcp' }, { ...context, facts: { ownListener: true } });
|
|
226
|
+
if (answer.outcome === 'pass') loopback = 'own'; else refusals.push(answer);
|
|
227
|
+
}
|
|
228
|
+
if (network.host === true) refusals.push(decideLoopback({ host: 'the host network' }, context));
|
|
229
|
+
const egress = network.egress === '*' || (Array.isArray(network.egress) && network.egress.length > 0) ? 'proxy' : 'none';
|
|
230
|
+
const persist = [];
|
|
231
|
+
for (const path of (declaration && declaration.files && declaration.files.persist) || []) {
|
|
232
|
+
const answer = decidePersist({ path }, context);
|
|
233
|
+
if (answer.outcome === 'pass') persist.push(path); else refusals.push(answer);
|
|
234
|
+
}
|
|
235
|
+
return Object.freeze({ network: Object.freeze({ egress, loopback, listen, endpoints }), files: Object.freeze({ persist }), refusals: Object.freeze(refusals) });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { OPEN_DECLARATION, GRANT_FIELDS, decideCapability, decideCapabilitySet, refusalRecord, refusalSide };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { WorldNetworkPolicy } from './network-policy.cjs';
|
|
2
|
+
|
|
3
|
+
export type CapabilityName = 'network.egress' | 'network.ownLoopback' | 'network.services' | 'network.host' | 'files.persist';
|
|
4
|
+
export type RefusalSide = 'declaration' | 'grant' | 'transport' | 'twin';
|
|
5
|
+
export type DeclaredBy = { readonly capability: CapabilityName; readonly file: string; readonly line: number };
|
|
6
|
+
|
|
7
|
+
/** What a guest declares (an image's runtime binding `capabilities`, a browser program's hosts). */
|
|
8
|
+
export interface CapabilityDeclaration {
|
|
9
|
+
readonly network?: {
|
|
10
|
+
readonly egress?: '*' | readonly string[];
|
|
11
|
+
readonly ownLoopback?: boolean;
|
|
12
|
+
readonly listen?: readonly number[];
|
|
13
|
+
readonly services?: '*' | readonly string[];
|
|
14
|
+
readonly host?: boolean;
|
|
15
|
+
};
|
|
16
|
+
readonly files?: { readonly persist?: readonly string[] };
|
|
17
|
+
readonly derivedFrom?: readonly DeclaredBy[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorldGrant { readonly policy?: WorldNetworkPolicy; readonly strict?: boolean; readonly unreadable?: string }
|
|
21
|
+
export interface WorldEndpoint { readonly host: string; readonly port: number; readonly kind: 'door' | 'service'; readonly name?: string }
|
|
22
|
+
export interface CapabilityFacts {
|
|
23
|
+
readonly twin?: { readonly vendor: string; readonly origin: string } | null;
|
|
24
|
+
readonly twinnedHostVendors?: readonly string[];
|
|
25
|
+
readonly twinOrigin?: boolean;
|
|
26
|
+
readonly invalid?: boolean;
|
|
27
|
+
readonly guestProxy?: URL | null;
|
|
28
|
+
readonly endpoint?: Pick<WorldEndpoint, 'kind' | 'name'> | null;
|
|
29
|
+
readonly ownListener?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface CapabilityContext { readonly run?: { readonly guest?: boolean }; readonly declaration?: CapabilityDeclaration; readonly grant?: WorldGrant; readonly facts?: CapabilityFacts }
|
|
32
|
+
|
|
33
|
+
export type CapabilityRequest =
|
|
34
|
+
| { readonly kind: 'egress'; readonly url: string | URL | null; readonly method?: string; readonly transport?: 'http' | 'upgrade' | 'http2' | 'dispatch' | 'socket' | 'datagram'; readonly port?: number }
|
|
35
|
+
| { readonly kind: 'loopback'; readonly host: string; readonly port?: number; readonly transport?: 'tcp' | 'udp' }
|
|
36
|
+
| { readonly kind: 'persist'; readonly path: string };
|
|
37
|
+
|
|
38
|
+
export type CapabilityAnswer =
|
|
39
|
+
| { readonly outcome: 'pass'; readonly capability: CapabilityName; readonly target: string; readonly via: 'direct' | 'inspected' | 'passthrough' | 'guest-proxy' }
|
|
40
|
+
| { readonly outcome: 'twin'; readonly capability: CapabilityName; readonly target: string; readonly twin: { readonly vendor: string; readonly origin: string } }
|
|
41
|
+
| { readonly outcome: 'refuse'; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly rule: string; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy };
|
|
42
|
+
|
|
43
|
+
export interface WorldRefusalRecord { readonly kind: 'volter-world-refusal'; readonly version: 1; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy }
|
|
44
|
+
|
|
45
|
+
export interface CapabilitySet {
|
|
46
|
+
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[] };
|
|
48
|
+
readonly refusals: readonly Extract<CapabilityAnswer, { outcome: 'refuse' }>[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const OPEN_DECLARATION: CapabilityDeclaration;
|
|
52
|
+
export const GRANT_FIELDS: Readonly<Partial<Record<CapabilityName, string>>>;
|
|
53
|
+
export function decideCapability(request: CapabilityRequest, context: CapabilityContext): CapabilityAnswer;
|
|
54
|
+
export function decideCapabilitySet(declaration: CapabilityDeclaration | undefined, grant?: WorldGrant, world?: { readonly endpoints?: readonly WorldEndpoint[] }): CapabilitySet;
|
|
55
|
+
export function refusalRecord(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): WorldRefusalRecord;
|
|
56
|
+
export function refusalSide(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): string;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// THE WORLD'S CAPABILITY DECISION (docs/contributing/architecture.md, "Capability decisions"): what a guest's request
|
|
3
|
+
// gets, PASS, TWIN or REFUSE, from what the guest declares and what the World grants. Every substrate (the injector, the
|
|
4
|
+
// scoped proxy, a tab's relay, a native image run's Seatbelt profile) enforces this answer and decides nothing itself.
|
|
5
|
+
// Pure: no I/O, no environment and nothing at import. What only the substrate can observe (the twin route, the vendors
|
|
6
|
+
// sharing a host, which loopback ports are the World's doors or the run's own listeners) arrives as `facts`.
|
|
7
|
+
const { allowsWorldNetworkEgress, isWorldInternalHost, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough } = require('./network-policy.cjs');
|
|
8
|
+
|
|
9
|
+
/** The declaration of a guest with no declaration format: an ordinary host process, which can reach what the host can.
|
|
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 }) });
|
|
12
|
+
|
|
13
|
+
/** The World field that would grant each capability; absent where the World has no grant for it yet. */
|
|
14
|
+
const GRANT_FIELDS = Object.freeze({
|
|
15
|
+
'network.egress': 'runtime.network.egress',
|
|
16
|
+
'network.ownLoopback': 'runtime.network.ownLoopback',
|
|
17
|
+
'network.services': 'services',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const MISSING_WORDS = Object.freeze({
|
|
21
|
+
declaration: 'the guest does not declare it',
|
|
22
|
+
grant: 'the World does not grant it',
|
|
23
|
+
transport: 'this transport cannot carry what the World grants',
|
|
24
|
+
twin: 'no twin in this World serves it',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function pass(capability, target, via) {
|
|
28
|
+
return Object.freeze({ outcome: 'pass', capability, target, via });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function refuse(capability, target, missing, rule, reason, declaration) {
|
|
32
|
+
const declaredBy = declaration && Array.isArray(declaration.derivedFrom)
|
|
33
|
+
? declaration.derivedFrom.find(entry => entry && entry.capability === capability) : undefined;
|
|
34
|
+
return Object.freeze({
|
|
35
|
+
outcome: 'refuse', capability, target, missing, rule, reason,
|
|
36
|
+
// the field that would grant it, named only where the World's grant is what is missing
|
|
37
|
+
...(missing === 'grant' && GRANT_FIELDS[capability] ? { grantField: GRANT_FIELDS[capability] } : {}),
|
|
38
|
+
...(declaredBy ? { declaredBy } : {}),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A refusal as the one record every substrate reports. */
|
|
43
|
+
function refusalRecord(answer) {
|
|
44
|
+
const { capability, target, missing, reason, grantField, declaredBy } = answer;
|
|
45
|
+
return Object.freeze({ kind: 'volter-world-refusal', version: 1, capability, target, missing, reason,
|
|
46
|
+
...(grantField ? { grantField } : {}), ...(declaredBy ? { declaredBy } : {}) });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The side that refused, in words, for the end of an error message. */
|
|
50
|
+
function refusalSide(answer) {
|
|
51
|
+
const words = MISSING_WORDS[answer.missing] || answer.missing;
|
|
52
|
+
return answer.missing === 'grant' && answer.grantField ? `${words}: ${answer.grantField}` : words;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function declaresEgress(declaration, url) {
|
|
56
|
+
const egress = declaration && declaration.network && declaration.network.egress;
|
|
57
|
+
if (egress === '*') return true;
|
|
58
|
+
if (!Array.isArray(egress)) return false;
|
|
59
|
+
return egress.some((entry) => {
|
|
60
|
+
const named = String(entry);
|
|
61
|
+
if (!named.includes('://')) return named.toLowerCase() === url.hostname.toLowerCase();
|
|
62
|
+
try { return new URL(named).origin === url.origin; } catch { return false; }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function declaresService(declaration, name) {
|
|
67
|
+
const services = declaration && declaration.network && declaration.network.services;
|
|
68
|
+
return services === '*' || (Array.isArray(services) && services.includes(name));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The run's own listener: observed by the substrate (a listener the run holds), or declared. A declared `ownLoopback`
|
|
72
|
+
* covers every loopback port, as a container's own loopback does (worker IPC on ephemeral ports); `listen` is inbound
|
|
73
|
+
* exposure, a separate question. */
|
|
74
|
+
function declaresOwnListener(declaration, facts) {
|
|
75
|
+
if (facts.ownListener === true) return true;
|
|
76
|
+
return !!(declaration && declaration.network && declaration.network.ownLoopback === true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A loopback or private destination. `request`: { host, port, transport: 'tcp' | 'udp' }. A host process is on the
|
|
81
|
+
* host's network. A guest reaches its World's doors (`facts.endpoint.kind: 'door'`: its proxy, a twin, the World's own
|
|
82
|
+
* origin), the World's services it declares (`kind: 'service'`, by `name`) and the listeners it opened itself.
|
|
83
|
+
*/
|
|
84
|
+
function decideLoopback(request, context) {
|
|
85
|
+
const { run = {}, declaration, grant = {}, facts = {} } = context;
|
|
86
|
+
const target = `${request.host}${request.port === undefined ? '' : `:${request.port}`}`;
|
|
87
|
+
if (!run.guest) {
|
|
88
|
+
return declaration && declaration.network && declaration.network.host === true ? pass('network.host', target, 'direct')
|
|
89
|
+
: refuse('network.host', target, 'declaration', 'host-undeclared', `the guest does not declare the host's network (${target})`, declaration);
|
|
90
|
+
}
|
|
91
|
+
if (facts.endpoint) {
|
|
92
|
+
if (facts.endpoint.kind !== 'service' || declaresService(declaration, facts.endpoint.name)) return pass('network.services', target, 'direct');
|
|
93
|
+
return refuse('network.services', target, 'declaration', 'service-undeclared', `the guest does not declare the World's service ${facts.endpoint.name} at ${target}`, declaration);
|
|
94
|
+
}
|
|
95
|
+
if ((request.transport ?? 'tcp') === 'tcp' && declaresOwnListener(declaration, facts)) {
|
|
96
|
+
return grant.policy && grant.policy.ownLoopback === false
|
|
97
|
+
? refuse('network.ownLoopback', target, 'grant', 'own-loopback-ungranted', `this World does not let a run reach its own listener at ${target}`, declaration)
|
|
98
|
+
: pass('network.ownLoopback', target, 'direct');
|
|
99
|
+
}
|
|
100
|
+
// neither a door of the World nor a listener this run opened: a service of the host, which no World grants a guest
|
|
101
|
+
return refuse('network.host', target, 'grant', 'guest-private', `a guest reaches only its World's doors, its declared services and its own listeners, not ${target}`, declaration);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The World's listing of an external destination: sealed refuses, an unreadable policy refuses, a policy refuses what
|
|
105
|
+
* it does not list. null when listed, or when the World restricts nothing. */
|
|
106
|
+
function unlisted(url, target, method, { declaration, grant = {} }) {
|
|
107
|
+
const capability = 'network.egress';
|
|
108
|
+
if (grant.strict === true) return refuse(capability, target, 'grant', 'sealed', `this sealed World refuses untwinned host ${url.hostname}`, declaration);
|
|
109
|
+
if (grant.unreadable) return refuse(capability, target, 'grant', 'policy-unreadable', `the World network policy is unreadable (${grant.unreadable}); refusing ${url.hostname}`, declaration);
|
|
110
|
+
if (grant.policy && !allowsWorldNetworkEgress(grant.policy, url, method ?? 'GET')) {
|
|
111
|
+
return refuse(capability, target, 'grant', 'not-granted', `the World network policy does not allow ${String(method ?? 'GET').toUpperCase()} ${url.origin}`, declaration);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* An outbound destination. `request`: { url, method?, transport?, port? }. `transport` is `http` (the default),
|
|
118
|
+
* `upgrade`, `http2` (a session: no path at connect), `dispatch` (an undici dispatcher hop), `socket` or `datagram`.
|
|
119
|
+
* A method is given only where the enforcer sees it; without one only the listing is decided, and the inspecting layer
|
|
120
|
+
* the answer sends it through (`via: 'inspected'`) decides the method.
|
|
121
|
+
* `context.facts`: { twin, twinnedHostVendors, twinOrigin, invalid, guestProxy, endpoint, ownListener }.
|
|
122
|
+
*/
|
|
123
|
+
function decideEgress(request, context) {
|
|
124
|
+
const { run = {}, declaration, grant = {}, facts = {} } = context;
|
|
125
|
+
const capability = 'network.egress';
|
|
126
|
+
const transport = request.transport ?? 'http';
|
|
127
|
+
const policy = grant.policy;
|
|
128
|
+
const strict = grant.strict === true;
|
|
129
|
+
let url = null;
|
|
130
|
+
try { url = request.url instanceof URL ? request.url : new URL(String(request.url)); } catch { url = null; }
|
|
131
|
+
const target = url ? url.href : String(request.url);
|
|
132
|
+
// an effective authority or path the World cannot read is refused where the World lists anything
|
|
133
|
+
if (facts.invalid && (policy || grant.unreadable)) {
|
|
134
|
+
return refuse(capability, target, 'grant', 'invalid', 'the request has an invalid effective authority or path', declaration);
|
|
135
|
+
}
|
|
136
|
+
if (!url) return pass(capability, target, 'direct');
|
|
137
|
+
const internal = isWorldInternalHost(url.hostname);
|
|
138
|
+
const port = request.port ?? Number(url.port || (url.protocol === 'https:' || url.protocol === 'wss:' ? 443 : 80));
|
|
139
|
+
const guestMediated = run.guest && facts.guestProxy && !facts.endpoint;
|
|
140
|
+
|
|
141
|
+
// a local guest's HTTP leaves through its guest listener, which decides it again; the World's own doors stay direct
|
|
142
|
+
if (guestMediated && (transport === 'http' || transport === 'upgrade' || (transport === 'http2' && !facts.twin))) {
|
|
143
|
+
if (internal && declaresOwnListener(declaration, facts)) return decideLoopback({ host: url.hostname, port, transport: 'tcp' }, context);
|
|
144
|
+
if (transport === 'upgrade') return refuse(capability, target, 'transport', 'guest-upgrade', 'a local guest upgrade requires a mediated transport', declaration);
|
|
145
|
+
if (transport === 'http2') return refuse(capability, target, 'transport', 'guest-http2', 'local guest HTTP/2 requires a mediated transport', declaration);
|
|
146
|
+
return pass(capability, target, 'guest-proxy');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (transport === 'socket' || transport === 'datagram') {
|
|
150
|
+
if (internal && run.guest && facts.guestProxy) {
|
|
151
|
+
return decideLoopback({ host: url.hostname, port: request.port, transport: transport === 'datagram' ? 'udp' : 'tcp' }, context);
|
|
152
|
+
}
|
|
153
|
+
if (internal || facts.twinOrigin) return pass(capability, target, 'direct');
|
|
154
|
+
if (!declaresEgress(declaration, url)) return refuse(capability, target, 'declaration', 'undeclared', `the guest does not declare egress to ${url.origin}`, declaration);
|
|
155
|
+
if (!strict && policy && worldNetworkUsesPassthrough(policy, url)) return pass(capability, target, 'passthrough');
|
|
156
|
+
if (facts.twinnedHostVendors && facts.twinnedHostVendors.length > 0) {
|
|
157
|
+
return refuse(capability, target, 'transport', 'raw-socket-twinned', `${url.hostname} is twinned in this World (${facts.twinnedHostVendors.join(', ')}); a raw socket bypasses the twin`, declaration);
|
|
158
|
+
}
|
|
159
|
+
if (facts.twin && facts.twin.vendor === 'app') {
|
|
160
|
+
return refuse(capability, target, 'transport', 'raw-socket-app', `${url.hostname} is routed to the application; a raw socket cannot be routed`, declaration);
|
|
161
|
+
}
|
|
162
|
+
if (!strict && policy && worldNetworkNeedsPathInspection(policy, url)) {
|
|
163
|
+
return refuse(capability, target, 'transport', 'inspected-transport', `inspected egress requires a mediated HTTP request to ${url.host}`, declaration);
|
|
164
|
+
}
|
|
165
|
+
if (facts.twin) return pass(capability, target, 'direct');
|
|
166
|
+
return unlisted(url, target, undefined, context) ?? pass(capability, target, 'direct');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!internal && !declaresEgress(declaration, url)) return refuse(capability, target, 'declaration', 'undeclared', `the guest does not declare egress to ${url.origin}`, declaration);
|
|
170
|
+
// an explicit public build grant wins over a twin: that exact path is inspected and sent to its real origin
|
|
171
|
+
const publicBuild = !strict && !!policy && policy.phase === 'build' && allowsWorldNetworkEgress(policy, url);
|
|
172
|
+
if (!publicBuild && facts.twin) return Object.freeze({ outcome: 'twin', capability, target, twin: facts.twin });
|
|
173
|
+
if (!publicBuild && facts.twinnedHostVendors && facts.twinnedHostVendors.length > 0) {
|
|
174
|
+
return refuse(capability, target, 'twin', 'unclaimed-path', `no twin in this World claims ${url.pathname} on ${url.hostname} (${facts.twinnedHostVendors.join(', ')})`, declaration);
|
|
175
|
+
}
|
|
176
|
+
if (internal || facts.twinOrigin) return pass(capability, target, 'direct');
|
|
177
|
+
const refused = unlisted(url, target, request.method, context);
|
|
178
|
+
if (refused) return refused;
|
|
179
|
+
if (policy && worldNetworkNeedsPathInspection(policy, url)) {
|
|
180
|
+
if (transport !== 'http') {
|
|
181
|
+
const what = transport === 'upgrade' ? 'an upgraded transport' : transport === 'http2' ? 'HTTP/2' : 'an unmediated undici hop';
|
|
182
|
+
return refuse(capability, target, 'transport', 'inspected-transport', `inspected egress cannot carry ${what} to ${url.host}`, declaration);
|
|
183
|
+
}
|
|
184
|
+
return pass(capability, target, 'inspected');
|
|
185
|
+
}
|
|
186
|
+
return pass(capability, target, policy && worldNetworkUsesPassthrough(policy, url) ? 'passthrough' : 'direct');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A path whose writes outlive the run: declared by the guest; the World has no grant for it yet. */
|
|
190
|
+
function decidePersist(request, { declaration }) {
|
|
191
|
+
const persist = declaration && declaration.files && declaration.files.persist;
|
|
192
|
+
if (!Array.isArray(persist) || !persist.includes(request.path)) return refuse('files.persist', request.path, 'declaration', 'persist-undeclared', `the guest does not declare ${request.path} as persistent`, declaration);
|
|
193
|
+
return refuse('files.persist', request.path, 'grant', 'persist-ungranted', `this World has no grant for persistent paths (${request.path})`, declaration);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The one decision. `request.kind`: `egress` | `loopback` | `persist`.
|
|
198
|
+
* `context`: { run: { guest }, declaration, grant: { policy, strict, unreadable }, facts }.
|
|
199
|
+
*/
|
|
200
|
+
function decideCapability(request, context) {
|
|
201
|
+
if (request.kind === 'egress') return decideEgress(request, context);
|
|
202
|
+
if (request.kind === 'loopback') return decideLoopback(request, context);
|
|
203
|
+
if (request.kind === 'persist') return decidePersist(request, context);
|
|
204
|
+
throw new Error(`Unknown capability request: ${request.kind}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The same rules evaluated up front, for an enforcer that renders a profile instead of asking per connection (a native
|
|
209
|
+
* image run's Seatbelt profile). `world.endpoints`: [{ host, port, kind: 'door' | 'service', name }].
|
|
210
|
+
* Egress is `proxy` when declared: twin routing and the per-request listing are the World proxy's, which a profile
|
|
211
|
+
* forces traffic through; `none` when the guest declares none.
|
|
212
|
+
*/
|
|
213
|
+
function decideCapabilitySet(declaration, grant = {}, world = {}) {
|
|
214
|
+
const network = (declaration && declaration.network) || {};
|
|
215
|
+
const refusals = [];
|
|
216
|
+
const context = { run: { guest: true }, declaration, grant, facts: {} };
|
|
217
|
+
const endpoints = (world.endpoints || []).filter((endpoint) => {
|
|
218
|
+
const answer = decideLoopback({ host: endpoint.host, port: endpoint.port }, { ...context, facts: { endpoint } });
|
|
219
|
+
if (answer.outcome !== 'pass') refusals.push(answer);
|
|
220
|
+
return answer.outcome === 'pass';
|
|
221
|
+
});
|
|
222
|
+
const listen = Array.isArray(network.listen) ? [...network.listen] : [];
|
|
223
|
+
let loopback = 'none';
|
|
224
|
+
if (network.ownLoopback === true) {
|
|
225
|
+
const answer = decideLoopback({ host: 'localhost', transport: 'tcp' }, { ...context, facts: { ownListener: true } });
|
|
226
|
+
if (answer.outcome === 'pass') loopback = 'own'; else refusals.push(answer);
|
|
227
|
+
}
|
|
228
|
+
if (network.host === true) refusals.push(decideLoopback({ host: 'the host network' }, context));
|
|
229
|
+
const egress = network.egress === '*' || (Array.isArray(network.egress) && network.egress.length > 0) ? 'proxy' : 'none';
|
|
230
|
+
const persist = [];
|
|
231
|
+
for (const path of (declaration && declaration.files && declaration.files.persist) || []) {
|
|
232
|
+
const answer = decidePersist({ path }, context);
|
|
233
|
+
if (answer.outcome === 'pass') persist.push(path); else refusals.push(answer);
|
|
234
|
+
}
|
|
235
|
+
return Object.freeze({ network: Object.freeze({ egress, loopback, listen, endpoints }), files: Object.freeze({ persist }), refusals: Object.freeze(refusals) });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { OPEN_DECLARATION, GRANT_FIELDS, decideCapability, decideCapabilitySet, refusalRecord, refusalSide };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { WorldNetworkPolicy } from './network-policy.cjs';
|
|
2
|
+
|
|
3
|
+
export type CapabilityName = 'network.egress' | 'network.ownLoopback' | 'network.services' | 'network.host' | 'files.persist';
|
|
4
|
+
export type RefusalSide = 'declaration' | 'grant' | 'transport' | 'twin';
|
|
5
|
+
export type DeclaredBy = { readonly capability: CapabilityName; readonly file: string; readonly line: number };
|
|
6
|
+
|
|
7
|
+
/** What a guest declares (an image's runtime binding `capabilities`, a browser program's hosts). */
|
|
8
|
+
export interface CapabilityDeclaration {
|
|
9
|
+
readonly network?: {
|
|
10
|
+
readonly egress?: '*' | readonly string[];
|
|
11
|
+
readonly ownLoopback?: boolean;
|
|
12
|
+
readonly listen?: readonly number[];
|
|
13
|
+
readonly services?: '*' | readonly string[];
|
|
14
|
+
readonly host?: boolean;
|
|
15
|
+
};
|
|
16
|
+
readonly files?: { readonly persist?: readonly string[] };
|
|
17
|
+
readonly derivedFrom?: readonly DeclaredBy[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorldGrant { readonly policy?: WorldNetworkPolicy; readonly strict?: boolean; readonly unreadable?: string }
|
|
21
|
+
export interface WorldEndpoint { readonly host: string; readonly port: number; readonly kind: 'door' | 'service'; readonly name?: string }
|
|
22
|
+
export interface CapabilityFacts {
|
|
23
|
+
readonly twin?: { readonly vendor: string; readonly origin: string } | null;
|
|
24
|
+
readonly twinnedHostVendors?: readonly string[];
|
|
25
|
+
readonly twinOrigin?: boolean;
|
|
26
|
+
readonly invalid?: boolean;
|
|
27
|
+
readonly guestProxy?: URL | null;
|
|
28
|
+
readonly endpoint?: Pick<WorldEndpoint, 'kind' | 'name'> | null;
|
|
29
|
+
readonly ownListener?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface CapabilityContext { readonly run?: { readonly guest?: boolean }; readonly declaration?: CapabilityDeclaration; readonly grant?: WorldGrant; readonly facts?: CapabilityFacts }
|
|
32
|
+
|
|
33
|
+
export type CapabilityRequest =
|
|
34
|
+
| { readonly kind: 'egress'; readonly url: string | URL | null; readonly method?: string; readonly transport?: 'http' | 'upgrade' | 'http2' | 'dispatch' | 'socket' | 'datagram'; readonly port?: number }
|
|
35
|
+
| { readonly kind: 'loopback'; readonly host: string; readonly port?: number; readonly transport?: 'tcp' | 'udp' }
|
|
36
|
+
| { readonly kind: 'persist'; readonly path: string };
|
|
37
|
+
|
|
38
|
+
export type CapabilityAnswer =
|
|
39
|
+
| { readonly outcome: 'pass'; readonly capability: CapabilityName; readonly target: string; readonly via: 'direct' | 'inspected' | 'passthrough' | 'guest-proxy' }
|
|
40
|
+
| { readonly outcome: 'twin'; readonly capability: CapabilityName; readonly target: string; readonly twin: { readonly vendor: string; readonly origin: string } }
|
|
41
|
+
| { readonly outcome: 'refuse'; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly rule: string; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy };
|
|
42
|
+
|
|
43
|
+
export interface WorldRefusalRecord { readonly kind: 'volter-world-refusal'; readonly version: 1; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy }
|
|
44
|
+
|
|
45
|
+
export interface CapabilitySet {
|
|
46
|
+
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[] };
|
|
48
|
+
readonly refusals: readonly Extract<CapabilityAnswer, { outcome: 'refuse' }>[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const OPEN_DECLARATION: CapabilityDeclaration;
|
|
52
|
+
export const GRANT_FIELDS: Readonly<Partial<Record<CapabilityName, string>>>;
|
|
53
|
+
export function decideCapability(request: CapabilityRequest, context: CapabilityContext): CapabilityAnswer;
|
|
54
|
+
export function decideCapabilitySet(declaration: CapabilityDeclaration | undefined, grant?: WorldGrant, world?: { readonly endpoints?: readonly WorldEndpoint[] }): CapabilitySet;
|
|
55
|
+
export function refusalRecord(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): WorldRefusalRecord;
|
|
56
|
+
export function refusalSide(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): string;
|