Skip to content
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
1 change: 1 addition & 0 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39370,6 +39370,7 @@ export function createAgentChatService(args: {
request: req,
policy,
laneRoot: managed.laneWorktreePath,
projectRoot,
sessionAllowedTools: runtime.sdkApprovedTools,
userHomeDir: resolveCursorSdkUserHome(),
});
Expand Down
168 changes: 168 additions & 0 deletions apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,174 @@ describe("Cursor SDK policy", () => {
},
}, laneRoot);
expect(evaluateCursorSdkHook({ request: writeTranscript, policy, laneRoot, userHomeDir })).toBe("deny");

const asset = summarizeCursorHook({
toolName: "read",
toolInput: {
path: path.join(userHomeDir, ".cursor", "projects", slug, "assets", "shot.png"),
},
}, laneRoot);
expect(evaluateCursorSdkHook({ request: asset, policy, laneRoot, userHomeDir })).toBe("allow");

const writeAsset = summarizeCursorHook({
toolName: "write",
toolInput: {
path: path.join(userHomeDir, ".cursor", "projects", slug, "assets", "shot.png"),
contents: "x",
},
}, laneRoot);
expect(evaluateCursorSdkHook({ request: writeAsset, policy, laneRoot, userHomeDir })).toBe("deny");

const otherSlug = `${slug}-other`;
const otherAsset = summarizeCursorHook({
toolName: "read",
toolInput: {
path: path.join(userHomeDir, ".cursor", "projects", otherSlug, "assets", "shot.png"),
},
}, laneRoot);
expect(evaluateCursorSdkHook({ request: otherAsset, policy, laneRoot, userHomeDir })).toBe("deny");
});

it("allows read-only access to staged project attachments from a lane worktree", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-attach-"));
const projectRoot = path.join(root, "repo");
const laneRoot = path.join(projectRoot, ".ade", "worktrees", "lane");
const attachmentsDir = path.join(projectRoot, ".ade", "attachments");
const secretsDir = path.join(projectRoot, ".ade", "secrets");
const imagePath = path.join(attachmentsDir, "00000000-0000-4000-8000-000000000001.png");
const otherProject = path.join(root, "other-repo");
const otherImage = path.join(otherProject, ".ade", "attachments", "shot.png");
fs.mkdirSync(laneRoot, { recursive: true });
fs.mkdirSync(attachmentsDir, { recursive: true });
fs.mkdirSync(secretsDir, { recursive: true });
fs.mkdirSync(path.dirname(otherImage), { recursive: true });
fs.writeFileSync(imagePath, "png");
fs.writeFileSync(path.join(secretsDir, "token"), "secret");
fs.writeFileSync(otherImage, "png");

try {
const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" });
const read = summarizeCursorHook({
toolName: "read",
toolInput: { path: imagePath },
}, laneRoot);
expect(evaluateCursorSdkHook({
request: read,
policy,
laneRoot,
projectRoot,
})).toBe("allow");
expect(evaluateCursorSdkHook({
request: summarizeCursorHook({
toolName: "read",
toolInput: { path: imagePath },
}, laneRoot),
policy,
laneRoot,
})).toBe("deny");

const write = summarizeCursorHook({
toolName: "write",
toolInput: { path: imagePath, contents: "x" },
}, laneRoot);
expect(evaluateCursorSdkHook({
request: write,
policy,
laneRoot,
projectRoot,
})).toBe("deny");

const secret = summarizeCursorHook({
toolName: "read",
toolInput: { path: path.join(secretsDir, "token") },
}, laneRoot);
expect(evaluateCursorSdkHook({
request: secret,
policy,
laneRoot,
projectRoot,
})).toBe("deny");

const foreign = summarizeCursorHook({
toolName: "read",
toolInput: { path: otherImage },
}, laneRoot);
expect(evaluateCursorSdkHook({
request: foreign,
policy,
laneRoot,
projectRoot,
})).toBe("deny");

const shell = summarizeCursorHook({
toolName: "shell",
toolInput: { command: `cat ${imagePath}`, cwd: laneRoot },
}, laneRoot);
expect(evaluateCursorSdkHook({
request: shell,
policy,
laneRoot,
projectRoot,
})).toBe("deny");
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});

it.skipIf(process.platform === "win32")("denies project attachment reads when attachments is symlinked onto secrets", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-attach-secrets-"));
const projectRoot = path.join(root, "repo");
const laneRoot = path.join(projectRoot, ".ade", "worktrees", "lane");
const secretsDir = path.join(projectRoot, ".ade", "secrets");
const attachmentsLink = path.join(projectRoot, ".ade", "attachments");
fs.mkdirSync(laneRoot, { recursive: true });
fs.mkdirSync(secretsDir, { recursive: true });
fs.writeFileSync(path.join(secretsDir, "token"), "secret");
fs.symlinkSync(secretsDir, attachmentsLink, "dir");

try {
const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" });
const request = summarizeCursorHook({
toolName: "read",
toolInput: { path: path.join(attachmentsLink, "token") },
}, laneRoot);
expect(evaluateCursorSdkHook({
request,
policy,
laneRoot,
projectRoot,
})).toBe("deny");
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});

it.skipIf(process.platform === "win32")("denies project attachment reads when attachments is symlinked outside the project", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-attach-link-"));
const projectRoot = path.join(root, "repo");
const laneRoot = path.join(projectRoot, ".ade", "worktrees", "lane");
const outside = path.join(root, "outside");
const attachmentsLink = path.join(projectRoot, ".ade", "attachments");
fs.mkdirSync(laneRoot, { recursive: true });
fs.mkdirSync(outside, { recursive: true });
fs.writeFileSync(path.join(outside, "shot.png"), "png");
fs.symlinkSync(outside, attachmentsLink, "dir");

try {
const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" });
const request = summarizeCursorHook({
toolName: "read",
toolInput: { path: path.join(attachmentsLink, "shot.png") },
}, laneRoot);
expect(evaluateCursorSdkHook({
request,
policy,
laneRoot,
projectRoot,
})).toBe("deny");
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});

it.skipIf(process.platform === "win32")("denies Cursor support reads when the active project support root is symlinked outside Cursor projects", () => {
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/main/services/chat/cursorSdkPolicy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import type { AgentChatSession } from "../../../shared/types";
import { projectAttachmentsDir } from "../../../shared/chatAttachmentStagingFs";
import { cursorProjectSlug } from "../../../shared/cursorProjectSlug";
import { pathComparisonKey } from "../shared/pathCompare";
import {
isOrchestrationLeadSession,
ORCHESTRATION_LEAD_ALLOWED_CURSOR_TOOL_RISKS,
Expand Down Expand Up @@ -529,9 +531,12 @@ function cursorSupportReadRoots(laneRoot: string, userHomeDir?: string | null):
const home = userHomeDir?.trim();
if (!home) return [];
const projectRoot = path.join(home, ".cursor", "projects", cursorProjectSlugForPath(laneRoot));
// `assets` is Cursor's own copy of inlined chat images for this workspace.
// The model is told to Read those files; they live outside the lane worktree.
return [
path.join(projectRoot, "terminals"),
path.join(projectRoot, "agent-transcripts"),
path.join(projectRoot, "assets"),
];
}

Expand Down Expand Up @@ -559,12 +564,47 @@ function isAllowedCursorSupportRead(args: {
return false;
}

function isAllowedProjectAttachmentRead(args: {
candidatePath: string;
projectRoot?: string | null;
risk: CursorSdkHookRequest["risk"];
}): boolean {
if (args.risk !== "read") return false;
// Worker init always passes the ADE project root. Fail closed rather than
// guessing from the lane path — a worktree chat must not inherit the
// project's attachments grant from layout inference.
const explicit = args.projectRoot?.trim();
if (!explicit) return false;
const resolvedProject = path.resolve(explicit);
const projectReal = realPathWithNearestExistingAncestor(resolvedProject);
const adeReal = realPathWithNearestExistingAncestor(path.join(resolvedProject, ".ade"));
const attachmentsReal = realPathWithNearestExistingAncestor(
projectAttachmentsDir(resolvedProject),
);
const secretsReal = realPathWithNearestExistingAncestor(
path.join(resolvedProject, ".ade", "secrets"),
);
// Same shape as the Cursor projects symlink guard, plus a basename check
// after realpath so a junction/symlink from `attachments` onto `.ade` or
// `.ade/secrets` cannot inherit this grant.
if (!isWithinPath(projectReal, adeReal) || !isWithinPath(adeReal, attachmentsReal)) {
return false;
}
if (pathComparisonKey(path.basename(attachmentsReal)) !== pathComparisonKey("attachments")) {
return false;
}
const candidateReal = realPathWithNearestExistingAncestor(args.candidatePath);
if (isWithinPath(secretsReal, candidateReal)) return false;
return isWithinPath(attachmentsReal, candidateReal);
}

function pathGuardReason(args: {
laneRoot: string;
cwd: string;
value: unknown;
risk: CursorSdkHookRequest["risk"];
userHomeDir?: string | null;
projectRoot?: string | null;
}): string | null {
const laneRoot = path.resolve(args.laneRoot);
const laneRootReal = realPathWithNearestExistingAncestor(laneRoot);
Expand Down Expand Up @@ -601,6 +641,10 @@ function pathGuardReason(args: {
laneRoot,
userHomeDir: args.userHomeDir,
risk: args.risk,
}) || isAllowedProjectAttachmentRead({
candidatePath: resolved,
projectRoot: args.projectRoot,
risk: args.risk,
})) {
continue;
}
Expand All @@ -624,6 +668,7 @@ export function evaluateCursorSdkHook(args: {
request: CursorSdkHookRequest;
policy: CursorSdkPermissionPolicy;
laneRoot: string;
projectRoot?: string | null;
sessionAllowedTools?: Set<string>;
userHomeDir?: string | null;
}): "allow" | "deny" | "ask" {
Expand All @@ -634,6 +679,7 @@ export function evaluateCursorSdkHook(args: {
value: args.request.toolInput ?? args.request.raw,
risk: args.request.risk,
userHomeDir: args.userHomeDir,
projectRoot: args.projectRoot,
})
: null;
if (guardReason) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,7 @@ describe("Cursor SDK pool paths", () => {
modelSdkId: "grok-4.6",
apiKey: "cursor-test-key",
sessionId: "oneshot:session_title",
projectRoot: path.join(os.tmpdir(), "ade-project"),
laneRoot: workspacePath,
// Fixed, both of them: the warm worker is shared across features and
// keeps the policy and the name it was created with.
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/services/chat/cursorSdkPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,7 @@ async function createCursorSdkConnection(args: Parameters<typeof acquireCursorSd
const initPayload: CursorSdkWorkerInit = {
sessionId: args.sessionId,
laneRoot: args.workspacePath,
projectRoot: args.projectRoot,
userHomeDir: paths.userHomeDir,
stateRoot: paths.stateRoot,
socketPath: paths.socketPath,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/services/chat/cursorSdkProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ export type CursorSdkHookRequest = {
export type CursorSdkWorkerInit = {
sessionId: string;
laneRoot: string;
/** Project root that owns `.ade/attachments`. Used by the lane path guard. */
projectRoot: string;
userHomeDir: string;
stateRoot: string;
socketPath: string;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/services/chat/cursorSdkWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ async function handleHookSocketLine(init: CursorSdkWorkerInit, socket: net.Socke
request,
policy: init.policy,
laneRoot: init.laneRoot,
projectRoot: init.projectRoot,
userHomeDir: init.userHomeDir,
});
if (localDecision === "allow") {
Expand Down
Loading
Loading