fix(agent-cursor): address Cursor Bugbot security findings

1. Add symlink check for .cursor directory in extractCursorSummary
   to match getCursorSessionMtime behavior (prevents path traversal)

2. Add vitest alias for @aoagents/ao-plugin-agent-cursor in CLI tests
   (fixes missing module resolution in tests)

3. Add lstatSync check before readFileSync in getLaunchCommand
   to reject symlinked systemPromptFile paths (security hardening)

4. Add test coverage for symlink rejection behavior

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
harshitsinghbhandari 2026-04-10 12:16:37 +05:30
parent 2cd5dc1a1d
commit e47b03807c
3 changed files with 34 additions and 6 deletions

View File

@ -47,6 +47,10 @@ export default defineConfig({
find: "@aoagents/ao-plugin-agent-opencode",
replacement: resolve(__dirname, "../plugins/agent-opencode/src/index.ts"),
},
{
find: "@aoagents/ao-plugin-agent-cursor",
replacement: resolve(__dirname, "../plugins/agent-cursor/src/index.ts"),
},
{
find: "@aoagents/ao-plugin-scm-github",
replacement: resolve(__dirname, "../plugins/scm-github/src/index.ts"),

View File

@ -14,8 +14,9 @@ vi.mock("node:fs/promises", async (importOriginal) => {
});
// Mock fs (sync) for getLaunchCommand systemPromptFile tests
const { mockReadFileSync } = vi.hoisted(() => ({
const { mockReadFileSync, mockLstatSync } = vi.hoisted(() => ({
mockReadFileSync: vi.fn(),
mockLstatSync: vi.fn().mockReturnValue({ isSymbolicLink: () => false }),
}));
vi.mock("node:fs", async (importOriginal) => {
@ -23,6 +24,7 @@ vi.mock("node:fs", async (importOriginal) => {
return {
...actual,
readFileSync: mockReadFileSync,
lstatSync: mockLstatSync,
};
});
@ -256,6 +258,17 @@ describe("getLaunchCommand", () => {
);
expect(cmd).toBe("agent -- 'Do the task'");
});
it("rejects symlinked systemPromptFile for security", () => {
mockLstatSync.mockReturnValueOnce({ isSymbolicLink: () => true });
mockReadFileSync.mockReturnValueOnce("Should not be read");
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ systemPromptFile: "/path/to/symlink.txt", prompt: "Do the task" }),
);
// Should skip the symlinked file and only include the prompt
expect(cmd).toBe("agent -- 'Do the task'");
expect(mockReadFileSync).not.toHaveBeenCalled();
});
});
// =========================================================================

View File

@ -24,7 +24,7 @@ import {
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { stat, access, readFile, lstat } from "node:fs/promises";
import { readFileSync, constants } from "node:fs";
import { readFileSync, lstatSync, constants } from "node:fs";
import { join, resolve } from "node:path";
const execFileAsync = promisify(execFile);
@ -101,10 +101,15 @@ async function extractCursorSummary(workspacePath: string): Promise<string | nul
const chatFile = join(cursorDir, "chat.md");
try {
// Security check: verify chatFile is not a symlink and stays under workspacePath
// Security check: reject symlinks to prevent path traversal
const dirStats = await lstat(cursorDir);
if (dirStats.isSymbolicLink()) {
return null; // Reject symlinked .cursor directory
}
const lstats = await lstat(chatFile);
if (lstats.isSymbolicLink()) {
return null; // Reject symlinks to prevent path traversal
return null; // Reject symlinked chat file
}
// Verify the resolved path stays under workspacePath
@ -177,8 +182,14 @@ function createCursorAgent(): Agent {
// Read system prompt from file or inline config
if (config.systemPromptFile) {
try {
const systemContent = readFileSync(config.systemPromptFile, "utf-8");
promptText = systemContent.trim();
// Security check: reject symlinks to prevent path traversal attacks
const lstats = lstatSync(config.systemPromptFile);
if (lstats.isSymbolicLink()) {
// Skip symlinked system prompt files
} else {
const systemContent = readFileSync(config.systemPromptFile, "utf-8");
promptText = systemContent.trim();
}
} catch {
// File doesn't exist or can't be read - continue without system prompt
}