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,10 @@
export class HarnessError extends Error {
public constructor(
public readonly code: string,
message: string,
public readonly details: Record<string, unknown> = {},
) {
super(message);
this.name = "HarnessError";
}
}
+5
View File
@@ -1,2 +1,7 @@
export * from "./events/event-store.js";
export * from "./events/in-memory-event-store.js";
export * from "./errors/harness-error.js";
export * from "./tools/mock-ads-metrics.js";
export * from "./tools/save-report-draft.js";
export * from "./tools/tool-policy.js";
export * from "./workspace/safe-workspace.js";
@@ -0,0 +1,142 @@
import { readFile } from "node:fs/promises";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { type Static, Type } from "typebox";
import { Check } from "typebox/value";
import { HarnessError } from "../errors/harness-error.js";
export const mockAdsMetricsInputSchema = Type.Object({
accountId: Type.String({ minLength: 1, maxLength: 100 }),
days: Type.Integer({ minimum: 1, maximum: 30 }),
}, { additionalProperties: false });
const campaignSchema = Type.Object({
campaignId: Type.String(),
name: Type.String(),
spend: Type.Number({ minimum: 0 }),
impressions: Type.Integer({ minimum: 0 }),
clicks: Type.Integer({ minimum: 0 }),
conversions: Type.Integer({ minimum: 0 }),
revenue: Type.Number({ minimum: 0 }),
}, { additionalProperties: false });
const fixtureSchema = Type.Object({
accounts: Type.Array(Type.Object({
accountId: Type.String(),
baseDays: Type.Integer({ minimum: 1 }),
campaigns: Type.Array(campaignSchema),
}, { additionalProperties: false })),
}, { additionalProperties: false });
export type MockAdsMetricsInput = Static<typeof mockAdsMetricsInputSchema>;
type CampaignFixture = Static<typeof campaignSchema>;
export type CampaignMetrics = CampaignFixture & {
ctr: number;
cpa: number;
roas: number;
};
export type AdsMetrics = {
accountId: string;
days: number;
spend: number;
impressions: number;
clicks: number;
ctr: number;
conversions: number;
cpa: number;
revenue: number;
roas: number;
campaigns: CampaignMetrics[];
};
export async function executeMockAdsMetrics(
fixturePath: string,
input: MockAdsMetricsInput,
): Promise<AdsMetrics> {
if (!Check(mockAdsMetricsInputSchema, input)) {
throw new HarnessError("INVALID_TOOL_ARGUMENTS", "Invalid mock_ads_metrics arguments");
}
const parsed: unknown = JSON.parse(await readFile(fixturePath, "utf8"));
if (!Check(fixtureSchema, parsed)) {
throw new HarnessError("INVALID_ADS_FIXTURE", "Ads fixture has an invalid structure");
}
const fixture = parsed;
const account = fixture.accounts.find((candidate) => candidate.accountId === input.accountId);
if (account === undefined) {
throw new HarnessError("ADS_ACCOUNT_NOT_FOUND", "Mock advertising account not found", {
accountId: input.accountId,
});
}
const scale = input.days / account.baseDays;
const campaigns = account.campaigns.map((campaign) => scaleCampaign(campaign, scale));
const spend = sum(campaigns, "spend");
const impressions = sum(campaigns, "impressions");
const clicks = sum(campaigns, "clicks");
const conversions = sum(campaigns, "conversions");
const revenue = sum(campaigns, "revenue");
return {
accountId: input.accountId,
days: input.days,
spend: round2(spend),
impressions: Math.round(impressions),
clicks: Math.round(clicks),
ctr: ratio(clicks * 100, impressions),
conversions: Math.round(conversions),
cpa: ratio(spend, conversions),
revenue: round2(revenue),
roas: ratio(revenue, spend),
campaigns,
};
}
export function createMockAdsMetricsTool(fixturePath: string) {
return defineTool({
name: "mock_ads_metrics",
label: "Mock Ads Metrics",
description: "Read deterministic training metrics for a mock advertising account. This does not access a real ad platform.",
promptSnippet: "Read mock advertising account metrics",
parameters: mockAdsMetricsInputSchema,
async execute(_toolCallId, params) {
const details = await executeMockAdsMetrics(fixturePath, params);
return {
content: [{ type: "text", text: JSON.stringify(details) }],
details,
};
},
});
}
function scaleCampaign(campaign: CampaignFixture, scale: number): CampaignMetrics {
const spend = round2(campaign.spend * scale);
const impressions = Math.round(campaign.impressions * scale);
const clicks = Math.round(campaign.clicks * scale);
const conversions = Math.round(campaign.conversions * scale);
const revenue = round2(campaign.revenue * scale);
return {
...campaign,
spend,
impressions,
clicks,
conversions,
revenue,
ctr: ratio(clicks * 100, impressions),
cpa: ratio(spend, conversions),
roas: ratio(revenue, spend),
};
}
function sum(items: CampaignMetrics[], key: "spend" | "impressions" | "clicks" | "conversions" | "revenue"): number {
return items.reduce((total, item) => total + item[key], 0);
}
function ratio(numerator: number, denominator: number): number {
return denominator === 0 ? 0 : round2(numerator / denominator);
}
function round2(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
@@ -0,0 +1,26 @@
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import type { SafeWorkspace } from "../workspace/safe-workspace.js";
export const saveReportDraftInputSchema = Type.Object({
title: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
body: Type.String({ minLength: 1, maxLength: 200_000 }),
}, { additionalProperties: false });
export function createSaveReportDraftTool(workspace: SafeWorkspace) {
return defineTool({
name: "save_report_draft",
label: "Save Report Draft",
description: "Save a report draft to the fixed report.md file in the current training Session. Requires approval.",
promptSnippet: "Save a report draft after analysis",
parameters: saveReportDraftInputSchema,
executionMode: "sequential",
async execute(_toolCallId, params) {
const details = await workspace.writeReportDraft(params);
return {
content: [{ type: "text", text: `Saved report draft to ${details.relativePath}` }],
details,
};
},
});
}
@@ -0,0 +1,9 @@
export type ToolPolicyDecision = "allow" | "ask" | "deny";
const allowedTools = new Set(["read", "grep", "find", "ls", "mock_ads_metrics"]);
export function decideToolPolicy(toolName: string): ToolPolicyDecision {
if (allowedTools.has(toolName)) return "allow";
if (toolName === "save_report_draft") return "ask";
return "deny";
}
@@ -0,0 +1,142 @@
import { randomUUID } from "node:crypto";
import { lstat, open, realpath, rename, rm } from "node:fs/promises";
import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
import { HarnessError } from "../errors/harness-error.js";
const protectedSegments = new Set([
".env",
".git",
".ssh",
"id_rsa",
"id_ed25519",
]);
export type ReportDraftInput = {
title?: string;
body: string;
};
export class SafeWorkspace {
private constructor(public readonly root: string) {}
public static async create(root: string): Promise<SafeWorkspace> {
const canonicalRoot = await realpath(resolve(root));
if (canonicalRoot === parse(canonicalRoot).root) {
throw new HarnessError("UNSAFE_WORKSPACE_ROOT", "Filesystem root cannot be used as a workspace");
}
const metadata = await lstat(canonicalRoot);
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new HarnessError("UNSAFE_WORKSPACE_ROOT", "Workspace root must be a regular directory");
}
return new SafeWorkspace(canonicalRoot);
}
public async resolveReadable(inputPath: string): Promise<string> {
const candidate = isAbsolute(inputPath) ? resolve(inputPath) : resolve(this.root, inputPath);
this.#assertContained(candidate);
this.#assertUnprotected(candidate);
await this.#assertNoSymlinkSegments(candidate);
const canonical = await realpath(candidate);
this.#assertContained(canonical);
const metadata = await lstat(canonical);
if (!metadata.isFile()) {
throw new HarnessError("NOT_A_REGULAR_FILE", "Only regular files may be read");
}
return canonical;
}
public async writeReportDraft(input: ReportDraftInput): Promise<{ relativePath: "report.md"; byteSize: number }> {
if (input.body.trim().length === 0) {
throw new HarnessError("REPORT_BODY_REQUIRED", "Report body cannot be empty");
}
const target = join(this.root, "report.md");
const temporary = join(this.root, `.report-${randomUUID()}.tmp`);
this.#assertContained(target);
await this.#assertNoSymlinkSegments(this.root);
const title = input.title?.trim();
const content = title === undefined || title.length === 0
? `${input.body.trim()}\n`
: `# ${title}\n\n${input.body.trim()}\n`;
const bytes = Buffer.from(content, "utf8");
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(temporary, "wx", 0o600);
await handle.writeFile(bytes);
await handle.sync();
await handle.close();
handle = undefined;
try {
const existing = await lstat(target);
if (existing.isSymbolicLink()) {
throw new HarnessError("SYMLINK_NOT_ALLOWED", "Report target cannot be a symbolic link");
}
} catch (error) {
if (error instanceof HarnessError) throw error;
if (!isMissingFileError(error)) throw error;
}
await rename(temporary, target);
return { relativePath: "report.md", byteSize: bytes.byteLength };
} finally {
await handle?.close().catch(() => undefined);
await rm(temporary, { force: true }).catch(() => undefined);
}
}
public async remove(): Promise<void> {
const metadata = await lstat(this.root).catch((error: unknown) => {
if (isMissingFileError(error)) return undefined;
throw error;
});
if (metadata === undefined) return;
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new HarnessError("UNSAFE_WORKSPACE_ROOT", "Refusing to remove a non-directory workspace root");
}
await rm(this.root, { recursive: true, force: false });
}
#assertContained(candidate: string): void {
const pathFromRoot = relative(this.root, candidate);
if (pathFromRoot === "") return;
if (pathFromRoot === ".." || pathFromRoot.startsWith(`..${sep}`) || isAbsolute(pathFromRoot)) {
throw new HarnessError("PATH_OUTSIDE_WORKSPACE", "Path is outside the Session workspace");
}
}
#assertUnprotected(candidate: string): void {
const pathFromRoot = relative(this.root, candidate);
const segments = pathFromRoot.split(sep);
if (segments.some((segment) => protectedSegments.has(segment.toLowerCase()))) {
throw new HarnessError("PROTECTED_PATH", "Protected paths cannot be accessed");
}
}
async #assertNoSymlinkSegments(candidate: string): Promise<void> {
this.#assertContained(candidate);
const pathFromRoot = relative(this.root, candidate);
if (pathFromRoot === "") {
const rootMetadata = await lstat(this.root);
if (rootMetadata.isSymbolicLink()) {
throw new HarnessError("SYMLINK_NOT_ALLOWED", "Symbolic links are not allowed");
}
return;
}
let current = this.root;
for (const segment of pathFromRoot.split(sep)) {
current = join(current, segment);
const metadata = await lstat(current);
if (metadata.isSymbolicLink()) {
throw new HarnessError("SYMLINK_NOT_ALLOWED", "Symbolic links are not allowed");
}
}
}
}
function isMissingFileError(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}