Skip to content
This repository was archived by the owner on Aug 17, 2026. It is now read-only.
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
8 changes: 4 additions & 4 deletions packages/extension/src/canvas-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export function registerCanvasTool(pi: ExtensionAPI): void {
promptSnippet:
"Point the dashboard canvas at a deliverable you're producing (file, url, or a running dev server)",
promptGuidelines: [
"Call canvas() when you produce or update a user-facing deliverable — a report/doc/mockup/image (target.kind='file', path relative to the session cwd), a web page (target.kind='url'), or a dev server you started (target.kind='server', port).",
"Paths MUST be relative to the session cwd — never absolute and never containing '..'.",
"Call canvas() when you produce or update a user-facing deliverable — a report/doc/mockup/image (target.kind='file', path relative to the session cwd or absolute file path), a web page (target.kind='url'), or a dev server you started (target.kind='server', port).",
"Paths can be relative to the session cwd or absolute never containing '..'.",
"This is fire-and-forget: it returns immediately and never blocks. Keep working after calling it.",
"Use mode='pin' to keep the canvas on this artifact across later writes; default 'replace' lets the newest artifact take the slot.",
],
Expand All @@ -48,13 +48,13 @@ export function registerCanvasTool(pi: ExtensionAPI): void {
[Type.Literal("file"), Type.Literal("url"), Type.Literal("server")],
{
description:
"'file' = a file relative to the session cwd (needs path); 'url' = a web page (needs url); 'server' = a running dev server (needs port).",
"'file' = a file relative to the session cwd or absolute (needs path); 'url' = a web page (needs url); 'server' = a running dev server (needs port).",
},
),
path: Type.Optional(
Type.String({
description:
"Required for kind='file'. Relative to the session cwd — no absolute paths, no '..'.",
"Required for kind='file'. Relative to the session cwd or absolute no '..'.",
}),
),
url: Type.Optional(
Expand Down
50 changes: 50 additions & 0 deletions packages/server/src/__tests__/canvas-accumulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
* (last declare wins), S21 (settings read-fresh, no cache).
*/

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
type CanvasTypes,
DEFAULT_CANVAS_TYPES,
Expand Down Expand Up @@ -184,4 +187,51 @@ describe("canvas accumulator", () => {
const settle = h.intents.filter((i) => i.phase === "settle");
expect(settle[0].target).toEqual({ kind: "file", cwd: CWD, path: "report.md" });
});
it("records session provenance paths on tool_execution_end for existing files only", () => {
const recorded: Array<{ sessionId: string; path: string }> = [];
const acc = createCanvasAccumulator({
readCanvasTypes: () => DEFAULT_CANVAS_TYPES,
broadcastIntent: () => {},
broadcastServerChip: () => {},
broadcastServerChipExpire: () => {},
recordProvenancePath: (sessionId, path) => recorded.push({ sessionId, path }),
});

const tmpFile = path.join(os.tmpdir(), `prov-test-${Date.now()}.txt`);
fs.writeFileSync(tmpFile, "test content");

try {
// tool_execution_start MUST NOT record provenance
acc.onEvent("s1", { eventType: "tool_execution_start", data: { toolName: "write", args: { path: tmpFile } } }, live);
expect(recorded).toHaveLength(0);

// Failed tool_execution_end MUST NOT record provenance
acc.onEvent("s1", { eventType: "tool_execution_end", data: { toolName: "write", args: { path: tmpFile }, isError: true } }, live);
expect(recorded).toHaveLength(0);

// canvas() declare tool_execution_end MUST NOT record provenance
acc.onEvent("s1", { eventType: "tool_execution_end", data: { toolName: "canvas", args: { target: { kind: "file", path: "/etc/passwd" } } } }, live);
expect(recorded).toHaveLength(0);

// Non-existent file tool_execution_end MUST NOT record provenance
acc.onEvent("s1", { eventType: "tool_execution_end", data: { toolName: "write", args: { path: "/non/existent/file.txt" } } }, live);
expect(recorded).toHaveLength(0);

// Directory path tool_execution_end MUST NOT record provenance
acc.onEvent("s1", { eventType: "tool_execution_end", data: { toolName: "write", args: { path: os.tmpdir() } } }, live);
expect(recorded).toHaveLength(0);

// Traversal attempt MUST NOT record provenance
acc.onEvent("s1", { eventType: "tool_execution_end", data: { toolName: "write", args: { path: "../etc/passwd" } } }, live);
expect(recorded).toHaveLength(0);

// Successful tool_execution_end for existing file DOES record provenance
acc.onEvent("s1", { eventType: "tool_execution_end", data: { toolName: "write", args: { path: tmpFile } } }, live);
expect(recorded).toEqual([
{ sessionId: "s1", path: fs.realpathSync(tmpFile) },
]);
} finally {
if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ function makeSessionMgr(sessions: DashboardSession[] = []): SessionManager {
get: (id: string) => map.get(id),
listActive: () => Array.from(map.values()).filter(s => s.status !== "ended"),
listAll: () => Array.from(map.values()),
addProvenancePath: vi.fn(),
getProvenancePathsForSession: () => new Set(),
getProvenancePathsForCwd: () => new Set(),
};
}

Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/__tests__/directory-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ function createMockSessionManager(sessions: DashboardSession[] = []): SessionMan
get: (id) => map.get(id),
listActive: () => Array.from(map.values()).filter((s) => s.status !== "ended"),
listAll: () => Array.from(map.values()),
addProvenancePath: vi.fn(),
getProvenancePathsForSession: () => new Set(),
getProvenancePathsForCwd: () => new Set(),
};
}

Expand Down
Loading
Loading