@namzu/sandbox 15.0.0 → 16.0.0

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.
@@ -87,6 +87,7 @@ import {
87
87
  type KubernetesTranslatedEgressPolicy,
88
88
  PER_SANDBOX_NARROWING_REFUSAL,
89
89
  assertHostsFitNarrowing,
90
+ assertUsableHost,
90
91
  buildCiliumEgressManifest,
91
92
  perSandboxEgressLabelKey,
92
93
  verifyEgressPolicyApplied,
@@ -214,18 +215,15 @@ export class KubernetesAdmissionFenceMissingError extends Error {
214
215
  * Raised for an `allowedHosts` entry this backend will not translate —
215
216
  * re-exported rather than defined here.
216
217
  *
217
- * It lives in `egress-policy.ts` beside {@link assertHostsFitNarrowing},
218
- * because the config-level `ciliumNarrowing` translation raises the same
219
- * class for the same entry, and this module imports that one: defining it
220
- * here would make the two modules import each other. The identifier is
221
- * re-exported so every existing import path — including the package index —
222
- * keeps resolving.
218
+ * It lives in `egress-policy.ts` beside {@link assertHostsFitNarrowing} and
219
+ * {@link assertUsableHost}, because the config-level translation raises the
220
+ * same class for the same entry and this module imports both checks from
221
+ * there: defining it here would make the two modules import each other. The
222
+ * identifier is re-exported so every existing import path — including the
223
+ * package index — keeps resolving.
223
224
  */
224
225
  export { KubernetesNetworkPolicyHostError }
225
226
 
226
- /** A DNS name, lowercase, no scheme, no port, no wildcard. */
227
- const DNS_NAME = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/
228
-
229
227
  /**
230
228
  * One `allowedHosts` entry, validated and canonicalised to the bytes a
231
229
  * `CiliumNetworkPolicy` should carry.
@@ -235,6 +233,13 @@ const DNS_NAME = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?
235
233
  * `matchName` is compared against what the DNS proxy saw — lowercase. The
236
234
  * docker backend accepts either case, and a list that worked there and threw
237
235
  * here would be a portability trap with no boundary behind it.
236
+ *
237
+ * The grammar itself is {@link assertUsableHost}'s, in `egress-policy.ts`
238
+ * beside the error it throws, because it is no longer only this writer's: the
239
+ * shared translation calls it too, so a config-level allowlist is refused the
240
+ * same entries this path refuses. What is HERE is the part that is this
241
+ * writer's alone — the canonicalisation, applied before the fence is read and
242
+ * before anything is queued behind a previous call.
238
243
  */
239
244
  function normalizeHost(entry: string): string {
240
245
  if (typeof entry !== 'string') {
@@ -245,55 +250,6 @@ function normalizeHost(entry: string): string {
245
250
  return canonical
246
251
  }
247
252
 
248
- function assertUsableHost(entry: string): void {
249
- if (typeof entry !== 'string' || entry === '') {
250
- throw new KubernetesNetworkPolicyHostError(String(entry), 'is empty')
251
- }
252
- const bare = entry.startsWith('.') ? entry.slice(1) : entry
253
- if (bare === '') {
254
- throw new KubernetesNetworkPolicyHostError(entry, 'names no domain after its leading dot')
255
- }
256
- if (entry.includes('*')) {
257
- throw new KubernetesNetworkPolicyHostError(
258
- entry,
259
- "contains a glob; a domain and its subdomains are written with a leading dot ('.example.com'), which becomes matchName plus matchPattern",
260
- )
261
- }
262
- if (!DNS_NAME.test(bare)) {
263
- throw new KubernetesNetworkPolicyHostError(
264
- entry,
265
- 'is not a DNS name (a scheme, a path, a port suffix and an IP address all land here; letter case is canonicalised before this check, so it is never the cause)',
266
- )
267
- }
268
- if (bare.length > 253) {
269
- throw new KubernetesNetworkPolicyHostError(entry, 'is longer than a DNS name may be')
270
- }
271
- // A leading-dot entry becomes `matchPattern: '*.<domain>'`, and a
272
- // single-label domain there is a whole public suffix — `.com`, `.org`.
273
- // That is not an allowlist entry, and the shipped admission fence refuses
274
- // the pattern it would produce, so refusing it HERE is what turns an
275
- // opaque 403 from the API server into an error naming the entry.
276
- if (entry.startsWith('.') && !bare.includes('.')) {
277
- throw new KubernetesNetworkPolicyHostError(
278
- entry,
279
- "names a whole top-level domain ('.com' means every name under it); a domain entry needs at least two labels, as in '.example.com', and the shipped admission policy refuses the '*.com' pattern this would emit",
280
- )
281
- }
282
- // An IPv4 literal passes the grammar above — every label is digits, and
283
- // digits are legal in a DNS label. It is still not a hostname: a DNS
284
- // top-level label is never all-numeric, and Cilium's `matchName` is
285
- // compared against names the DNS proxy SAW, which an address never is. A
286
- // policy carrying one is admitted and matches nothing, which reads from
287
- // outside exactly like a policy that is working.
288
- const lastLabel = bare.slice(bare.lastIndexOf('.') + 1)
289
- if (/^[0-9]+$/.test(lastLabel)) {
290
- throw new KubernetesNetworkPolicyHostError(
291
- entry,
292
- 'ends in an all-numeric label, so it is an address rather than a hostname; toFQDNs matches names a DNS lookup returned, and an address is never one of them (use config.egress.policy for address-based egress)',
293
- )
294
- }
295
- }
296
-
297
253
  /**
298
254
  * Raised when the object an acquire created reported no `metadata.uid`.
299
255
  *
@@ -434,7 +390,7 @@ export function buildPerSandboxPolicySetter(
434
390
  policyKind: 'static',
435
391
  ...(perSandbox.narrowing !== undefined ? { narrowing: perSandbox.narrowing } : {}),
436
392
  ownerReferences: [perSandboxPolicyOwnerReference(owner)],
437
- expandDomains: true,
393
+ refusalContext: PER_SANDBOX_NARROWING_REFUSAL,
438
394
  })
439
395
 
440
396
  const write = async (translated: KubernetesTranslatedEgressPolicy): Promise<void> => {
@@ -497,13 +453,12 @@ export function buildPerSandboxPolicySetter(
497
453
  perSandbox.narrowing,
498
454
  // The per-sandbox context, from `egress-policy.ts` rather than
499
455
  // rebuilt here: it is the SAME value `buildCiliumEgressManifest`
500
- // refuses by on this path (`expandDomains: true`), so the earlier
501
- // check and the translation's own cannot name different fields —
502
- // and the sentence it carries is the true one here, where the entry
503
- // really does become a name plus a `*.domain` pattern. The
504
- // config-level wording, where nothing is expanded, would be a lie
505
- // on this path: leaving the option off here does allow the domain
506
- // and its subdomains.
456
+ // refuses by on this path (`refusalContext`), so the earlier check
457
+ // and the translation's own cannot name different fields — and the
458
+ // sentence it carries is the true one here, where the entry really
459
+ // does become a name plus a `*.domain` pattern. Sending this caller
460
+ // to `config.egress.ciliumNarrowing` would name a field a
461
+ // `perSandbox` host never set.
507
462
  PER_SANDBOX_NARROWING_REFUSAL,
508
463
  )
509
464
  // A previous call's failure belongs to that caller; this one still runs,
package/src/index.ts CHANGED
@@ -9,11 +9,29 @@
9
9
  *
10
10
  * Two tiers, each a trust boundary:
11
11
  *
12
- * • `container` — one OCI container per task, seccomp on, tmpfs
13
- * workdir, no network unless asked. The same path on a laptop and
14
- * on a Linux replica anywhere. The tier for trusted prompts and
15
- * contained workloads. Boundary: kernel namespaces, or a
16
- * userspace-kernel runtime where one is installed.
12
+ * • `container` — one OCI container per task, with the container itself
13
+ * as the boundary: kernel namespaces, or a userspace-kernel runtime
14
+ * where one is installed. The same path on a laptop and on a Linux
15
+ * replica anywhere. The tier for trusted prompts and contained
16
+ * workloads. What confines the workload INSIDE the container is
17
+ * applied by each backend and documented where it is applied, not
18
+ * promised here: `container:docker` drops every capability, sets
19
+ * no-new-privileges, gives the container an IPC namespace nothing else can
20
+ * join and mounts its root filesystem read-only over a named writable set
21
+ * (see `backends/docker/index.ts`), while the ACI standby pool takes
22
+ * its controls from the container-group profile its pool was built
23
+ * from. This line used to claim "seccomp on, tmpfs workdir, no
24
+ * network unless asked" on both their behalf, and none of the three
25
+ * is a property this package establishes: nothing here passes a
26
+ * seccomp flag, so what filters a container's syscalls is the daemon's
27
+ * own profile rather than a default this package sets; the directory
28
+ * the agent works in is the layout's `outputs` bind mount — a host
29
+ * directory the run is collected from — rather than a tmpfs that would
30
+ * lose it when the container exits; and the network a container is
31
+ * attached to is a fact about
32
+ * the backend's own configuration — a daemon network whose internals
33
+ * the egress policy is checked against, or the container group's
34
+ * subnet or public address.
17
35
  *
18
36
  * • `microvm` — one hardware-virtualized guest per task. The boundary
19
37
  * to reach for when the prompt itself is adversarial, at the cost
@@ -685,6 +703,46 @@ export interface ContainerBackendConfig {
685
703
  * collisions with Docker / orchestrator labels.
686
704
  */
687
705
  readonly labels?: Readonly<Record<string, string>>
706
+ /**
707
+ * CPU cores the container may use, rendered as `--cpus`. Unset by
708
+ * default, like `memoryLimitMb` and `maxProcesses`, and for the same
709
+ * reason: the value that is right is a property of the host's machine
710
+ * and of the workload, and a number chosen here would silently throttle
711
+ * runs that finish inside their timeout today.
712
+ *
713
+ * It is set at provider construction rather than per `create()` call,
714
+ * because the documented deployment builds one provider per task — and
715
+ * because the ACI and kubernetes backends cannot apply a per-sandbox CPU
716
+ * limit, so a per-call field would be a control they would have to
717
+ * refuse. See `backends/docker/index.ts` for what it renders.
718
+ */
719
+ readonly cpuLimit?: number
720
+ /**
721
+ * Mount the container's root filesystem read-only. Default `true`.
722
+ *
723
+ * On by default with the paths that stay writable named in
724
+ * `backends/docker/index.ts` (`writableRootfsPaths` extends them). Set it
725
+ * to `false` to make the whole container filesystem writable again, which a
726
+ * host whose image writes somewhere the writable set cannot describe needs,
727
+ * and which is why the switch exists rather than the baseline being
728
+ * unconditional. It gives up that one control: the capability drop,
729
+ * `no-new-privileges` and `--ipc private` are applied to every container
730
+ * whatever this says, so it is not a way back to the previous argv.
731
+ */
732
+ readonly readOnlyRootfs?: boolean
733
+ /**
734
+ * Extra paths to keep writable under `--read-only`, each mounted
735
+ * `--tmpfs`.
736
+ *
737
+ * The default set is the reference image's needs, read off its Dockerfile.
738
+ * A host that points `image` at its own build says what that image needs
739
+ * here, because the backend cannot read an image's writable set and the
740
+ * alternative to asking is guessing. Setting this beside
741
+ * `readOnlyRootfs: false` is refused: with a writable root filesystem the
742
+ * mounts would add nothing, and accepting a control that is not applied is
743
+ * the failure this package refuses everywhere else.
744
+ */
745
+ readonly writableRootfsPaths?: readonly string[]
688
746
  }
689
747
 
690
748
  /**
@@ -1260,6 +1318,11 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
1260
1318
  ...(backend.network !== undefined ? { network: backend.network } : {}),
1261
1319
  ...(backend.allowInwardFor !== undefined ? { allowInwardFor: backend.allowInwardFor } : {}),
1262
1320
  ...(backend.labels !== undefined ? { labels: backend.labels } : {}),
1321
+ ...(backend.cpuLimit !== undefined ? { cpuLimit: backend.cpuLimit } : {}),
1322
+ ...(backend.readOnlyRootfs !== undefined ? { readOnlyRootfs: backend.readOnlyRootfs } : {}),
1323
+ ...(backend.writableRootfsPaths !== undefined
1324
+ ? { writableRootfsPaths: backend.writableRootfsPaths }
1325
+ : {}),
1263
1326
  })
1264
1327
  }
1265
1328
  if (backend.tier === 'container' && backend.runtime === 'runsc') {
@@ -1280,6 +1343,11 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
1280
1343
  ...(backend.network !== undefined ? { network: backend.network } : {}),
1281
1344
  ...(backend.allowInwardFor !== undefined ? { allowInwardFor: backend.allowInwardFor } : {}),
1282
1345
  ...(backend.labels !== undefined ? { labels: backend.labels } : {}),
1346
+ ...(backend.cpuLimit !== undefined ? { cpuLimit: backend.cpuLimit } : {}),
1347
+ ...(backend.readOnlyRootfs !== undefined ? { readOnlyRootfs: backend.readOnlyRootfs } : {}),
1348
+ ...(backend.writableRootfsPaths !== undefined
1349
+ ? { writableRootfsPaths: backend.writableRootfsPaths }
1350
+ : {}),
1283
1351
  })
1284
1352
  }
1285
1353
  // `microvm:self-hosted` targeting the OWNED Azure Firecracker