diff --git a/packages/harness/README.md b/packages/harness/README.md index 4007c99..d039e15 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -118,22 +118,22 @@ and the evidence convention's verdicts are appended the same way. ## Sandboxed execution -`MxcSandbox` adapts [Microsoft MXC](https://www.npmjs.com/package/@microsoft/mxc-sdk) -to a Deep-Agents-compatible sandbox backend. Every execute/upload/download -operation is dispatched through the bus as the configured `actor`, gated when -a `gate` is supplied. `createHarnessPolicy` builds the sandbox policy: network, -local-network, UI, clipboard, and input access are denied by default, and -`protectedPaths` (for example a file-backed bus log) must lie outside every -writable sandbox workspace. Add `allowedHosts` only when a sandboxed tool -genuinely needs outbound access. +`HarnessSandbox` is a Deep-Agents-compatible sandbox backend. Every +execute/upload/download operation is dispatched through the bus as the +configured `actor`, gated when a `gate` is supplied. The sandbox enforces its +own policy: writes are confined to the workspace; network, local-network, UI, +clipboard, and input access are denied by default; and `protectedPaths` (for +example a file-backed bus log) must lie outside every writable sandbox +workspace. Add `allowedHosts` only when a sandboxed tool genuinely needs +outbound access. ```ts -import { createHarnessPolicy, MxcSandbox, WriteAheadAgentBus } from "ts-autocode-harness"; +import { HarnessSandbox, WriteAheadAgentBus } from "ts-autocode-harness"; -const sandbox = new MxcSandbox({ +const sandbox = new HarnessSandbox({ id: "student", workspace, - policy: createHarnessPolicy({ workspace, timeoutMs: 60_000 }), + timeoutMs: 60_000, bus, actor: "student", gate: (action, context) => myJudge({ subject: "action", action, context }), @@ -141,7 +141,7 @@ const sandbox = new MxcSandbox({ ``` > [!WARNING] -> MXC is an early preview. Its upstream documentation warns that current profiles should not yet be treated as production security boundaries. Evaluate the selected MXC backend for your deployment. +> Sandboxing is backed by [Microsoft MXC](https://www.npmjs.com/package/@microsoft/mxc-sdk), an early preview. Its upstream documentation warns that current profiles should not yet be treated as production security boundaries. Evaluate the selected MXC backend for your deployment. ## License diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index a722ff2..1f4256c 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -22,8 +22,5 @@ export type { TrainingHarness, } from "./harness.js"; -export { createHarnessPolicy, sandboxPolicyVersion } from "./policy.js"; -export type { HarnessPolicySettings } from "./policy.js"; - -export { MxcSandbox } from "./sandbox.js"; -export type { MxcSandboxSettings } from "./sandbox.js"; +export { HarnessSandbox } from "./sandbox.js"; +export type { HarnessSandboxSettings } from "./sandbox.js"; diff --git a/packages/harness/src/policy.ts b/packages/harness/src/policy.ts index 0043fac..e024a81 100644 --- a/packages/harness/src/policy.ts +++ b/packages/harness/src/policy.ts @@ -8,20 +8,21 @@ const policySettings = z.object({ readonlyPaths: z.array(absolutePath).optional(), allowedHosts: z.array(z.string().trim()).optional(), timeoutMs: z.number().int().positive("timeoutMs must be a positive integer").optional(), - version: z.string().optional(), }); -export type HarnessPolicySettings = z.input; +export type SandboxPolicySettings = z.input; -/** The mxc-sdk policy schema version emitted when `version` is unset. */ -export const sandboxPolicyVersion = "0.7.0-alpha"; +const policyVersion = "0.7.0-alpha"; -export function createHarnessPolicy(input: HarnessPolicySettings): SandboxPolicy { +/** Builds a sandbox policy under the harness's security invariants: writes + * confined to the workspace, no local network, outbound only with an explicit + * allowlist, and no UI, clipboard, or input access. */ +export function createSandboxPolicy(input: SandboxPolicySettings): SandboxPolicy { const settings = policySettings.parse(input); const allowedHosts = settings.allowedHosts?.filter(Boolean); return { - version: settings.version ?? sandboxPolicyVersion, + version: policyVersion, filesystem: { readwritePaths: [settings.workspace], ...(settings.readonlyPaths?.length ? { readonlyPaths: [...settings.readonlyPaths] } : {}), @@ -33,22 +34,3 @@ export function createHarnessPolicy(input: HarnessPolicySettings): SandboxPolicy ...(settings.timeoutMs === undefined ? {} : { timeoutMs: settings.timeoutMs }), }; } - -/** Enforces the harness's security invariants on a sandbox policy: writes - * confined to the workspace, no local network or proxy escape, outbound only - * with an explicit allowlist, and no UI, clipboard, or input access. */ -export function assertHarnessPolicy(policy: SandboxPolicy, workspace: string): void { - const root = absolutePath.parse(workspace); - const writable = policy.filesystem?.readwritePaths?.map((path) => absolutePath.parse(path)) ?? []; - if (writable.length !== 1 || writable[0] !== root) { - throw new TypeError("policy must grant write access only to the sandbox workspace"); - } - if (policy.network?.allowLocalNetwork) throw new TypeError("policy cannot grant local network access"); - if (policy.network?.proxy) throw new TypeError("policy cannot bypass the host allowlist with a proxy"); - if (policy.network?.allowOutbound && !policy.network.allowedHosts?.length) { - throw new TypeError("outbound access requires allowedHosts"); - } - if (policy.ui?.allowWindows || policy.ui?.allowInputInjection || (policy.ui?.clipboard ?? "none") !== "none") { - throw new TypeError("policy cannot grant UI, clipboard, or input access"); - } -} diff --git a/packages/harness/src/sandbox.ts b/packages/harness/src/sandbox.ts index adec44e..2731693 100644 --- a/packages/harness/src/sandbox.ts +++ b/packages/harness/src/sandbox.ts @@ -1,11 +1,7 @@ import { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, relative, resolve } from "node:path"; -import { - spawnSandboxAsync, - type SandboxPolicy, - type SandboxSpawnOptions, -} from "@microsoft/mxc-sdk"; +import { spawnSandboxAsync, type SandboxPolicy } from "@microsoft/mxc-sdk"; import { BaseSandbox, type ExecuteResponse, @@ -15,13 +11,12 @@ import { import { WriteAheadAgentBus } from "./bus.js"; import { dispatchAction, type ActionGate } from "./dispatch.js"; -import { assertHarnessPolicy } from "./policy.js"; +import { createSandboxPolicy } from "./policy.js"; import { absolutePath } from "./schema.js"; -export interface MxcSandboxSettings { +export interface HarnessSandboxSettings { readonly id: string; readonly workspace: string; - readonly policy: SandboxPolicy; readonly bus: WriteAheadAgentBus; /** The bus actor this sandbox's operations are recorded as. */ readonly actor: string; @@ -31,46 +26,47 @@ export interface MxcSandboxSettings { /** Paths that must remain outside the writable workspace — for example a * file-backed bus log the sandboxed agent must not be able to tamper with. */ readonly protectedPaths?: readonly string[]; - readonly spawn?: Omit; + /** Absolute paths outside the workspace the sandboxed process may read. */ + readonly readonlyPaths?: readonly string[]; + /** Hosts the sandboxed process may reach. Outbound network is denied + * without them; local network access is always denied. */ + readonly allowedHosts?: readonly string[]; + readonly timeoutMs?: number; } -export class MxcSandbox extends BaseSandbox { +export class HarnessSandbox extends BaseSandbox { readonly id: string; readonly #workspace: string; readonly #policy: SandboxPolicy; readonly #bus: WriteAheadAgentBus; readonly #actor: string; readonly #gate: ActionGate | undefined; - readonly #spawn: Omit; - constructor(settings: MxcSandboxSettings) { + constructor(settings: HarnessSandboxSettings) { super(); this.id = settings.id; this.#workspace = resolve(settings.workspace); - assertHarnessPolicy(settings.policy, this.#workspace); + this.#policy = createSandboxPolicy({ + workspace: this.#workspace, + readonlyPaths: settings.readonlyPaths && [...settings.readonlyPaths], + allowedHosts: settings.allowedHosts && [...settings.allowedHosts], + timeoutMs: settings.timeoutMs, + }); for (const path of settings.protectedPaths ?? []) { const fromWorkspace = relative(this.#workspace, absolutePath.parse(path)); if (!fromWorkspace.startsWith("..") && !isAbsolute(fromWorkspace)) { throw new TypeError("protected paths must be outside the writable sandbox workspace"); } } - this.#policy = settings.policy; this.#bus = settings.bus; this.#actor = settings.actor; this.#gate = settings.gate; - this.#spawn = settings.spawn ?? {}; } async execute(command: string): Promise { return this.#perform("sandbox.execute", { sandbox: this.id, command }, async () => { await mkdir(this.#workspace, { recursive: true }); - const result = await spawnSandboxAsync( - command, - this.#policy, - this.#spawn, - this.#workspace, - this.id, - ); + const result = await spawnSandboxAsync(command, this.#policy, {}, this.#workspace, this.id); return { output: [result.stdout, result.stderr].filter(Boolean).join("\n"), exitCode: result.exitCode, diff --git a/packages/harness/test/harness.test.ts b/packages/harness/test/harness.test.ts index 1707a67..631d8cc 100644 --- a/packages/harness/test/harness.test.ts +++ b/packages/harness/test/harness.test.ts @@ -9,10 +9,9 @@ import fsDriver from "unstorage/drivers/fs"; import { AgentActionDeniedError, - createHarnessPolicy, defineTrainingHarness, dispatchAction, - MxcSandbox, + HarnessSandbox, WriteAheadAgentBus, type AgentBusEntry, } from "../src/index.js"; @@ -218,10 +217,9 @@ describe("training harness", () => { it("gates sandbox file actions and keeps the bus outside writable workspaces", async () => { const workspace = await mkdtemp(join(tmpdir(), "ts-autocode-sandbox-")); const { bus } = await newBus(); - const sandbox = new MxcSandbox({ + const sandbox = new HarnessSandbox({ id: "files", workspace, - policy: createHarnessPolicy({ workspace }), bus, actor: "student", gate: () => "pass", @@ -231,10 +229,9 @@ describe("training harness", () => { expect((await bus.read()).map(({ kind }) => kind)) .toEqual(["sandbox.upload", "agent.decision", "sandbox.upload.completed"]); - expect(() => new MxcSandbox({ + expect(() => new HarnessSandbox({ id: "unsafe", workspace, - policy: createHarnessPolicy({ workspace }), bus, actor: "student", protectedPaths: [join(workspace, "actions.jsonl")], @@ -248,7 +245,7 @@ describe("training harness", () => { await symlink(outside, join(workspace, "leak")); await symlink(join(outside, "secret.txt"), join(workspace, "alias.txt")); const { bus } = await newBus(); - const sandbox = new MxcSandbox({ id: "links", workspace, policy: createHarnessPolicy({ workspace }), bus, actor: "student" }); + const sandbox = new HarnessSandbox({ id: "links", workspace, bus, actor: "student" }); expect(await sandbox.downloadFiles(["leak/secret.txt", "alias.txt"])).toEqual([ { path: "leak/secret.txt", content: null, error: "file_not_found" },