Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions packages/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,30 +118,30 @@ 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 }),
});
```

> [!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

Expand Down
7 changes: 2 additions & 5 deletions packages/harness/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
32 changes: 7 additions & 25 deletions packages/harness/src/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof policySettings>;
export type SandboxPolicySettings = z.input<typeof policySettings>;

/** 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] } : {}),
Expand All @@ -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");
}
}
40 changes: 18 additions & 22 deletions packages/harness/src/sandbox.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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<SandboxSpawnOptions, "usePty">;
/** 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<SandboxSpawnOptions, "usePty">;

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<ExecuteResponse> {
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,
Expand Down
11 changes: 4 additions & 7 deletions packages/harness/test/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@ import fsDriver from "unstorage/drivers/fs";

import {
AgentActionDeniedError,
createHarnessPolicy,
defineTrainingHarness,
dispatchAction,
MxcSandbox,
HarnessSandbox,
WriteAheadAgentBus,
type AgentBusEntry,
} from "../src/index.js";
Expand Down Expand Up @@ -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",
Expand All @@ -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")],
Expand All @@ -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" },
Expand Down
Loading