@b4run/sandbox 0.8.28

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +42 -0
  3. package/dist/docker/docker-cli.d.ts +23 -0
  4. package/dist/docker/docker-cli.d.ts.map +1 -0
  5. package/dist/docker/docker-cli.js +29 -0
  6. package/dist/docker/docker-exec.d.ts +12 -0
  7. package/dist/docker/docker-exec.d.ts.map +1 -0
  8. package/dist/docker/docker-exec.js +74 -0
  9. package/dist/docker/docker-filesystem.d.ts +11 -0
  10. package/dist/docker/docker-filesystem.d.ts.map +1 -0
  11. package/dist/docker/docker-filesystem.js +87 -0
  12. package/dist/docker/docker-pid-exhaustion.d.ts +8 -0
  13. package/dist/docker/docker-pid-exhaustion.d.ts.map +1 -0
  14. package/dist/docker/docker-pid-exhaustion.js +39 -0
  15. package/dist/docker/docker-sandbox.d.ts +20 -0
  16. package/dist/docker/docker-sandbox.d.ts.map +1 -0
  17. package/dist/docker/docker-sandbox.js +267 -0
  18. package/dist/docker/thread-lifecycle.d.ts +15 -0
  19. package/dist/docker/thread-lifecycle.d.ts.map +1 -0
  20. package/dist/docker/thread-lifecycle.js +85 -0
  21. package/dist/errors.d.ts +12 -0
  22. package/dist/errors.d.ts.map +1 -0
  23. package/dist/errors.js +10 -0
  24. package/dist/index.d.ts +5 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +3 -0
  27. package/dist/kubernetes/default-kube-client.d.ts +6 -0
  28. package/dist/kubernetes/default-kube-client.d.ts.map +1 -0
  29. package/dist/kubernetes/default-kube-client.js +331 -0
  30. package/dist/kubernetes/kube-client.d.ts +138 -0
  31. package/dist/kubernetes/kube-client.d.ts.map +1 -0
  32. package/dist/kubernetes/kube-client.js +26 -0
  33. package/dist/kubernetes/kube-exec.d.ts +7 -0
  34. package/dist/kubernetes/kube-exec.d.ts.map +1 -0
  35. package/dist/kubernetes/kube-exec.js +38 -0
  36. package/dist/kubernetes/kube-filesystem.d.ts +5 -0
  37. package/dist/kubernetes/kube-filesystem.d.ts.map +1 -0
  38. package/dist/kubernetes/kube-filesystem.js +57 -0
  39. package/dist/kubernetes/kube-sandbox.d.ts +25 -0
  40. package/dist/kubernetes/kube-sandbox.d.ts.map +1 -0
  41. package/dist/kubernetes/kube-sandbox.js +278 -0
  42. package/dist/testing/conformance.d.ts +13 -0
  43. package/dist/testing/conformance.d.ts.map +1 -0
  44. package/dist/testing/conformance.js +75 -0
  45. package/dist/testing/fake-sandbox.d.ts +16 -0
  46. package/dist/testing/fake-sandbox.d.ts.map +1 -0
  47. package/dist/testing/fake-sandbox.js +61 -0
  48. package/dist/testing/index.d.ts +3 -0
  49. package/dist/testing/index.d.ts.map +1 -0
  50. package/dist/testing/index.js +2 -0
  51. package/dist/tsconfig.tsbuildinfo +1 -0
  52. package/package.json +61 -0
@@ -0,0 +1,331 @@
1
+ /** Default KubeClient backed by the real @kubernetes/client-node (v1.x) API.
2
+ * KubeConfig.loadFromDefault() auto-detects in-cluster ServiceAccount token vs
3
+ * ~/.kube/config. Unit tests never construct this — they inject a fake KubeClient. */
4
+ import { Readable, Writable } from "node:stream";
5
+ import { ApiException, AuthorizationV1Api, CoreV1Api, Exec, KubeConfig, NetworkingV1Api, } from "@kubernetes/client-node";
6
+ import { KubeAuthorizationReviewError, } from "./kube-client.js";
7
+ const CONTAINER_NAME = "sandbox";
8
+ function statusCode(error) {
9
+ return error instanceof ApiException ? error.code : undefined;
10
+ }
11
+ /** Writable sink that accumulates written chunks into a single string, for
12
+ * capturing exec stdout/stderr without piping to a real stream destination. */
13
+ function collect() {
14
+ const chunks = [];
15
+ const stream = new Writable({
16
+ write(chunk, _encoding, callback) {
17
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
18
+ callback();
19
+ },
20
+ });
21
+ return { stream, text: () => Buffer.concat(chunks).toString("utf8") };
22
+ }
23
+ function toPodManifest(namespace, s) {
24
+ const mounts = [
25
+ { name: "workspace", mountPath: "/workspace" },
26
+ ...(s.readOnlyRootFilesystem
27
+ ? [
28
+ { name: "tmp", mountPath: "/tmp" },
29
+ { name: "run", mountPath: "/run" },
30
+ ]
31
+ : []),
32
+ ];
33
+ const volumes = [
34
+ { name: "workspace", persistentVolumeClaim: { claimName: s.pvcName } },
35
+ ...(s.readOnlyRootFilesystem
36
+ ? [
37
+ { name: "tmp", emptyDir: {} },
38
+ { name: "run", emptyDir: {} },
39
+ ]
40
+ : []),
41
+ ];
42
+ return {
43
+ metadata: { name: s.name, namespace, labels: { ...s.labels } },
44
+ spec: {
45
+ restartPolicy: "Always",
46
+ automountServiceAccountToken: s.automountServiceAccountToken,
47
+ securityContext: s.podSecurityContext,
48
+ containers: [
49
+ {
50
+ name: CONTAINER_NAME,
51
+ image: s.image,
52
+ command: ["sleep", "infinity"],
53
+ env: s.env.map((e) => ({ name: e.name, value: e.value })),
54
+ securityContext: s.containerSecurityContext,
55
+ ...(Object.keys(s.limits).length > 0 ? { resources: { limits: { ...s.limits } } } : {}),
56
+ volumeMounts: mounts,
57
+ },
58
+ ],
59
+ volumes,
60
+ },
61
+ };
62
+ }
63
+ function toPvcManifest(s) {
64
+ return {
65
+ metadata: { name: s.name, labels: { ...s.labels } },
66
+ spec: {
67
+ accessModes: ["ReadWriteOnce"],
68
+ resources: { requests: { storage: `${s.storageGi}Gi` } },
69
+ ...(s.storageClass ? { storageClassName: s.storageClass } : {}),
70
+ },
71
+ };
72
+ }
73
+ /** `mode` is always "deny" in this provider (allow-mode emits no policy). Deny =
74
+ * block all egress except a DNS carve-out plus any allowlisted CIDRs. The manifest
75
+ * below encodes ONLY deny semantics — guard against a future allow-mode caller. */
76
+ function toNetworkPolicyManifest(s) {
77
+ if (s.mode !== "deny") {
78
+ throw new Error(`toNetworkPolicyManifest only builds deny-mode policies; got mode "${s.mode}".`);
79
+ }
80
+ // Scope DNS egress to the cluster-DNS namespace (CoreDNS lives in kube-system).
81
+ // Without a `to:` selector the rule would allow port-53 traffic to ANY host,
82
+ // opening a DNS-tunneling exfiltration path out of the deny-mode sandbox.
83
+ const dnsEgress = {
84
+ to: [{ namespaceSelector: { matchLabels: { "kubernetes.io/metadata.name": "kube-system" } } }],
85
+ ports: [
86
+ { protocol: "UDP", port: 53 },
87
+ { protocol: "TCP", port: 53 },
88
+ ],
89
+ };
90
+ const cidrEgress = (s.allowlist ?? []).map((cidr) => ({ to: [{ ipBlock: { cidr } }] }));
91
+ return {
92
+ metadata: { name: s.name, labels: { ...s.labels } },
93
+ spec: {
94
+ podSelector: { matchLabels: { "b4.run/thread": s.threadLabelValue } },
95
+ policyTypes: ["Egress"],
96
+ egress: [dnsEgress, ...cidrEgress],
97
+ },
98
+ };
99
+ }
100
+ /** @internal Validates that a live policy is the B4.run-owned object we intend to
101
+ * replace, then carries only its optimistic-concurrency token into the desired body. */
102
+ export function prepareNetworkPolicyReplacement(existing, desired, threadLabelValue) {
103
+ const desiredName = desired.metadata?.name;
104
+ if (!desiredName || existing.metadata?.name !== desiredName) {
105
+ throw new Error("Cannot replace NetworkPolicy: existing name does not match desired name.");
106
+ }
107
+ if (existing.metadata.labels?.["app.kubernetes.io/managed-by"] !== "b4") {
108
+ throw new Error("Cannot replace NetworkPolicy: existing object is not B4.run-owned.");
109
+ }
110
+ if (existing.metadata.labels?.["b4.run/thread"] !== threadLabelValue) {
111
+ throw new Error("Cannot replace NetworkPolicy: existing thread label does not match.");
112
+ }
113
+ const resourceVersion = existing.metadata.resourceVersion;
114
+ if (!resourceVersion) {
115
+ throw new Error("Cannot replace NetworkPolicy: live resourceVersion is missing or empty.");
116
+ }
117
+ return {
118
+ ...desired,
119
+ metadata: { ...desired.metadata, resourceVersion },
120
+ };
121
+ }
122
+ /** Parses the exec status callback's V1Status into an exit code. Success -> 0;
123
+ * Failure with an ExitCode cause -> that code; anything else -> 1 (best-effort). */
124
+ function exitCodeFromStatus(status) {
125
+ if (!status || status.status === "Success")
126
+ return 0;
127
+ const cause = status.details?.causes?.find((c) => c.reason === "ExitCode");
128
+ if (cause?.message !== undefined) {
129
+ const n = Number.parseInt(cause.message, 10);
130
+ if (!Number.isNaN(n))
131
+ return n;
132
+ }
133
+ return 1;
134
+ }
135
+ export function createDefaultKubeClient() {
136
+ const kc = new KubeConfig();
137
+ kc.loadFromDefault();
138
+ const core = kc.makeApiClient(CoreV1Api);
139
+ const networking = kc.makeApiClient(NetworkingV1Api);
140
+ const authorization = kc.makeApiClient(AuthorizationV1Api);
141
+ const execClient = new Exec(kc);
142
+ return {
143
+ async readNamespacedPodPhase(ns, name) {
144
+ try {
145
+ const pod = await core.readNamespacedPod({ name, namespace: ns });
146
+ return pod.status?.phase ?? "Unknown";
147
+ }
148
+ catch (error) {
149
+ if (statusCode(error) === 404)
150
+ return null;
151
+ throw error;
152
+ }
153
+ },
154
+ async createNamespacedPod(ns, spec) {
155
+ await core.createNamespacedPod({ namespace: ns, body: toPodManifest(ns, spec) });
156
+ },
157
+ async deleteNamespacedPod(ns, name, opts) {
158
+ try {
159
+ await core.deleteNamespacedPod({
160
+ name,
161
+ namespace: ns,
162
+ ...(opts?.gracePeriodSeconds !== undefined
163
+ ? { gracePeriodSeconds: opts.gracePeriodSeconds }
164
+ : {}),
165
+ });
166
+ }
167
+ catch (error) {
168
+ if (statusCode(error) === 404)
169
+ return;
170
+ throw error;
171
+ }
172
+ },
173
+ async createNamespacedPvcIfAbsent(ns, spec) {
174
+ try {
175
+ await core.createNamespacedPersistentVolumeClaim({
176
+ namespace: ns,
177
+ body: toPvcManifest(spec),
178
+ });
179
+ }
180
+ catch (error) {
181
+ if (statusCode(error) === 409)
182
+ return;
183
+ throw error;
184
+ }
185
+ },
186
+ async deleteNamespacedPvc(ns, name) {
187
+ try {
188
+ await core.deleteNamespacedPersistentVolumeClaim({ name, namespace: ns });
189
+ }
190
+ catch (error) {
191
+ if (statusCode(error) === 404)
192
+ return;
193
+ throw error;
194
+ }
195
+ },
196
+ async pvcExists(ns, name) {
197
+ try {
198
+ await core.readNamespacedPersistentVolumeClaim({ name, namespace: ns });
199
+ return true;
200
+ }
201
+ catch (error) {
202
+ if (statusCode(error) === 404)
203
+ return false;
204
+ throw error;
205
+ }
206
+ },
207
+ async upsertNamespacedNetworkPolicy(ns, spec) {
208
+ const body = toNetworkPolicyManifest(spec);
209
+ try {
210
+ await networking.createNamespacedNetworkPolicy({ namespace: ns, body });
211
+ }
212
+ catch (error) {
213
+ if (statusCode(error) === 409) {
214
+ const existing = await networking.readNamespacedNetworkPolicy({
215
+ name: spec.name,
216
+ namespace: ns,
217
+ });
218
+ const replacement = prepareNetworkPolicyReplacement(existing, body, spec.threadLabelValue);
219
+ await networking.replaceNamespacedNetworkPolicy({
220
+ name: spec.name,
221
+ namespace: ns,
222
+ body: replacement,
223
+ });
224
+ return;
225
+ }
226
+ throw error;
227
+ }
228
+ },
229
+ async deleteNamespacedNetworkPolicy(ns, name) {
230
+ try {
231
+ await networking.deleteNamespacedNetworkPolicy({ name, namespace: ns });
232
+ }
233
+ catch (error) {
234
+ if (statusCode(error) === 404)
235
+ return;
236
+ throw error;
237
+ }
238
+ },
239
+ async exec(ns, pod, argv, opts = {}) {
240
+ const stdout = collect();
241
+ const stderr = collect();
242
+ let settledStatus;
243
+ // The library sends a close-stdin frame when the readable ends, giving the
244
+ // in-pod process (e.g. `cat > file`) a clean EOF. null = no stdin.
245
+ const stdin = opts.stdin !== undefined ? Readable.from(Buffer.from(opts.stdin, "utf8")) : null;
246
+ const signal = opts.signal;
247
+ // `ws` is untyped (the resolved WebSocket has no shipped types); track it so
248
+ // an abort can close the socket and tear down the orphaned in-pod process.
249
+ let socket;
250
+ let settled = false;
251
+ await new Promise((resolve, reject) => {
252
+ const finish = (fn) => {
253
+ if (settled)
254
+ return;
255
+ settled = true;
256
+ signal?.removeEventListener("abort", onAbort);
257
+ fn();
258
+ };
259
+ const onAbort = () => {
260
+ socket?.close();
261
+ finish(() => reject(new Error("Kubernetes exec aborted.")));
262
+ };
263
+ if (signal?.aborted) {
264
+ onAbort();
265
+ return;
266
+ }
267
+ signal?.addEventListener("abort", onAbort, { once: true });
268
+ execClient
269
+ .exec(ns, pod, CONTAINER_NAME, [...argv], stdout.stream, stderr.stream, stdin, false, (status) => {
270
+ settledStatus = status;
271
+ })
272
+ .then((ws) => {
273
+ socket = ws;
274
+ // Aborted between scheduling and connecting: close the fresh socket.
275
+ if (signal?.aborted) {
276
+ ws.close();
277
+ return;
278
+ }
279
+ ws.on("close", () => finish(() => resolve()));
280
+ ws.on("error", (event) => {
281
+ finish(() => reject(event instanceof Error ? event : new Error("Kubernetes exec socket error.")));
282
+ });
283
+ })
284
+ .catch((error) => finish(() => reject(error)));
285
+ });
286
+ return {
287
+ stdout: stdout.text(),
288
+ stderr: stderr.text(),
289
+ // The library fires the status callback before `close` on a normal exit,
290
+ // so an undefined status at close means the socket died abnormally (pod
291
+ // OOMKill mid-exec, network drop). Report that as a failure, not success.
292
+ exitCode: settledStatus === undefined ? 1 : exitCodeFromStatus(settledStatus),
293
+ };
294
+ },
295
+ async canI(ns, permission) {
296
+ try {
297
+ const review = await authorization.createSelfSubjectAccessReview({
298
+ body: {
299
+ spec: {
300
+ resourceAttributes: {
301
+ group: permission.apiGroup,
302
+ namespace: ns,
303
+ resource: permission.resource,
304
+ ...(permission.subresource !== undefined
305
+ ? { subresource: permission.subresource }
306
+ : {}),
307
+ verb: permission.verb,
308
+ },
309
+ },
310
+ },
311
+ });
312
+ return review.status?.allowed === true;
313
+ }
314
+ catch (error) {
315
+ throw new KubeAuthorizationReviewError(error instanceof ApiException ? "api" : "transport", error instanceof Error ? error.message : String(error), { cause: error });
316
+ }
317
+ },
318
+ async networkPolicyEnforced(ns) {
319
+ // Listing NetworkPolicy objects only proves the API is present, not that a
320
+ // CNI enforces them — we cannot portably introspect the CNI, so treat a
321
+ // successful list as inconclusive rather than a confirmed "true".
322
+ try {
323
+ await networking.listNamespacedNetworkPolicy({ namespace: ns });
324
+ return "unknown";
325
+ }
326
+ catch {
327
+ return false;
328
+ }
329
+ },
330
+ };
331
+ }
@@ -0,0 +1,138 @@
1
+ /** Narrow Kubernetes API seam the provider needs. Default impl (later task) wraps
2
+ * @kubernetes/client-node; unit tests inject a fake. Pod/PVC/NetworkPolicy specs
3
+ * are the minimal shapes this provider sets — NOT the full k8s object types. */
4
+ export interface KubePodSpec {
5
+ readonly name: string;
6
+ readonly image: string;
7
+ readonly labels: Readonly<Record<string, string>>;
8
+ readonly pvcName: string;
9
+ readonly env: readonly {
10
+ readonly name: string;
11
+ readonly value: string;
12
+ }[];
13
+ readonly limits: Readonly<Record<string, string>>;
14
+ readonly podSecurityContext: Readonly<Record<string, unknown>>;
15
+ readonly containerSecurityContext: Readonly<Record<string, unknown>>;
16
+ readonly readOnlyRootFilesystem: boolean;
17
+ readonly automountServiceAccountToken: boolean;
18
+ }
19
+ export interface KubePvcSpec {
20
+ readonly name: string;
21
+ readonly labels: Readonly<Record<string, string>>;
22
+ readonly storageGi: number;
23
+ readonly storageClass?: string;
24
+ }
25
+ export interface KubeNetworkPolicySpec {
26
+ readonly name: string;
27
+ readonly labels: Readonly<Record<string, string>>;
28
+ readonly threadLabelValue: string;
29
+ readonly mode: "deny" | "allow";
30
+ readonly allowlist?: readonly string[];
31
+ }
32
+ export type KubePermission = {
33
+ readonly apiGroup: "";
34
+ readonly resource: "pods";
35
+ readonly subresource?: never;
36
+ readonly verb: "create" | "get" | "delete";
37
+ } | {
38
+ readonly apiGroup: "";
39
+ readonly resource: "pods";
40
+ readonly subresource: "exec";
41
+ readonly verb: "create" | "get";
42
+ } | {
43
+ readonly apiGroup: "";
44
+ readonly resource: "persistentvolumeclaims";
45
+ readonly subresource?: never;
46
+ readonly verb: "create" | "get" | "delete";
47
+ } | {
48
+ readonly apiGroup: "networking.k8s.io";
49
+ readonly resource: "networkpolicies";
50
+ readonly subresource?: never;
51
+ readonly verb: "create" | "get" | "list" | "update" | "delete";
52
+ };
53
+ export declare const REQUIRED_KUBE_PERMISSIONS: readonly [{
54
+ readonly apiGroup: "";
55
+ readonly resource: "pods";
56
+ readonly verb: "create";
57
+ }, {
58
+ readonly apiGroup: "";
59
+ readonly resource: "pods";
60
+ readonly verb: "get";
61
+ }, {
62
+ readonly apiGroup: "";
63
+ readonly resource: "pods";
64
+ readonly verb: "delete";
65
+ }, {
66
+ readonly apiGroup: "";
67
+ readonly resource: "persistentvolumeclaims";
68
+ readonly verb: "create";
69
+ }, {
70
+ readonly apiGroup: "";
71
+ readonly resource: "persistentvolumeclaims";
72
+ readonly verb: "get";
73
+ }, {
74
+ readonly apiGroup: "";
75
+ readonly resource: "persistentvolumeclaims";
76
+ readonly verb: "delete";
77
+ }, {
78
+ readonly apiGroup: "";
79
+ readonly resource: "pods";
80
+ readonly subresource: "exec";
81
+ readonly verb: "create";
82
+ }, {
83
+ readonly apiGroup: "";
84
+ readonly resource: "pods";
85
+ readonly subresource: "exec";
86
+ readonly verb: "get";
87
+ }, {
88
+ readonly apiGroup: "networking.k8s.io";
89
+ readonly resource: "networkpolicies";
90
+ readonly verb: "create";
91
+ }, {
92
+ readonly apiGroup: "networking.k8s.io";
93
+ readonly resource: "networkpolicies";
94
+ readonly verb: "get";
95
+ }, {
96
+ readonly apiGroup: "networking.k8s.io";
97
+ readonly resource: "networkpolicies";
98
+ readonly verb: "list";
99
+ }, {
100
+ readonly apiGroup: "networking.k8s.io";
101
+ readonly resource: "networkpolicies";
102
+ readonly verb: "update";
103
+ }, {
104
+ readonly apiGroup: "networking.k8s.io";
105
+ readonly resource: "networkpolicies";
106
+ readonly verb: "delete";
107
+ }];
108
+ export declare class KubeAuthorizationReviewError extends Error {
109
+ readonly kind: "api" | "transport";
110
+ constructor(kind: "api" | "transport", message: string, options?: ErrorOptions);
111
+ }
112
+ export type PodPhase = "Pending" | "Running" | "Succeeded" | "Failed" | "Unknown";
113
+ export interface KubeClient {
114
+ readNamespacedPodPhase(ns: string, name: string): Promise<PodPhase | null>;
115
+ createNamespacedPod(ns: string, spec: KubePodSpec): Promise<void>;
116
+ deleteNamespacedPod(ns: string, name: string, opts?: {
117
+ readonly gracePeriodSeconds?: number;
118
+ }): Promise<void>;
119
+ createNamespacedPvcIfAbsent(ns: string, spec: KubePvcSpec): Promise<void>;
120
+ deleteNamespacedPvc(ns: string, name: string): Promise<void>;
121
+ /** Existence probe: true if the PVC is still present (including Terminating). */
122
+ pvcExists(ns: string, name: string): Promise<boolean>;
123
+ upsertNamespacedNetworkPolicy(ns: string, spec: KubeNetworkPolicySpec): Promise<void>;
124
+ deleteNamespacedNetworkPolicy(ns: string, name: string): Promise<void>;
125
+ exec(ns: string, pod: string, argv: readonly string[], opts?: {
126
+ readonly stdin?: string;
127
+ readonly signal?: AbortSignal;
128
+ }): Promise<{
129
+ readonly stdout: string;
130
+ readonly stderr: string;
131
+ readonly exitCode: number;
132
+ }>;
133
+ /** SelfSubjectAccessReview probe for preflight. */
134
+ canI(namespace: string, permission: KubePermission): Promise<boolean>;
135
+ /** Whether a NetworkPolicy-enforcing CNI is present; "unknown" if undetectable. */
136
+ networkPolicyEnforced(ns: string): Promise<boolean | "unknown">;
137
+ }
138
+ //# sourceMappingURL=kube-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kube-client.d.ts","sourceRoot":"","sources":["../../src/kubernetes/kube-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ /** Narrow Kubernetes API seam the provider needs. Default impl (later task) wraps
2
+ * @kubernetes/client-node; unit tests inject a fake. Pod/PVC/NetworkPolicy specs
3
+ * are the minimal shapes this provider sets — NOT the full k8s object types. */
4
+ export const REQUIRED_KUBE_PERMISSIONS = [
5
+ { apiGroup: "", resource: "pods", verb: "create" },
6
+ { apiGroup: "", resource: "pods", verb: "get" },
7
+ { apiGroup: "", resource: "pods", verb: "delete" },
8
+ { apiGroup: "", resource: "persistentvolumeclaims", verb: "create" },
9
+ { apiGroup: "", resource: "persistentvolumeclaims", verb: "get" },
10
+ { apiGroup: "", resource: "persistentvolumeclaims", verb: "delete" },
11
+ { apiGroup: "", resource: "pods", subresource: "exec", verb: "create" },
12
+ { apiGroup: "", resource: "pods", subresource: "exec", verb: "get" },
13
+ { apiGroup: "networking.k8s.io", resource: "networkpolicies", verb: "create" },
14
+ { apiGroup: "networking.k8s.io", resource: "networkpolicies", verb: "get" },
15
+ { apiGroup: "networking.k8s.io", resource: "networkpolicies", verb: "list" },
16
+ { apiGroup: "networking.k8s.io", resource: "networkpolicies", verb: "update" },
17
+ { apiGroup: "networking.k8s.io", resource: "networkpolicies", verb: "delete" },
18
+ ];
19
+ export class KubeAuthorizationReviewError extends Error {
20
+ kind;
21
+ constructor(kind, message, options) {
22
+ super(message, options);
23
+ this.name = "KubeAuthorizationReviewError";
24
+ this.kind = kind;
25
+ }
26
+ }
@@ -0,0 +1,7 @@
1
+ import type { ExecBackend } from "@b4run/workspace";
2
+ import type { KubeClient } from "./kube-client.js";
3
+ /** ExecBackend that runs commands inside a pod via KubeClient.exec (sh -c). */
4
+ export declare function kubeExec(client: KubeClient, namespace: string, pod: string, opts?: {
5
+ readonly timeoutMs?: number;
6
+ }): ExecBackend;
7
+ //# sourceMappingURL=kube-exec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kube-exec.d.ts","sourceRoot":"","sources":["../../src/kubernetes/kube-exec.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAkB,WAAW,EAAE,MAAM,kBAAkB,CAAA;AACnE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAMlD,+EAA+E;AAC/E,wBAAgB,QAAQ,CACtB,MAAM,EAAE,UAAU,EAClB,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,IAAI,GAAE;IAAE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GACzC,WAAW,CAoCb"}
@@ -0,0 +1,38 @@
1
+ function shellQuote(s) {
2
+ return `'${s.replaceAll("'", `'\\''`)}'`;
3
+ }
4
+ /** ExecBackend that runs commands inside a pod via KubeClient.exec (sh -c). */
5
+ export function kubeExec(client, namespace, pod, opts = {}) {
6
+ return {
7
+ async runCommand(args, ctx) {
8
+ const envPrefix = args.env
9
+ ? Object.entries(args.env)
10
+ .map(([k, v]) => {
11
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) {
12
+ throw new Error(`Invalid environment variable name ${JSON.stringify(k)}: keys must match /^[A-Za-z_][A-Za-z0-9_]*$/`);
13
+ }
14
+ return `${k}=${shellQuote(v)} `;
15
+ })
16
+ .join("")
17
+ : "";
18
+ const cwd = args.cwd ?? ctx.workspaceRoot;
19
+ const cdPrefix = cwd ? `cd ${shellQuote(cwd)} && ` : "";
20
+ const full = `${envPrefix}${cdPrefix}${args.command}`;
21
+ const shArgs = ["sh", "-c", full];
22
+ // `timeout` has second granularity, so round up to the enforced ceiling and
23
+ // report THAT (not the raw ms) — otherwise `timeoutMs: 500` reports "500ms"
24
+ // while the process actually gets a full 1s.
25
+ const timeoutSecs = opts.timeoutMs !== undefined ? Math.ceil(opts.timeoutMs / 1000) : undefined;
26
+ const argv = timeoutSecs !== undefined ? ["timeout", `${timeoutSecs}s`, ...shArgs] : shArgs;
27
+ const r = await client.exec(namespace, pod, argv, { signal: ctx.signal });
28
+ if (timeoutSecs !== undefined && r.exitCode === 124) {
29
+ return {
30
+ stdout: r.stdout,
31
+ stderr: `${r.stderr}${r.stderr ? "\n" : ""}Command timed out after ${timeoutSecs}s (resources.timeoutMs: ${opts.timeoutMs}ms).`,
32
+ exitCode: 124,
33
+ };
34
+ }
35
+ return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode };
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,5 @@
1
+ import type { FilesystemBackend } from "@b4run/workspace";
2
+ import type { KubeClient } from "./kube-client.js";
3
+ /** FilesystemBackend whose ops run inside a pod via KubeClient.exec. */
4
+ export declare function kubeFilesystem(client: KubeClient, namespace: string, pod: string): FilesystemBackend;
5
+ //# sourceMappingURL=kube-filesystem.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kube-filesystem.d.ts","sourceRoot":"","sources":["../../src/kubernetes/kube-filesystem.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAkB,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACzE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAMlD,wEAAwE;AACxE,wBAAgB,cAAc,CAC5B,MAAM,EAAE,UAAU,EAClB,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,GACV,iBAAiB,CAiDnB"}
@@ -0,0 +1,57 @@
1
+ function q(s) {
2
+ return `'${s.replaceAll("'", `'\\''`)}'`;
3
+ }
4
+ /** FilesystemBackend whose ops run inside a pod via KubeClient.exec. */
5
+ export function kubeFilesystem(client, namespace, pod) {
6
+ const run = (cmd, ctx, stdin) => client.exec(namespace, pod, ["sh", "-c", cmd], {
7
+ ...(stdin !== undefined ? { stdin } : {}),
8
+ signal: ctx.signal,
9
+ });
10
+ return {
11
+ async readFile(path, ctx, opts) {
12
+ const r = await run(`cat ${q(path)}`, ctx);
13
+ if (r.exitCode !== 0)
14
+ throw new Error(`readFile failed: ${r.stderr.trim()}`);
15
+ const max = opts?.maxBytes;
16
+ if (max !== undefined && Number.isFinite(max) && Buffer.byteLength(r.stdout) > max) {
17
+ throw new Error(`readFile ${path}: content exceeds maxBytes (${max}).`);
18
+ }
19
+ return r.stdout;
20
+ },
21
+ async writeFile(path, content, ctx) {
22
+ const r = await run(`mkdir -p "$(dirname ${q(path)})" && cat > ${q(path)}`, ctx, content);
23
+ if (r.exitCode !== 0)
24
+ throw new Error(`writeFile failed: ${r.stderr.trim()}`);
25
+ return { bytesWritten: Buffer.byteLength(content) };
26
+ },
27
+ async listDir(path, ctx) {
28
+ const r = await run(`ls -1 ${q(path)}`, ctx);
29
+ if (r.exitCode !== 0)
30
+ throw new Error(`listDir failed: ${r.stderr.trim()}`);
31
+ return r.stdout
32
+ .split("\n")
33
+ .map((l) => l.trim())
34
+ .filter(Boolean);
35
+ },
36
+ async realPath(path, ctx) {
37
+ const r = await run(`realpath -m ${q(path)}`, ctx);
38
+ return r.exitCode === 0 ? r.stdout.trim() : path;
39
+ },
40
+ async statFile(path, ctx) {
41
+ const r = await run(`stat -c '%s %Y' ${q(path)}`, ctx);
42
+ if (r.exitCode !== 0)
43
+ throw new Error(`statFile failed: ${r.stderr.trim()}`);
44
+ const [size, mtime] = r.stdout.trim().split(" ");
45
+ return { size: Number(size), mtimeMs: Number(mtime) * 1000 };
46
+ },
47
+ async removeFile(path, ctx) {
48
+ await run(`rm -f ${q(path)}`, ctx);
49
+ },
50
+ async touchFile(path, ctx) {
51
+ await run(`touch ${q(path)}`, ctx);
52
+ },
53
+ async mkdir(path, ctx) {
54
+ await run(`mkdir -p ${q(path)}`, ctx);
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,25 @@
1
+ import type { SandboxPolicy, SandboxProvider } from "@b4run/workspace";
2
+ import { type KubeClient } from "./kube-client.js";
3
+ export interface KubernetesSandboxOptions {
4
+ readonly image: string;
5
+ readonly namespace?: string;
6
+ readonly storageClass?: string;
7
+ readonly startupTimeoutMs?: number;
8
+ /** Injected for tests; defaults to the real @kubernetes/client-node impl (later task). */
9
+ readonly client?: KubeClient;
10
+ }
11
+ export declare function resolveSecurity(policy: SandboxPolicy): {
12
+ podSecurityContext: Record<string, unknown>;
13
+ containerSecurityContext: Record<string, unknown>;
14
+ readOnly: boolean;
15
+ user: {
16
+ uid: number;
17
+ gid: number;
18
+ } | undefined;
19
+ };
20
+ /** Kubernetes SandboxProvider. Per thread: a keeper Pod `b4-sbx-<t>` (sleep
21
+ * infinity) + a PVC `b4-sbx-vol-<t>` at /workspace. acquire = create-or-reattach;
22
+ * release deletes the Pod (keeps the PVC); destroy deletes both. Hardening maps to
23
+ * SecurityContext; fsGroup chowns the PVC (no chown-init); the pod mounts no SA token. */
24
+ export declare function kubernetesSandbox(opts: KubernetesSandboxOptions): SandboxProvider;
25
+ //# sourceMappingURL=kube-sandbox.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kube-sandbox.d.ts","sourceRoot":"","sources":["../../src/kubernetes/kube-sandbox.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAiB,aAAa,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAGrF,OAAO,EAEL,KAAK,UAAU,EAIhB,MAAM,kBAAkB,CAAA;AAiCzB,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;IAClC,0FAA0F;IAC1F,QAAQ,CAAC,MAAM,CAAC,EAAE,UAAU,CAAA;CAC7B;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,aAAa,GAAG;IACtD,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC3C,wBAAwB,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACjD,QAAQ,EAAE,OAAO,CAAA;IACjB,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAA;CAC/C,CAiCA;AAED;;;0FAG0F;AAC1F,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,wBAAwB,GAAG,eAAe,CA8KjF"}