@polycore/runner 0.2.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycore/runner",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Polycore runner: executes governed, read-only-constrained connectors against a customer's datastores. Runs in the customer's own infrastructure; holds scoped credentials and connects outbound to the control plane.",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  "tsx": "4.21.0",
31
31
  "ws": "8.18.3",
32
32
  "zod": "4.4.3",
33
- "@polycore/protocol": "^0.2.0"
33
+ "@polycore/protocol": "^0.4.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "25.6.2",
package/src/action.ts CHANGED
@@ -14,7 +14,7 @@ import type { z } from "zod";
14
14
  * its environment — the credential never leaves the runner's host.
15
15
  */
16
16
 
17
- export type ActionEffect = "read" | "mutate";
17
+ export type ActionHazard = "read" | "write";
18
18
 
19
19
  /** Human-facing progress sink, mirroring the desktop SDK's `ctx.log`. */
20
20
  export interface ActionLogger {
@@ -39,11 +39,11 @@ export interface Action<Input = unknown, Output = unknown> {
39
39
  readonly description: string;
40
40
  readonly tags?: readonly string[];
41
41
  /**
42
- * Hazard class. Defaults to `mutate` — the safe default, since an
42
+ * Hazard class. Defaults to `write` — the safe default, since an
43
43
  * unclassified operation must be treated as dangerous and routed through
44
44
  * the approval gate. Declare `read` for idempotent, side-effect-free work.
45
45
  */
46
- readonly effect?: ActionEffect;
46
+ readonly hazard?: ActionHazard;
47
47
  readonly input: z.ZodType<Input>;
48
48
  readonly output: z.ZodType<Output>;
49
49
  /** Secret keys this action needs, mapped to a human description. */
package/src/cli.ts CHANGED
@@ -156,7 +156,7 @@ async function main(): Promise<void> {
156
156
  for (const [slug, registry] of registries) {
157
157
  for (const capability of registry.list()) {
158
158
  process.stdout.write(
159
- `[${slug}] ${capability.name} [${capability.kind}/${capability.effect}] ${capability.description}\n`,
159
+ `[${slug}] ${capability.name} [${capability.kind}/${capability.hazard}] ${capability.description}\n`,
160
160
  );
161
161
  }
162
162
  }
package/src/config.ts CHANGED
@@ -82,6 +82,55 @@ export const ProjectConfigSchema = z
82
82
 
83
83
  export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
84
84
 
85
+ /**
86
+ * Resource caps a delegated workload runs under. Each field is an upper bound
87
+ * the runner enforces at the container boundary regardless of what the
88
+ * approved code requests; a workload may ask for less, never more.
89
+ */
90
+ export const SandboxLimitsSchema = z.object({
91
+ /** CPU cores (fractional allowed), e.g. `1` or `0.5`. */
92
+ cpus: z.number().positive().optional(),
93
+ /** Memory ceiling in MiB. */
94
+ memoryMb: z.number().int().positive().optional(),
95
+ /** Wall-clock timeout in seconds; the container is killed past it. */
96
+ timeoutSeconds: z.number().int().positive().optional(),
97
+ /** Max process count inside the sandbox (fork-bomb guard). */
98
+ pids: z.number().int().positive().optional(),
99
+ });
100
+
101
+ export type SandboxLimits = z.infer<typeof SandboxLimitsSchema>;
102
+
103
+ /**
104
+ * Enables the delegated-workload executor (`workload.execute`) on this runner.
105
+ * When present, the runner advertises a single generic capability that runs
106
+ * **human-approved** arbitrary code inside a disposable, OS-isolated container
107
+ * on the runner's own host. The container holds only the credentials the
108
+ * approved workload requested; it never sees the runner's other secrets.
109
+ *
110
+ * Absent means the runner serves only connectors + actions, exactly as before.
111
+ */
112
+ export const SandboxConfigSchema = z.object({
113
+ /**
114
+ * Base container image the workload runs in. Must contain a Node runtime
115
+ * plus `tsx` on `PATH` (to run `.ts` entrypoints) and a package manager
116
+ * (`npm`) for workloads that install dependencies.
117
+ */
118
+ image: z.string().min(1),
119
+ /**
120
+ * OCI runtime that isolates each workload. Defaults to `runsc` (gVisor):
121
+ * syscall-level isolation that runs on any Linux VM with no nested
122
+ * virtualization. Set to `runc` only where gVisor is unavailable (weaker,
123
+ * shared-kernel isolation).
124
+ */
125
+ runtime: z.string().min(1).optional(),
126
+ /** Container CLI to invoke (default `docker`). */
127
+ cli: z.string().min(1).optional(),
128
+ /** Default/maximum resource limits applied to every workload. */
129
+ limits: SandboxLimitsSchema.optional(),
130
+ });
131
+
132
+ export type SandboxConfig = z.infer<typeof SandboxConfigSchema>;
133
+
85
134
  /**
86
135
  * How the runner reaches the control plane. Provisioned at enrollment; the
87
136
  * `signingSecret` is shared out-of-band (never sent on the wire) so the
@@ -119,6 +168,11 @@ export const RunnerConfigSchema = z.object({
119
168
  * `process.env`. These never reach the control plane.
120
169
  */
121
170
  secrets: z.record(z.string(), z.string()).optional(),
171
+ /**
172
+ * Opt-in delegated-workload execution (`workload.execute`). Absent leaves
173
+ * the runner serving only connectors + actions. See {@link SandboxConfigSchema}.
174
+ */
175
+ sandbox: SandboxConfigSchema.optional(),
122
176
  });
123
177
 
124
178
  export type RunnerConfig = z.infer<typeof RunnerConfigSchema>;
package/src/connect.ts CHANGED
@@ -23,7 +23,7 @@ export function describeConnectors(
23
23
  return registry.list().map((capability) => ({
24
24
  name: capability.name,
25
25
  description: capability.description,
26
- effect: capability.effect,
26
+ hazard: capability.hazard,
27
27
  kind: capability.kind,
28
28
  params: z.toJSONSchema(capability.params),
29
29
  }));
package/src/connector.ts CHANGED
@@ -4,17 +4,29 @@ import type { Action, ActionLogger, LoadedAction } from "./action.js";
4
4
  import type { EnvironmentConfig, ProjectConfig } from "./config.js";
5
5
 
6
6
  /**
7
- * Hazard class for a connector. Lane 1 connectors are always `read`: * they exist precisely because they are mutation-incapable, which is what
8
- * lets them run with no human approval. Mutating operations are modeled as
9
- * blessed *actions*, not connectors, and run behind the approval gate.
7
+ * Hazard class for a connector. Lane 1 connectors are always `read`: they exist
8
+ * precisely because they are mutation-incapable, which is what lets them run
9
+ * with no human approval. Mutating operations are modeled as blessed *actions*,
10
+ * not connectors, and run behind the approval gate.
10
11
  */
11
- export type ConnectorEffect = "read";
12
+ export type ConnectorHazard = "read";
12
13
 
13
- /** Hazard class of any capability (connector or action). */
14
- export type CapabilityEffect = "read" | "mutate";
14
+ /**
15
+ * Hazard class of any capability, the honest description of what it can do and
16
+ * therefore how it must be gated:
17
+ * - `read`: no side effects, runs with no approval.
18
+ * - `write`: a known, human-declared mutation, runs behind one human approval.
19
+ * - `unknown`: arbitrary code whose effect cannot be established from a trusted
20
+ * declaration (a delegated workload). A human must review the code before it
21
+ * runs; it is never auto-run and is never labeled a "mutation".
22
+ */
23
+ export type Hazard = "read" | "write" | "unknown";
15
24
 
16
- /** Whether a capability is a built-in connector or a customer-authored action. */
17
- export type CapabilityKind = "connector" | "action";
25
+ /**
26
+ * Whether a capability is a built-in connector, a customer-authored action,
27
+ * or the generic delegated-workload executor (`workload.execute`).
28
+ */
29
+ export type CapabilityKind = "connector" | "action" | "workload";
18
30
 
19
31
  /** Everything a connector needs to execute one request. */
20
32
  export interface ConnectorContext {
@@ -39,8 +51,8 @@ export interface Connector<Args = unknown, Result = unknown> {
39
51
  readonly name: string;
40
52
  /** One-line description for catalogs and the agent. */
41
53
  readonly description: string;
42
- /** Always `read`: see {@link ConnectorEffect}. */
43
- readonly effect: ConnectorEffect;
54
+ /** Always `read`: see {@link ConnectorHazard}. */
55
+ readonly hazard: ConnectorHazard;
44
56
  /**
45
57
  * Which environment-config family this connector needs (e.g. `firestore`).
46
58
  * A built-in connector is registered for a project only when at least one
@@ -78,7 +90,7 @@ export function connectorAvailableFor(
78
90
  export interface RegisteredCapability {
79
91
  readonly name: string;
80
92
  readonly description: string;
81
- readonly effect: CapabilityEffect;
93
+ readonly hazard: Hazard;
82
94
  readonly kind: CapabilityKind;
83
95
  readonly params: z.ZodType;
84
96
  run(args: unknown, ctx: ConnectorContext): Promise<unknown>;
@@ -155,7 +167,7 @@ export class ConnectorRegistry {
155
167
  this.add({
156
168
  name: connector.name,
157
169
  description: connector.description,
158
- effect: connector.effect,
170
+ hazard: connector.hazard,
159
171
  kind: "connector",
160
172
  params: connector.params,
161
173
  run: (args, ctx) => connector.run(args, ctx),
@@ -168,6 +180,16 @@ export class ConnectorRegistry {
168
180
  }
169
181
  }
170
182
 
183
+ /**
184
+ * Register a pre-built capability directly. Used for capabilities that are
185
+ * neither a read-only `Connector` nor a folder-loaded action, e.g. the
186
+ * `workload.execute` executor, whose `hazard` is `unknown` and whose `run`
187
+ * bridges into a sandbox backend.
188
+ */
189
+ public registerCapability(capability: RegisteredCapability): void {
190
+ this.add(capability);
191
+ }
192
+
171
193
  /**
172
194
  * Register a customer-authored action as a capability. The action's `input`
173
195
  * schema becomes the capability params; its declared secrets are resolved
@@ -178,7 +200,7 @@ export class ConnectorRegistry {
178
200
  this.add({
179
201
  name: id,
180
202
  description: action.description,
181
- effect: action.effect ?? "mutate",
203
+ hazard: action.hazard ?? "write",
182
204
  kind: "action",
183
205
  params: action.input,
184
206
  run: (args, ctx) =>
@@ -100,7 +100,7 @@ const firestoreQuery: Connector<z.infer<typeof queryParams>, unknown> = {
100
100
  name: "firestore.query",
101
101
  description:
102
102
  "Read documents from a Firestore collection with optional where filters, ordering, and a limit. Returns at most `limit` documents (a capped page for inspection); do NOT use it to count or total; use firestore.count for that. Read-only.",
103
- effect: "read",
103
+ hazard: "read",
104
104
  configKey: "firestore",
105
105
  params: queryParams,
106
106
  async run(args, ctx) {
@@ -133,7 +133,7 @@ const firestoreGet: Connector<z.infer<typeof getParams>, unknown> = {
133
133
  name: "firestore.get",
134
134
  description:
135
135
  "Read a single Firestore document by collection and id. Read-only.",
136
- effect: "read",
136
+ hazard: "read",
137
137
  configKey: "firestore",
138
138
  params: getParams,
139
139
  async run(args, ctx) {
@@ -156,7 +156,7 @@ const firestoreCount: Connector<z.infer<typeof countParams>, number> = {
156
156
  name: "firestore.count",
157
157
  description:
158
158
  "Count documents in a Firestore collection (optionally filtered) using a server-side aggregation. It counts EVERY matching document, so it is the correct, exact tool for any 'how many' / total question. For 'how many <record>s', count the <record> collection (e.g. users → the \"users\" collection) with NO filter unless a subset is explicitly asked for; if unsure of the exact collection name, call firestore.listCollections first. Read-only.",
159
- effect: "read",
159
+ hazard: "read",
160
160
  configKey: "firestore",
161
161
  params: countParams,
162
162
  async run(args, ctx) {
@@ -172,7 +172,7 @@ const listCollections: Connector<Record<string, never>, string[]> = {
172
172
  name: "firestore.listCollections",
173
173
  description:
174
174
  "List the top-level Firestore collection ids. Call this FIRST to ground yourself whenever you are not certain which collection holds the kind of record the question is about, then count/query that collection. Map the record asked about (a 'user', an 'order', a 'workspace') to its collection. The name of an organization, product, team, or environment is NEVER a collection: it only identifies whose system you are querying, so never treat it as a collection, id, or filter. Read-only.",
175
- effect: "read",
175
+ hazard: "read",
176
176
  configKey: "firestore",
177
177
  params: z.object({}),
178
178
  async run(_args, ctx) {
package/src/index.ts CHANGED
@@ -10,10 +10,19 @@ import {
10
10
  } from "./connector.js";
11
11
  import { firestoreConnectors } from "./connectors/firestore.js";
12
12
  import { loadActions } from "./load-actions.js";
13
+ import {
14
+ createSandboxBackend,
15
+ createWorkloadCapability,
16
+ } from "./workload/capability.js";
17
+ import { resolveSandboxMaxima } from "./workload/limits.js";
18
+ import type {
19
+ ResolvedSandboxLimits,
20
+ SandboxBackend,
21
+ } from "./workload/types.js";
13
22
 
14
23
  export type {
15
24
  Action,
16
- ActionEffect,
25
+ ActionHazard,
17
26
  ActionLogger,
18
27
  ActionRunContext,
19
28
  LoadedAction,
@@ -26,6 +35,7 @@ export type {
26
35
  ProjectConfig,
27
36
  RunnerConfig,
28
37
  } from "./config.js";
38
+ export type { SandboxConfig, SandboxLimits } from "./config.js";
29
39
  export {
30
40
  buildRunnerConfigFromEnv,
31
41
  ControlPlaneConfigSchema,
@@ -42,6 +52,8 @@ export {
42
52
  RUNNER_CONFIG_ENV,
43
53
  RUNNER_PROJECTS_ENV,
44
54
  RunnerConfigSchema,
55
+ SandboxConfigSchema,
56
+ SandboxLimitsSchema,
45
57
  } from "./config.js";
46
58
  export type { RunnerClientOptions } from "./connect.js";
47
59
  export {
@@ -50,12 +62,12 @@ export {
50
62
  RunnerClient,
51
63
  } from "./connect.js";
52
64
  export type {
53
- CapabilityEffect,
54
65
  CapabilityKind,
55
66
  Connector,
56
67
  ConnectorContext,
57
- ConnectorEffect,
68
+ ConnectorHazard,
58
69
  DispatchResult,
70
+ Hazard,
59
71
  RegisteredCapability,
60
72
  SecretSource,
61
73
  } from "./connector.js";
@@ -66,6 +78,38 @@ export {
66
78
  } from "./connector.js";
67
79
  export { firestoreConnectors } from "./connectors/firestore.js";
68
80
  export { loadActions } from "./load-actions.js";
81
+ export {
82
+ createSandboxBackend,
83
+ createWorkloadCapability,
84
+ WORKLOAD_CAPABILITY_NAME,
85
+ } from "./workload/capability.js";
86
+ export {
87
+ assertSafeRelativePath,
88
+ buildContainerArgs,
89
+ buildRunScript,
90
+ ContainerSandboxBackend,
91
+ } from "./workload/container-backend.js";
92
+ export { workloadDigest } from "./workload/digest.js";
93
+ export {
94
+ clampWorkloadLimits,
95
+ DEFAULT_SANDBOX_LIMITS,
96
+ resolveSandboxMaxima,
97
+ } from "./workload/limits.js";
98
+ export type {
99
+ ResolvedSandboxLimits,
100
+ SandboxBackend,
101
+ SandboxRunRequest,
102
+ SandboxRunResult,
103
+ WorkloadFile,
104
+ WorkloadLimits,
105
+ WorkloadResult,
106
+ WorkloadSpec,
107
+ } from "./workload/types.js";
108
+ export {
109
+ workloadFileSchema,
110
+ workloadLimitsSchema,
111
+ workloadSpecSchema,
112
+ } from "./workload/types.js";
69
113
  // The pinned Zod, re-exported so action authors share the runner's exact
70
114
  // instance, schemas must be introspectable by the runner's `z.toJSONSchema`.
71
115
  // Authors import `{ defineAction, z }` from "@polycore/runner", never from a
@@ -85,11 +129,20 @@ export interface CreateRegistryOptions {
85
129
  * family at least one of the project's environments configures.
86
130
  */
87
131
  readonly project?: ProjectConfig;
132
+ /**
133
+ * When given, the generic `workload.execute` capability is registered,
134
+ * running approved arbitrary code in `backend` under `maxima` resource caps.
135
+ */
136
+ readonly sandbox?: {
137
+ readonly backend: SandboxBackend;
138
+ readonly maxima: ResolvedSandboxLimits;
139
+ };
88
140
  }
89
141
 
90
142
  /**
91
- * Build a registry with the runner's built-in connectors (Lane 1) plus any
92
- * customer-authored actions (Lane 2/3).
143
+ * Build a registry with the runner's built-in connectors (Lane 1), any
144
+ * customer-authored actions (Lane 2/3), and, when a sandbox is provided, the
145
+ * delegated-workload executor.
93
146
  */
94
147
  export function createRegistry(
95
148
  options: CreateRegistryOptions = {},
@@ -103,6 +156,13 @@ export function createRegistry(
103
156
  for (const loaded of options.actions ?? []) {
104
157
  registry.registerAction(loaded, options.secrets ?? {});
105
158
  }
159
+ if (options.sandbox) {
160
+ registry.registerCapability(
161
+ createWorkloadCapability(options.sandbox.backend, options.secrets ?? {}, {
162
+ maxima: options.sandbox.maxima,
163
+ }),
164
+ );
165
+ }
106
166
  return registry;
107
167
  }
108
168
 
@@ -147,6 +207,15 @@ export async function buildProjectRegistries(
147
207
  ): Promise<Map<string, ConnectorRegistry>> {
148
208
  const registries = new Map<string, ConnectorRegistry>();
149
209
  const projectCount = Object.keys(config.projects).length;
210
+ // Sandbox is a runner-level capability: one backend + maxima shared across
211
+ // projects, each registered with its own secrets so a workload can only ask
212
+ // for credentials of the project it targets.
213
+ const sandbox = config.sandbox
214
+ ? {
215
+ backend: createSandboxBackend(config.sandbox),
216
+ maxima: resolveSandboxMaxima(config.sandbox.limits),
217
+ }
218
+ : undefined;
150
219
  for (const [slug, project] of Object.entries(config.projects)) {
151
220
  const dir =
152
221
  actionsBaseDir === undefined
@@ -159,6 +228,7 @@ export async function buildProjectRegistries(
159
228
  project,
160
229
  actions,
161
230
  secrets: { ...config.secrets, ...project.secrets },
231
+ ...(sandbox ? { sandbox } : {}),
162
232
  }),
163
233
  );
164
234
  }
@@ -0,0 +1,121 @@
1
+ import type { SandboxConfig } from "../config.js";
2
+ import type {
3
+ ConnectorContext,
4
+ RegisteredCapability,
5
+ SecretSource,
6
+ } from "../connector.js";
7
+ import { ContainerSandboxBackend } from "./container-backend.js";
8
+ import { workloadDigest } from "./digest.js";
9
+ import { clampWorkloadLimits } from "./limits.js";
10
+ import type {
11
+ ResolvedSandboxLimits,
12
+ SandboxBackend,
13
+ WorkloadResult,
14
+ } from "./types.js";
15
+ import { workloadSpecSchema } from "./types.js";
16
+
17
+ /** The single generic capability name for delegated code execution. */
18
+ export const WORKLOAD_CAPABILITY_NAME = "workload.execute";
19
+
20
+ const WORKLOAD_DESCRIPTION =
21
+ "Run a short program you author, in an isolated sandbox on the runner host, " +
22
+ "when no other connector or action covers the task. Use this for arbitrary " +
23
+ "computation and multi-step work: parsing files, reconciling systems, batch " +
24
+ "operations, calling an API no connector exposes. Provide `title` (a short " +
25
+ 'human-readable label for what the program does, e.g. "Credit top 10 active ' +
26
+ 'users $5", shown to the human reviewer), `files` (source, at least the ' +
27
+ "entrypoint; include a package.json to use npm libraries), `entrypoint` (the " +
28
+ "file to run; .ts runs under tsx), `secrets` (keys of the credentials the " +
29
+ "code needs, injected as env vars), and set `egress: true` to allow network " +
30
+ "access or dependency installs. It ALWAYS pauses for a human to review the " +
31
+ "exact code before running, so propose the workload directly rather than " +
32
+ "asking the user for permission first. Read its stdout for the result.";
33
+
34
+ /** Build the container backend the runner uses to execute workloads. */
35
+ export function createSandboxBackend(config: SandboxConfig): SandboxBackend {
36
+ return new ContainerSandboxBackend({
37
+ image: config.image,
38
+ ...(config.runtime === undefined ? {} : { runtime: config.runtime }),
39
+ ...(config.cli === undefined ? {} : { cli: config.cli }),
40
+ });
41
+ }
42
+
43
+ /** Resolve the requested secret keys to values, failing loud on any missing. */
44
+ function resolveWorkloadSecrets(
45
+ keys: readonly string[],
46
+ source: SecretSource,
47
+ ): Record<string, string> {
48
+ const resolved: Record<string, string> = {};
49
+ for (const key of keys) {
50
+ const value = source[key] ?? process.env[key];
51
+ if (value === undefined || value.length === 0) {
52
+ throw new Error(
53
+ `Missing secret "${key}" requested by the workload. Provide it in the ` +
54
+ `runner config's "secrets" or the environment.`,
55
+ );
56
+ }
57
+ resolved[key] = value;
58
+ }
59
+ return resolved;
60
+ }
61
+
62
+ /** A workload installs dependencies when it ships a package.json. */
63
+ function needsInstall(files: readonly { path: string }[]): boolean {
64
+ return files.some(
65
+ (f) => f.path === "package.json" || f.path.endsWith("/package.json"),
66
+ );
67
+ }
68
+
69
+ export interface WorkloadCapabilityOptions {
70
+ /** The runner's resource maxima; per-workload requests are clamped to these. */
71
+ readonly maxima: ResolvedSandboxLimits;
72
+ }
73
+
74
+ /**
75
+ * The `workload.execute` capability: validate the spec, resolve exactly the
76
+ * requested credentials, clamp the limits, and run it in the sandbox backend.
77
+ * Its hazard is `unknown` (not `write`): its effect can't be established from a
78
+ * trusted declaration, only from the code itself, so the control plane always
79
+ * routes it through human *code* review, the trust transition that turns
80
+ * agent-authored code into trusted-for-one-run code. The full spec (including
81
+ * the source) is the approved artifact; its digest is returned for audit.
82
+ */
83
+ export function createWorkloadCapability(
84
+ backend: SandboxBackend,
85
+ secrets: SecretSource,
86
+ options: WorkloadCapabilityOptions,
87
+ ): RegisteredCapability {
88
+ return {
89
+ name: WORKLOAD_CAPABILITY_NAME,
90
+ description: WORKLOAD_DESCRIPTION,
91
+ hazard: "unknown",
92
+ kind: "workload",
93
+ params: workloadSpecSchema,
94
+ run: async (rawArgs: unknown, ctx: ConnectorContext) => {
95
+ const spec = workloadSpecSchema.parse(rawArgs);
96
+ const egress = spec.egress ?? false;
97
+ const credentials = resolveWorkloadSecrets(spec.secrets ?? [], secrets);
98
+ const limits = clampWorkloadLimits(options.maxima, spec.limits);
99
+ const install = needsInstall(spec.files);
100
+ const outcome = await backend.run({
101
+ files: spec.files,
102
+ entrypoint: spec.entrypoint,
103
+ credentials,
104
+ egress,
105
+ install,
106
+ limits,
107
+ log: (m) => ctx.log(m),
108
+ });
109
+ const result: WorkloadResult = {
110
+ digest: workloadDigest(spec),
111
+ ok: outcome.exitCode === 0 && !outcome.timedOut,
112
+ exitCode: outcome.exitCode,
113
+ stdout: outcome.stdout,
114
+ stderr: outcome.stderr,
115
+ timedOut: outcome.timedOut,
116
+ durationMs: outcome.durationMs,
117
+ };
118
+ return result;
119
+ },
120
+ };
121
+ }
@@ -0,0 +1,259 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+
7
+ import type {
8
+ ResolvedSandboxLimits,
9
+ SandboxBackend,
10
+ SandboxRunRequest,
11
+ SandboxRunResult,
12
+ } from "./types.js";
13
+
14
+ const DEFAULT_RUNTIME = "runsc";
15
+ const DEFAULT_CLI = "docker";
16
+ const WORKDIR = "/workload";
17
+ /** Cap captured stream size so a chatty workload can't exhaust runner memory. */
18
+ const MAX_STREAM_BYTES = 1_000_000;
19
+
20
+ export interface ContainerSandboxOptions {
21
+ /** Base image (must carry a Node runtime, `tsx`, and `npm`). */
22
+ readonly image: string;
23
+ /** OCI runtime; defaults to `runsc` (gVisor). */
24
+ readonly runtime?: string;
25
+ /** Container CLI executable; defaults to `docker`. */
26
+ readonly cli?: string;
27
+ }
28
+
29
+ /**
30
+ * Reject a path that could escape the sandbox workdir. Workload paths are
31
+ * agent-authored, so this is a hard boundary, not a nicety.
32
+ */
33
+ export function assertSafeRelativePath(path: string): void {
34
+ const segments = path.split("/");
35
+ if (
36
+ path.startsWith("/") ||
37
+ path.includes("\\") ||
38
+ path.includes("\0") ||
39
+ segments.some((s) => s === ".." || s === "")
40
+ ) {
41
+ throw new Error(
42
+ `Unsafe workload path "${path}": paths must be relative with no "..", ` +
43
+ `absolute, empty, or backslash segments.`,
44
+ );
45
+ }
46
+ }
47
+
48
+ /** Single-quote a token for safe embedding in a `sh -c` script. */
49
+ function shellSingleQuote(value: string): string {
50
+ return `'${value.replace(/'/g, `'\\''`)}'`;
51
+ }
52
+
53
+ /**
54
+ * The in-container command. `.ts`/`.tsx` entrypoints run under `tsx`, others
55
+ * under `node`. When `install` is set, dependencies are installed first (this
56
+ * requires network egress); its noisy output is redirected to stderr so it
57
+ * never contaminates the workload's stdout, which is the channel the result is
58
+ * read from. `exec` replaces the shell so signals reach the process directly
59
+ * for prompt timeout kills.
60
+ */
61
+ export function buildRunScript(entrypoint: string, install: boolean): string {
62
+ const runner = /\.tsx?$/.test(entrypoint) ? "tsx" : "node";
63
+ const run = `exec ${runner} ${shellSingleQuote(entrypoint)}`;
64
+ if (!install) return run;
65
+ return `npm install --omit=dev --no-audit --no-fund >&2 && ${run}`;
66
+ }
67
+
68
+ export interface ContainerArgsParams {
69
+ readonly name: string;
70
+ readonly image: string;
71
+ readonly runtime: string;
72
+ readonly hostDir: string;
73
+ readonly egress: boolean;
74
+ readonly limits: ResolvedSandboxLimits;
75
+ readonly secretKeys: readonly string[];
76
+ readonly script: string;
77
+ }
78
+
79
+ /**
80
+ * Build the container CLI arguments (everything after the `docker`/`podman`
81
+ * executable). Pure and deterministic so the isolation posture is unit-tested:
82
+ * no host network unless egress is granted, read-only root fs, all capabilities
83
+ * dropped, no privilege escalation, hard resource caps, and secrets passed by
84
+ * *key* (`-e KEY`) so their values never appear on the process argv.
85
+ */
86
+ export function buildContainerArgs(params: ContainerArgsParams): string[] {
87
+ const { limits } = params;
88
+ const args = [
89
+ "run",
90
+ "--rm",
91
+ "--name",
92
+ params.name,
93
+ "--runtime",
94
+ params.runtime,
95
+ "--network",
96
+ params.egress ? "bridge" : "none",
97
+ "--cpus",
98
+ String(limits.cpus),
99
+ "--memory",
100
+ `${limits.memoryMb}m`,
101
+ // Equal memory + swap disables swap, so the memory cap is hard.
102
+ "--memory-swap",
103
+ `${limits.memoryMb}m`,
104
+ "--pids-limit",
105
+ String(limits.pids),
106
+ "--read-only",
107
+ "--tmpfs",
108
+ `/tmp:rw,size=${limits.memoryMb}m`,
109
+ "--cap-drop",
110
+ "ALL",
111
+ "--security-opt",
112
+ "no-new-privileges",
113
+ "-v",
114
+ `${params.hostDir}:${WORKDIR}`,
115
+ "-w",
116
+ WORKDIR,
117
+ ];
118
+ for (const key of params.secretKeys) {
119
+ args.push("-e", key);
120
+ }
121
+ args.push(params.image, "sh", "-c", params.script);
122
+ return args;
123
+ }
124
+
125
+ /** Append to a capped buffer, dropping bytes past the cap. */
126
+ function appendCapped(current: string, chunk: string): string {
127
+ if (current.length >= MAX_STREAM_BYTES) return current;
128
+ return (current + chunk).slice(0, MAX_STREAM_BYTES);
129
+ }
130
+
131
+ /**
132
+ * Runs each approved workload in a fresh, OS-isolated container on the runner's
133
+ * host and destroys it afterward. Isolation (gVisor by default), credential
134
+ * injection, and resource limits are enforced at the container boundary; the
135
+ * approved code inside may use any library, SDK, and (when egress is granted)
136
+ * the open internet, exactly as a human reviewer signed off on.
137
+ */
138
+ export class ContainerSandboxBackend implements SandboxBackend {
139
+ private readonly image: string;
140
+ private readonly runtime: string;
141
+ private readonly cli: string;
142
+
143
+ constructor(options: ContainerSandboxOptions) {
144
+ this.image = options.image;
145
+ this.runtime = options.runtime ?? DEFAULT_RUNTIME;
146
+ this.cli = options.cli ?? DEFAULT_CLI;
147
+ }
148
+
149
+ public async run(request: SandboxRunRequest): Promise<SandboxRunResult> {
150
+ assertSafeRelativePath(request.entrypoint);
151
+ if (!request.files.some((f) => f.path === request.entrypoint)) {
152
+ throw new Error(
153
+ `Entrypoint "${request.entrypoint}" is not among the workload files.`,
154
+ );
155
+ }
156
+ if (request.install && !request.egress) {
157
+ throw new Error(
158
+ "Installing dependencies requires network egress; set egress: true.",
159
+ );
160
+ }
161
+
162
+ const dir = await mkdtemp(join(tmpdir(), "polycore-wl-"));
163
+ try {
164
+ await this.writeFiles(dir, request.files);
165
+ return await this.spawnContainer(dir, request);
166
+ } finally {
167
+ await rm(dir, { recursive: true, force: true });
168
+ }
169
+ }
170
+
171
+ private async writeFiles(
172
+ dir: string,
173
+ files: SandboxRunRequest["files"],
174
+ ): Promise<void> {
175
+ for (const file of files) {
176
+ assertSafeRelativePath(file.path);
177
+ const full = join(dir, file.path);
178
+ await mkdir(dirname(full), { recursive: true });
179
+ await writeFile(full, file.contents, "utf8");
180
+ }
181
+ }
182
+
183
+ private spawnContainer(
184
+ hostDir: string,
185
+ request: SandboxRunRequest,
186
+ ): Promise<SandboxRunResult> {
187
+ const name = `polycore-wl-${randomUUID()}`;
188
+ const secretKeys = Object.keys(request.credentials);
189
+ const script = buildRunScript(request.entrypoint, request.install);
190
+ const args = buildContainerArgs({
191
+ name,
192
+ image: this.image,
193
+ runtime: this.runtime,
194
+ hostDir,
195
+ egress: request.egress,
196
+ limits: request.limits,
197
+ secretKeys,
198
+ script,
199
+ });
200
+
201
+ request.log(
202
+ `Running workload in ${this.runtime} sandbox (egress=${request.egress}, ` +
203
+ `timeout=${request.limits.timeoutSeconds}s)…`,
204
+ );
205
+
206
+ return new Promise<SandboxRunResult>((resolve, reject) => {
207
+ const startedAt = Date.now();
208
+ // Secret values ride the child's env so only `-e KEY` forwards them into
209
+ // the container; they never touch argv. Other host env stays out of the
210
+ // sandbox (it is not forwarded) but is available to the CLI itself.
211
+ const child = spawn(this.cli, args, {
212
+ env: { ...process.env, ...request.credentials },
213
+ });
214
+
215
+ let stdout = "";
216
+ let stderr = "";
217
+ let timedOut = false;
218
+ let settled = false;
219
+
220
+ const timer = setTimeout(() => {
221
+ timedOut = true;
222
+ // Killing the CLI does not stop the container; kill it by name.
223
+ spawn(this.cli, ["kill", name]).on("error", () => undefined);
224
+ child.kill("SIGKILL");
225
+ }, request.limits.timeoutSeconds * 1000);
226
+
227
+ child.stdout.on("data", (d: Buffer) => {
228
+ stdout = appendCapped(stdout, d.toString("utf8"));
229
+ });
230
+ child.stderr.on("data", (d: Buffer) => {
231
+ stderr = appendCapped(stderr, d.toString("utf8"));
232
+ });
233
+
234
+ child.on("error", (error) => {
235
+ if (settled) return;
236
+ settled = true;
237
+ clearTimeout(timer);
238
+ reject(
239
+ new Error(
240
+ `Failed to launch sandbox via "${this.cli}": ${error.message}`,
241
+ ),
242
+ );
243
+ });
244
+
245
+ child.on("close", (code) => {
246
+ if (settled) return;
247
+ settled = true;
248
+ clearTimeout(timer);
249
+ resolve({
250
+ exitCode: code ?? -1,
251
+ stdout,
252
+ stderr,
253
+ durationMs: Date.now() - startedAt,
254
+ timedOut,
255
+ });
256
+ });
257
+ });
258
+ }
259
+ }
@@ -0,0 +1,24 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import type { WorkloadSpec } from "./types.js";
4
+
5
+ /**
6
+ * A stable sha256 identity for a workload: the exact thing a human approves and
7
+ * the runner executes. Files are sorted by path and secret keys are sorted, so
8
+ * the digest depends on *what* the workload is and *what authority it asks
9
+ * for*, not on incidental ordering. Any change to code, entrypoint, requested
10
+ * secrets, egress, or limits changes the digest, which is what lets an approval
11
+ * bind to one immutable artifact: re-review is required the moment it differs.
12
+ */
13
+ export function workloadDigest(spec: WorkloadSpec): string {
14
+ const canonical = {
15
+ files: [...spec.files]
16
+ .map((f) => ({ path: f.path, contents: f.contents }))
17
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
18
+ entrypoint: spec.entrypoint,
19
+ secrets: [...(spec.secrets ?? [])].sort(),
20
+ egress: spec.egress ?? false,
21
+ limits: spec.limits ?? {},
22
+ };
23
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
24
+ }
@@ -0,0 +1,43 @@
1
+ import type { SandboxLimits } from "../config.js";
2
+ import type { ResolvedSandboxLimits, WorkloadLimits } from "./types.js";
3
+
4
+ /** Conservative defaults when the runner config sets no explicit maxima. */
5
+ export const DEFAULT_SANDBOX_LIMITS: ResolvedSandboxLimits = {
6
+ cpus: 1,
7
+ memoryMb: 512,
8
+ timeoutSeconds: 120,
9
+ pids: 256,
10
+ };
11
+
12
+ /** Resolve the runner's configured maxima, filling any unset field with a default. */
13
+ export function resolveSandboxMaxima(
14
+ config: SandboxLimits | undefined,
15
+ ): ResolvedSandboxLimits {
16
+ return {
17
+ cpus: config?.cpus ?? DEFAULT_SANDBOX_LIMITS.cpus,
18
+ memoryMb: config?.memoryMb ?? DEFAULT_SANDBOX_LIMITS.memoryMb,
19
+ timeoutSeconds:
20
+ config?.timeoutSeconds ?? DEFAULT_SANDBOX_LIMITS.timeoutSeconds,
21
+ pids: config?.pids ?? DEFAULT_SANDBOX_LIMITS.pids,
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Clamp a workload's requested limits to the runner's maxima: a workload may
27
+ * ask for less than the configured ceiling, never more. Unset requests default
28
+ * to the maximum.
29
+ */
30
+ export function clampWorkloadLimits(
31
+ maxima: ResolvedSandboxLimits,
32
+ requested: WorkloadLimits | undefined,
33
+ ): ResolvedSandboxLimits {
34
+ return {
35
+ cpus: Math.min(requested?.cpus ?? maxima.cpus, maxima.cpus),
36
+ memoryMb: Math.min(requested?.memoryMb ?? maxima.memoryMb, maxima.memoryMb),
37
+ timeoutSeconds: Math.min(
38
+ requested?.timeoutSeconds ?? maxima.timeoutSeconds,
39
+ maxima.timeoutSeconds,
40
+ ),
41
+ pids: maxima.pids,
42
+ };
43
+ }
@@ -0,0 +1,117 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Delegated workloads are the universal escape hatch: when no connector or
5
+ * action covers a task, the agent authors a small program, a human reviews the
6
+ * exact code + dependencies + requested credentials, and on approval the runner
7
+ * executes it in a disposable, OS-isolated sandbox on its own host. This is
8
+ * arbitrary code in the same sense every program is arbitrary: it computes
9
+ * freely, and the credentials it may touch are exactly the ones the approval
10
+ * delegated to it, nothing more.
11
+ */
12
+
13
+ /** One source file of a workload bundle, at a workspace-relative path. */
14
+ export const workloadFileSchema = z.object({
15
+ /** POSIX-relative path inside the sandbox workdir, e.g. `main.ts`. */
16
+ path: z.string().min(1),
17
+ /** UTF-8 file contents. */
18
+ contents: z.string(),
19
+ });
20
+
21
+ export type WorkloadFile = z.infer<typeof workloadFileSchema>;
22
+
23
+ /** Per-workload resource request; clamped to the runner's configured maxima. */
24
+ export const workloadLimitsSchema = z.object({
25
+ cpus: z.number().positive().optional(),
26
+ memoryMb: z.number().int().positive().optional(),
27
+ timeoutSeconds: z.number().int().positive().optional(),
28
+ });
29
+
30
+ export type WorkloadLimits = z.infer<typeof workloadLimitsSchema>;
31
+
32
+ /**
33
+ * The `workload.execute` arguments the agent supplies. This whole object is
34
+ * what a human approves: the exact files, the entrypoint, which credentials it
35
+ * may use, whether it may reach the network, and its resource envelope.
36
+ */
37
+ export const workloadSpecSchema = z.object({
38
+ /**
39
+ * A short, human-readable label for what the program does (e.g. "Credit top
40
+ * 10 active users $5"). Shown to the human reviewer on the approval card so
41
+ * they see the intent before reading the code. Purely descriptive: it is not
42
+ * part of the artifact digest, since it does not affect what executes.
43
+ */
44
+ title: z.string().optional(),
45
+ /** Source files of the program (at least the entrypoint). */
46
+ files: z.array(workloadFileSchema).min(1),
47
+ /** Which file to run; must be one of `files`, a safe relative path. */
48
+ entrypoint: z.string().min(1),
49
+ /**
50
+ * Secret keys (from the runner's configured secrets) to inject into the
51
+ * sandbox as environment variables. These are the credentials the approval
52
+ * delegates to the code; omit for a pure-computation workload.
53
+ */
54
+ secrets: z.array(z.string().min(1)).optional(),
55
+ /**
56
+ * Whether the sandbox may reach the network. Required to install npm
57
+ * dependencies or call any external system. Defaults to false (no egress).
58
+ */
59
+ egress: z.boolean().optional(),
60
+ /** Optional per-workload resource caps (clamped to the runner's maxima). */
61
+ limits: workloadLimitsSchema.optional(),
62
+ });
63
+
64
+ export type WorkloadSpec = z.infer<typeof workloadSpecSchema>;
65
+
66
+ /** Resolved, absolute resource caps a single sandbox run executes under. */
67
+ export interface ResolvedSandboxLimits {
68
+ readonly cpus: number;
69
+ readonly memoryMb: number;
70
+ readonly timeoutSeconds: number;
71
+ readonly pids: number;
72
+ }
73
+
74
+ /** Everything a sandbox backend needs to execute one approved workload. */
75
+ export interface SandboxRunRequest {
76
+ readonly files: readonly WorkloadFile[];
77
+ readonly entrypoint: string;
78
+ /** Resolved secret values to expose as env vars inside the sandbox. */
79
+ readonly credentials: Readonly<Record<string, string>>;
80
+ readonly egress: boolean;
81
+ /** Whether to install dependencies before running (needs `egress`). */
82
+ readonly install: boolean;
83
+ readonly limits: ResolvedSandboxLimits;
84
+ readonly log: (message: string) => void;
85
+ }
86
+
87
+ /** The raw outcome of one sandbox run. */
88
+ export interface SandboxRunResult {
89
+ readonly exitCode: number;
90
+ readonly stdout: string;
91
+ readonly stderr: string;
92
+ readonly durationMs: number;
93
+ /** True when the run hit its wall-clock timeout and was killed. */
94
+ readonly timedOut: boolean;
95
+ }
96
+
97
+ /**
98
+ * Executes an approved workload in isolation. The default implementation runs
99
+ * an OS-isolated container; the interface exists so a microVM (or a fake, in
100
+ * tests) can be swapped in without touching the capability or its callers.
101
+ */
102
+ export interface SandboxBackend {
103
+ run(request: SandboxRunRequest): Promise<SandboxRunResult>;
104
+ }
105
+
106
+ /** The `workload.execute` result returned to the control plane and audit. */
107
+ export interface WorkloadResult {
108
+ /** Deterministic identity of the approved artifact (what was run). */
109
+ readonly digest: string;
110
+ /** Convenience flag: exited 0 and did not time out. */
111
+ readonly ok: boolean;
112
+ readonly exitCode: number;
113
+ readonly stdout: string;
114
+ readonly stderr: string;
115
+ readonly timedOut: boolean;
116
+ readonly durationMs: number;
117
+ }