diff --git a/electron/automationbuilder/builder.ts b/electron/automationbuilder/builder.ts index 83de506..623e8d0 100644 --- a/electron/automationbuilder/builder.ts +++ b/electron/automationbuilder/builder.ts @@ -43,6 +43,12 @@ function automationsRoot(): string { return path.join(os.homedir(), ".copilot", "automations"); } +/** True when `dir` is `root` or nested inside it (so we can safely re-use it). */ +function isInside(root: string, dir: string): boolean { + const rel = path.relative(root, dir); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} + interface LiveBuild extends BaseLive { sessionDir: string; architecture: SkillArchitecture; @@ -217,10 +223,15 @@ export class AutomationBuilder extends AgentBuilder { const root = automationsRoot(); const name = slugifySkillName(automation.name); const prior = loadPersistedAutomation(automation.sessionId); + const priorDir = prior?.exportedPath ? path.dirname(prior.exportedPath) : null; // Re-export to the same folder if this session already exported one; otherwise pick - // a fresh, non-colliding directory so we never clobber an unrelated automation. - let dir = prior?.exportedPath ? path.dirname(prior.exportedPath) : path.join(root, name); - if (!prior?.exportedPath && existsSync(dir)) { + // the same in-root folder. Persisted paths outside the automations root may come + // from a prior download/export or tampered session data and must not be reused. + // Otherwise pick a fresh, non-colliding directory so we never clobber an unrelated + // automation. + const reuse = priorDir !== null && isInside(root, priorDir); + let dir = reuse ? (priorDir as string) : path.join(root, name); + if (!reuse && existsSync(dir)) { let n = 2; while (existsSync(path.join(root, `${name}-${n}`))) n++; dir = path.join(root, `${name}-${n}`); diff --git a/electron/automationbuilder/export.test.ts b/electron/automationbuilder/export.test.ts new file mode 100644 index 0000000..27d16ff --- /dev/null +++ b/electron/automationbuilder/export.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { AutomationPlanSchema } from "../../common/automation"; +import { AutomationBuilder, loadPersistedAutomation } from "./builder"; + +test("automation re-export refuses persisted targets outside the automations root", async () => { + await withRoots(async ({ automationsRoot, sessionsRoot, outsideRoot }) => { + const builder = new AutomationBuilder(() => undefined); + const sessionId = "automation-reexport-safety"; + mkdirSync(path.join(sessionsRoot, sessionId), { recursive: true }); + const plan = AutomationPlanSchema.parse({ + architecture: "cowork", + name: "daily-digest", + title: "Daily digest", + description: "Build a daily digest automation.", + trigger: { + type: "schedule", + schedule: { + kind: "single", + naturalLanguage: "Every weekday at 09:00", + days: [1, 2, 3, 4, 5], + time: { hour: 9, minute: 0 }, + }, + }, + steps: [{ label: "Draft", prompt: "Draft the digest." }], + }); + + const first = await builder.create(sessionId, plan); + assert.match(first.path, new RegExp(`^${escapeRegExp(automationsRoot)}`)); + + const persistedPath = path.join(sessionsRoot, sessionId, "built-automation.json"); + const persisted = loadPersistedAutomation(sessionId); + assert.ok(persisted?.exportedPath, "expected first create() to persist an exported path"); + + const escaped = path.join(outsideRoot, "escaped", "automation.json"); + await writeFile( + persistedPath, + `${JSON.stringify({ ...persisted, exportedPath: escaped }, null, 2)}\n`, + "utf8", + ); + + const second = await builder.create(sessionId, plan); + assert.match(second.path, new RegExp(`^${escapeRegExp(automationsRoot)}`)); + assert.doesNotMatch(second.path, new RegExp(`^${escapeRegExp(outsideRoot)}`)); + + const rendered = readFileSync(second.path, "utf8"); + assert.match(rendered, /daily-digest/); + }); +}); + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function withRoots( + run: (roots: { automationsRoot: string; sessionsRoot: string; outsideRoot: string }) => Promise, +): Promise { + const automationsRoot = await mkdtemp(path.join(tmpdir(), "skill-recorder-automations-")); + const sessionsRoot = await mkdtemp(path.join(tmpdir(), "skill-recorder-sessions-")); + const outsideRoot = await mkdtemp(path.join(tmpdir(), "skill-recorder-outside-")); + const previousAutomations = process.env.SKILL_RECORDER_AUTOMATIONS_DIR; + const previousSessions = process.env.SKILL_RECORDER_SESSIONS_DIR; + process.env.SKILL_RECORDER_AUTOMATIONS_DIR = automationsRoot; + process.env.SKILL_RECORDER_SESSIONS_DIR = sessionsRoot; + try { + await run({ automationsRoot, sessionsRoot, outsideRoot }); + } finally { + if (previousAutomations === undefined) delete process.env.SKILL_RECORDER_AUTOMATIONS_DIR; + else process.env.SKILL_RECORDER_AUTOMATIONS_DIR = previousAutomations; + if (previousSessions === undefined) delete process.env.SKILL_RECORDER_SESSIONS_DIR; + else process.env.SKILL_RECORDER_SESSIONS_DIR = previousSessions; + await Promise.all([ + rm(automationsRoot, { recursive: true, force: true }), + rm(sessionsRoot, { recursive: true, force: true }), + rm(outsideRoot, { recursive: true, force: true }), + ]); + } +} \ No newline at end of file diff --git a/package.json b/package.json index d1f81ae..9bcf860 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "check:lockfile": "node scripts/check-lockfile-portability.mjs", "typecheck": "tsc --noEmit", "typecheck:evals": "tsc --noEmit -p evals/tsconfig.json", - "test": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs --test evals/builder-imports.test.ts common/architecture-registry.test.ts electron/architectures/catalogue-registry.test.ts common/audio.test.ts common/microphone.test.ts common/screen.test.ts common/narration.test.ts common/sensitive.test.ts electron/recording-controls-bounds.test.ts electron/recorder-window-sizing.test.ts electron/recording-privacy.test.ts electron/crash-guards.test.ts electron/recorder/controller.test.ts electron/recorder/session-store.test.ts electron/frames/extractor.test.ts electron/narration/audio-analysis.test.ts electron/narration/analyze-gate.test.ts electron/narration/transcribe.test.ts electron/narration/whisper.test.ts electron/sensitive/scanner.test.ts electron/sensitive/secrets.test.ts electron/sensitive/tessdata-source.test.ts electron/sensitive/ocr.test.ts electron/sensitive/frame-redact.test.ts electron/sensitive/frame-heuristics.test.ts electron/sessions.test.ts electron/debug-bundle.test.ts electron/skillbuilder/placement.test.ts src/skill-placement.test.ts scripts/compliance.test.mjs", + "test": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs --test evals/builder-imports.test.ts common/architecture-registry.test.ts electron/architectures/catalogue-registry.test.ts common/audio.test.ts common/microphone.test.ts common/screen.test.ts common/narration.test.ts common/sensitive.test.ts electron/recording-controls-bounds.test.ts electron/recorder-window-sizing.test.ts electron/recording-privacy.test.ts electron/crash-guards.test.ts electron/recorder/controller.test.ts electron/recorder/session-store.test.ts electron/frames/extractor.test.ts electron/narration/audio-analysis.test.ts electron/narration/analyze-gate.test.ts electron/narration/transcribe.test.ts electron/narration/whisper.test.ts electron/sensitive/scanner.test.ts electron/sensitive/secrets.test.ts electron/sensitive/tessdata-source.test.ts electron/sensitive/ocr.test.ts electron/sensitive/frame-redact.test.ts electron/sensitive/frame-heuristics.test.ts electron/sessions.test.ts electron/debug-bundle.test.ts electron/skillbuilder/placement.test.ts electron/automationbuilder/export.test.ts src/skill-placement.test.ts scripts/compliance.test.mjs", "eval": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/run.ts", "eval:builder": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/builder/run.ts", "eval:skill": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/skillbuilder/run.ts",