Skip to content
28 changes: 24 additions & 4 deletions packages/harness/src/sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve } from "node:path";

import {
Expand Down Expand Up @@ -72,9 +72,11 @@ export class MxcSandbox extends BaseSandbox {
try {
const target = this.#path(path);
await mkdir(dirname(target), { recursive: true });
await this.#assertContained(dirname(target));
if (await isSymlink(target)) throw new Error("path escapes sandbox workspace");
await writeFile(target, content);
return { path, error: null };
} catch (error) {
} catch {
return { path, error: "permission_denied" as const };
}
})));
Expand All @@ -84,8 +86,10 @@ export class MxcSandbox extends BaseSandbox {
return this.#perform("sandbox.download", { sandbox: this.id, paths }, () =>
Promise.all(paths.map(async (path) => {
try {
return { path, content: await readFile(this.#path(path)), error: null };
} catch (error) {
const target = this.#path(path);
await this.#assertContained(target);
return { path, content: await readFile(target), error: null };
} catch {
return { path, content: null, error: "file_not_found" as const };
}
})));
Expand All @@ -103,4 +107,20 @@ export class MxcSandbox extends BaseSandbox {
if (fromWorkspace.startsWith("..") || isAbsolute(fromWorkspace)) throw new Error("path escapes sandbox workspace");
return target;
}

/** Host file access follows symlinks, so containment must hold after resolving them too. */
async #assertContained(path: string): Promise<void> {
const fromWorkspace = relative(await realpath(this.#workspace), await realpath(path));
if (fromWorkspace.startsWith("..") || isAbsolute(fromWorkspace)) {
throw new Error("path escapes sandbox workspace");
}
}
}

async function isSymlink(path: string): Promise<boolean> {
try {
return (await lstat(path)).isSymbolicLink();
} catch {
return false;
}
}
26 changes: 25 additions & 1 deletion packages/harness/test/harness.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { appendFile, mkdtemp } from "node:fs/promises";
import { appendFile, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

Expand Down Expand Up @@ -139,6 +139,30 @@ describe("training harness", () => {
.toThrow("outside the writable sandbox");
});

it("refuses symlinked paths that resolve outside the workspace", async () => {
const workspace = await mkdtemp(join(tmpdir(), "ts-autocode-sandbox-links-"));
const outside = await mkdtemp(join(tmpdir(), "ts-autocode-outside-"));
await writeFile(join(outside, "secret.txt"), "secret", "utf8");
await symlink(outside, join(workspace, "leak"));
await symlink(join(outside, "secret.txt"), join(workspace, "alias.txt"));
const { bus } = await approvedBus();
const sandbox = new MxcSandbox({ id: "links", workspace, policy: createHarnessPolicy({ workspace }), bus, role: "student" });

expect(await sandbox.downloadFiles(["leak/secret.txt", "alias.txt"])).toEqual([
{ path: "leak/secret.txt", content: null, error: "file_not_found" },
{ path: "alias.txt", content: null, error: "file_not_found" },
]);
expect(await sandbox.uploadFiles([
["leak/implant.txt", new TextEncoder().encode("x")],
["alias.txt", new TextEncoder().encode("x")],
])).toEqual([
{ path: "leak/implant.txt", error: "permission_denied" },
{ path: "alias.txt", error: "permission_denied" },
]);
await expect(readFile(join(outside, "implant.txt"))).rejects.toThrow();
expect(await readFile(join(outside, "secret.txt"), "utf8")).toBe("secret");
});

it("creates configurable Deep Agent callbacks for the same run model", async () => {
const root = await mkdtemp(join(tmpdir(), "ts-autocode-agents-"));
const role = (name: string) => {
Expand Down
14 changes: 8 additions & 6 deletions src/training.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,9 @@ class TrainingRuntime implements Training {
const decision = await evaluatePromotionGate({
candidate,
evaluations: verification.evaluations,
conformance: input.conformance ?? true,
// The engine already validated the candidate; `conformance: false` waives the
// requirement rather than reporting a failed check to the gate.
conformance: true,
...(input.minScore === undefined ? {} : { minScore: input.minScore }),
...(input.minPassRate === undefined ? {} : { minPassRate: input.minPassRate }),
...(input.policy === undefined ? {} : { policy: input.policy }),
Expand All @@ -270,7 +272,7 @@ class TrainingRuntime implements Training {
const decision = await evaluatePromotionGate({
candidate,
evaluations: verification.evaluations,
conformance: input.conformance ?? true,
conformance: true,
...(input.minScore === undefined ? {} : { minScore: input.minScore }),
...(input.minPassRate === undefined ? {} : { minPassRate: input.minPassRate }),
...(input.policy === undefined ? {} : { policy: input.policy }),
Expand Down Expand Up @@ -456,9 +458,9 @@ class TrainingRuntime implements Training {
if (this.#settings.capture.enabled === false) return;
try {
const spanContext = span?.spanContext();
const input = this.#settings.capture.mapInput?.(args, token) ?? args;
const input = this.#settings.capture.mapInput ? this.#settings.capture.mapInput(args, token) : args;
const output = error === undefined
? this.#settings.capture.mapOutput?.(result, token) ?? result
? (this.#settings.capture.mapOutput ? this.#settings.capture.mapOutput(result, token) : result)
: errorMessage(error);
const record: TrainingRecord = {
id: this.#settings.idFactory(),
Expand Down Expand Up @@ -490,7 +492,7 @@ class TrainingRuntime implements Training {
}

#serialize(value: unknown, field: "input" | "output"): string {
const redacted = this.#settings.capture.redact?.(value, field) ?? value;
const redacted = this.#settings.capture.redact ? this.#settings.capture.redact(value, field) : value;
return (this.#settings.capture.serialize ?? defaultSerialize)(redacted);
}

Expand Down Expand Up @@ -534,7 +536,7 @@ function isPromise<T>(value: T): value is T & Promise<Awaited<T>> {
function defaultSerialize(value: unknown): string {
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
Expand Down
44 changes: 44 additions & 0 deletions test/training.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ describe("trainable method capture", () => {
expect(await training.records("Router.route")).toEqual([]);
});

it("lets capture mappers redact values to undefined", async () => {
const training = configureTraining({
tracing: { enabled: false },
capture: { mapInput: () => undefined, mapOutput: () => undefined },
});
class Router {
route(input: string): string { return input; }
}
applyMethodDecorator(Router, "route", trainable("Router.redacted"));

expect(new Router().route("secret-input")).toBe("secret-input");
const [record] = await training.records("Router.redacted");
expect(record?.succeeded).toBe(true);
expect(JSON.stringify(record)).not.toContain("secret-input");
});

it("supports the decorator without external source metadata", async () => {
const training = configureTraining({});
class Router {
Expand Down Expand Up @@ -140,6 +156,34 @@ describe("training execution", () => {
expect(await readFile(artifact, "utf8")).toContain('"use training"');
});

it("waives the conformance requirement instead of rejecting every candidate", async () => {
const directory = await mkdtemp(join(tmpdir(), "ts-autocode-conformance-"));
const artifact = join(directory, "echo.ts");
await writeFile(artifact, `export function echo(input: string): string {
"use training";
return input;
}\n`);
const training = configureTraining({
engine: { id: "conformance-test", optimize: async () => ({ implementation: "return input.toUpperCase();" }) },
source: { files: [artifact] },
tracing: { enabled: false },
});

const run = await training.train({
trainable: "echo",
objective: "Uppercase the input",
conformance: false,
evaluation: {
tests: [{ id: "upper", input: "abc", assert: [{ type: "equals", value: "ABC" }] }],
task: (input) => input,
outputDir: join(directory, "agentv"),
},
});

expect(run.outcome).toBe("ready");
expect(run.final.decision.failures).toEqual([]);
});

it("requires enough successful runtime traces before evolving code", async () => {
const training = configureTraining({ tracing: { enabled: false } });
class Router {
Expand Down
Loading