feat(harness): secure workspace and custom tools

This commit is contained in:
Wenxin Duan
2026-09-07 15:34:38 +08:00
parent 7d1626dcde
commit d229ac0f0d
9 changed files with 487 additions and 0 deletions
@@ -0,0 +1,61 @@
import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import * as harness from "../src/index.js";
type Workspace = {
resolveReadable(path: string): Promise<string>;
writeReportDraft(input: { title?: string; body: string }): Promise<{ relativePath: "report.md"; byteSize: number }>;
remove(): Promise<void>;
};
type WorkspaceConstructor = {
create(root: string): Promise<Workspace>;
};
let root = "";
let outside = "";
let workspace: Workspace;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "agent-studio-workspace-"));
outside = await mkdtemp(join(tmpdir(), "agent-studio-outside-"));
await writeFile(join(root, "inside.txt"), "inside", "utf8");
await writeFile(join(outside, "secret.txt"), "secret", "utf8");
const Constructor = Reflect.get(harness, "SafeWorkspace") as WorkspaceConstructor | undefined;
expect(Constructor, "SafeWorkspace must be exported").toBeDefined();
workspace = await Constructor!.create(root);
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
await rm(outside, { recursive: true, force: true });
});
describe("SafeWorkspace", () => {
it("resolves an existing regular file inside the Session root", async () => {
await expect(workspace.resolveReadable("inside.txt")).resolves.toBe(await realpath(join(root, "inside.txt")));
});
it.each(["../outside/secret.txt", "/tmp/outside.txt"])("rejects path escape %s", async (path) => {
await expect(workspace.resolveReadable(path)).rejects.toMatchObject({ code: "PATH_OUTSIDE_WORKSPACE" });
});
it("rejects symlinks before following them", async () => {
await symlink(outside, join(root, "link"));
await expect(workspace.resolveReadable("link/secret.txt")).rejects.toMatchObject({ code: "SYMLINK_NOT_ALLOWED" });
});
it("rejects protected path segments", async () => {
await mkdir(join(root, ".git"));
await writeFile(join(root, ".git", "config"), "credential", "utf8");
await expect(workspace.resolveReadable(".git/config")).rejects.toMatchObject({ code: "PROTECTED_PATH" });
});
it("writes only the fixed report.md path", async () => {
const result = await workspace.writeReportDraft({ title: "广告账户报告", body: "账户总结" });
expect(result.relativePath).toBe("report.md");
await expect(readFile(join(root, "report.md"), "utf8")).resolves.toBe("# 广告账户报告\n\n账户总结\n");
});
});
+55
View File
@@ -0,0 +1,55 @@
import { resolve } from "node:path";
import { Check } from "typebox/value";
import { describe, expect, it } from "vitest";
import {
decideToolPolicy,
executeMockAdsMetrics,
mockAdsMetricsInputSchema,
} from "../src/index.js";
const fixturePath = resolve(import.meta.dirname, "../../../fixtures/ads-account.json");
describe("mock_ads_metrics", () => {
it("uses an integer 1-30 day TypeBox schema", () => {
expect(Check(mockAdsMetricsInputSchema, { accountId: "demo-account", days: 7 })).toBe(true);
expect(Check(mockAdsMetricsInputSchema, { accountId: "demo-account", days: 0 })).toBe(false);
expect(Check(mockAdsMetricsInputSchema, { accountId: "demo-account", days: 7.5 })).toBe(false);
expect(Check(mockAdsMetricsInputSchema, { accountId: "demo-account", days: 31 })).toBe(false);
});
it("returns hand-checked zero-safe metrics and campaign details", async () => {
const result = await executeMockAdsMetrics(fixturePath, { accountId: "demo-account", days: 7 });
expect(result).toMatchObject({
spend: 10000,
impressions: 400000,
clicks: 8100,
ctr: 2.03,
conversions: 295,
cpa: 33.9,
revenue: 21800,
roas: 2.18,
days: 7,
});
expect(result.campaigns).toContainEqual(expect.objectContaining({
campaignId: "cmp-low-ctr",
ctr: 1,
cpa: 80,
}));
});
it("rejects unknown accounts with a stable error code", async () => {
await expect(executeMockAdsMetrics(fixturePath, { accountId: "missing", days: 7 }))
.rejects.toMatchObject({ code: "ADS_ACCOUNT_NOT_FOUND" });
});
});
describe("tool policy", () => {
it("allows only the explicit safe set, asks for report save, and denies unknown tools", () => {
expect(["read", "grep", "find", "ls", "mock_ads_metrics"].map(decideToolPolicy)).toEqual([
"allow", "allow", "allow", "allow", "allow",
]);
expect(decideToolPolicy("save_report_draft")).toBe("ask");
expect(decideToolPolicy("bash")).toBe("deny");
expect(decideToolPolicy("unknown_tool")).toBe("deny");
});
});