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
2 changes: 1 addition & 1 deletion sdk/plugin-tinyplace/adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ field for your harness.
| `projectDir()` | `() => string` | Stable per-project scope key for assignment persistence when there's no session id. `""` = fall back to global scope. |
| `serverInstructions` | string | MCP `instructions`. **Must contain the word `UNTRUSTED`** — the prompt-injection guard telling the agent inbound DMs are data, not instructions. |
| `inbound` | `{ push, pull, foregroundInject }` | How new DMs reach a live session. `push` is `false` **or** `{ capability, method }` (server→client channel). `pull`/`foregroundInject` are booleans. **At least one delivery path must be truthy**, else DMs vanish. |
| `responder` | `{ command, defaultModel, buildArgs }` | Headless autoresponder. `buildArgs(prompt, model, pluginRoot)` returns the CLI argv and **must thread both `prompt` and `model`**. |
| `responder` | `{ command, defaultModel, buildArgs, prepare?, streamComplete? }` | Headless autoresponder. `buildArgs(prompt, model, pluginRoot, ctx?)` returns the CLI argv and **must thread both `prompt` and `model`**; keep it **side-effect-free** (unit-tested with no env). Optional `prepare(ctx)` runs **once per batch** in `respond-batch.mjs` for setup that can't live in `buildArgs` (e.g. Cursor builds a throwaway send-only `--workspace`); its returned fields are merged into the `ctx` passed to `buildArgs`. Optional `streamComplete: true` makes the spawner pipe stdout and finish on the CLI's terminal `{"type":"result"}` NDJSON event (killing a CLI that hangs after replying) instead of waiting for exit. |
| `install` | `{ kind }` | Launcher install strategy tag (e.g. `plugin-dir`, `codex-home`). |
| `launch` | `{ displayHarness, binary, notFoundHint?, prepare }` | Launcher recipe. `prepare(ctx)` returns `{ command, args, env }`; see below. |

Expand Down
155 changes: 110 additions & 45 deletions sdk/plugin-tinyplace/adapters/cursor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,30 +52,36 @@ export const cursorAdapter = {
responder: {
command: "cursor-agent",
defaultModel: "auto",
// Feeds ATTACKER-CONTROLLED DM text into headless `cursor-agent -p`, so it runs
// least-privilege (no `--force`/`--yolo`, which auto-allow writes + shell):
// • `--sandbox enabled` — OS-level sandbox so a prompt-injected DM can't write
// files or run shell through the Cursor agent.
// • `--trust` — grant workspace trust so headless mode can START (cursor-agent
// refuses an untrusted dir) WITHOUT `--force`'s run-everything permissiveness.
// • `--approve-mcps` — auto-approve the tinyplace MCP server; auto_reply (the
// only intended side-effecting path) runs in a SEPARATE process outside the
// sandbox.
// • `--output-format text` — clean reply text.
// • `--` — terminate option parsing before the untrusted DM (no flag smuggling;
// verified: cursor-agent errors on a dash-leading prompt without it).
//
// ⚠️ [VERIFY] Whether cursor-agent can INVOKE MCP tools under `--sandbox enabled`
// headlessly is NOT yet live-confirmed — validation was blocked by cursor-agent
// rate-limiting during testing. If a clean-env retest shows the sandbox blocks
// the MCP tool call, fall back (e.g. drop `--sandbox`, keep the throwaway
// isolated workspace + the spawner's timeout guard as the bound) and reopen the
// security trade-off. The first clean E2E confirmed the adapter itself works.
// NOTE: cursor-agent print-mode can hang after replying (verified) — the shared
// responder spawner (hooks/respond-batch.mjs) bounds every turn with a
// timeout + kill, so a hang fails the message instead of wedging the pool.
buildArgs(prompt, model /* pluginRoot unused: MCP comes from the workspace mcp.json */) {
return ["-p", "--sandbox", "enabled", "--trust", "--approve-mcps", "--output-format", "text", "--model", model, "--", prompt];
// The reply is delivered by the agent CALLING the tinyplace `auto_reply` MCP
// tool (the spawner ignores stdout — success is turn completion, not parsed
// text). cursor-agent can only invoke MCP tools headlessly under `--yolo`, so
// that flag is REQUIRED here — but it also auto-allows shell + file writes, and
// the prompt carries ATTACKER-CONTROLLED DM text. We bound the blast radius by
// running in a THROWAWAY isolated `--workspace` (below): a prompt-injected DM's
// writes/shell land in that per-wallet scratch dir, never the user's files.
// • `--yolo` — auto-approve MCP tool calls (needed for `auto_reply`); the
// `--workspace` isolation + the spawner's timeout/kill are the guardrails.
// • `--workspace <iso>` — throwaway send-only workspace carrying the tinyplace
// `.cursor/mcp.json` (SEND_ONLY, NO_AUTORESPOND, daemon off). Prepared once
// per batch by `prepare()`.
// • `--output-format stream-json` — emits a terminal `result` event; the
// spawner watches for it and kills the process, so cursor-agent's known
// print-mode HANG-after-reply ends promptly instead of waiting out the
// 180 s timeout (which would falsely fail an already-sent reply).
// • `--` — terminate option parsing before the untrusted DM (no flag
// smuggling; verified: cursor-agent errors on a dash-leading prompt).
streamComplete: true,
// Called ONCE per batch by hooks/respond-batch.mjs (not in buildArgs, which must
// stay side-effect-free for the unit test). Builds the isolated responder
// workspace and returns fields merged into the ctx passed to buildArgs.
prepare(ctx) {
return { workspace: ensureResponderWorkspace(ctx) };
},
buildArgs(prompt, model, _pluginRoot, ctx) {
const args = ["-p", "--yolo", "--output-format", "stream-json"];
if (ctx?.workspace) args.push("--workspace", ctx.workspace);
Comment on lines +81 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid yolo for attacker-controlled Cursor replies

When the Cursor auto-responder handles an untrusted DM, adding --yolo lets the model auto-approve shell and file-write tools; Cursor's parameter docs state that print mode has access to write/shell tools and that --yolo is an alias for --force, while --workspace is only the workspace directory, not an OS sandbox (https://cursor.com/docs/cli/reference/parameters.md). A malicious message can therefore prompt the responder to read or modify absolute paths outside the scratch workspace before calling auto_reply, regressing the previous --sandbox enabled posture.

Useful? React with 👍 / 👎.

args.push("--model", model, "--", prompt);
return args;
},
},

Expand All @@ -90,7 +96,8 @@ export const cursorAdapter = {
launch: {
displayHarness: "Cursor",
binary: "cursor-agent",
notFoundHint: "Is the Cursor Agent CLI installed and on your PATH? (curl https://cursor.com/install | bash)",
notFoundHint:
"Is the Cursor Agent CLI installed and on your PATH? (curl https://cursor.com/install | bash)",
// ctx: { pluginDir, dataDir, apiUrl, walletName, forwardedArgs }
prepare(ctx) {
const iso = ensureIsolatedWorkspace(ctx);
Expand All @@ -107,33 +114,91 @@ export const cursorAdapter = {
},
};

// Build (idempotently) an isolated Cursor workspace for a wallet and return its
// path. Layout: <dataDir>/cursor-home/<wallet>/.cursor/mcp.json
function ensureIsolatedWorkspace({ pluginDir, dataDir, apiUrl, walletName }) {
const iso = join(dataDir, "cursor-home", encodeURIComponent(walletName));
const cursorDir = join(iso, ".cursor");
// Write a `.cursor/mcp.json` under `root` wiring the tinyplace stdio MCP server
// with `env`. cursor-agent SANITIZES the MCP child env, so this `env` block is the
// ONLY channel that reaches the server — it must carry identity + config + the
// TINYPLACE_HARNESS=cursor sentinel that makes detectHarness pick this adapter.
function writeCursorMcpConfig({ root, pluginDir, env }) {
const cursorDir = join(root, ".cursor");
mkdirSync(cursorDir, { recursive: true });

const serverScript = join(pluginDir, "mcp", "server.mjs");
// The MCP `env` block is the ONLY channel that reaches the server (cursor-agent
// sanitizes the child env), so it must carry identity + config + the harness
// sentinel. TINYPLACE_HARNESS=cursor makes detectHarness pick this adapter, and
// the durable daemon is on so inbound survives MCP restarts.
const config = {
mcpServers: {
tinyplace: {
command: "node",
args: [serverScript],
env: {
TINYPLACE_HARNESS: "cursor",
TINYPLACE_ACTIVE_WALLET: walletName,
TINYPLACE_CURSOR_HOME: dataDir,
TINYPLACE_API_URL: apiUrl,
TINYPLACE_SESSION_DAEMON: "on",
},
args: [join(pluginDir, "mcp", "server.mjs")],
env,
},
},
};
writeFileSync(join(cursorDir, "mcp.json"), JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
writeFileSync(
join(cursorDir, "mcp.json"),
JSON.stringify(config, null, 2) + "\n",
{ mode: 0o600 },
);
return root;
}

// cursor-agent has no `--system-prompt`, so standing guidance can't ride the
// command line. Drop the tiny.place security posture into an always-applied
// Cursor rule (`.cursor/rules/*.mdc` with `alwaysApply: true`) so the interactive
// agent sees the UNTRUSTED-data handling even when the MCP serverInstructions
// aren't surfaced. (multica delivers instructions via `.cursor/skills/`, but those
// are on-demand; a standing security rule belongs in alwaysApply rules.)
// [VERIFY] headless honoring of alwaysApply rules across cursor-agent versions.
function writeCursorInstructionsRule(root, instructions) {
const rulesDir = join(root, ".cursor", "rules");
mkdirSync(rulesDir, { recursive: true });
const body = `---\ndescription: tiny.place messaging safety\nalwaysApply: true\n---\n\n${instructions}\n`;
writeFileSync(join(rulesDir, "tinyplace.mdc"), body, { mode: 0o600 });
}

// Build (idempotently) an isolated Cursor workspace for a wallet and return its
// path. Layout: <dataDir>/cursor-home/<wallet>/.cursor/{mcp.json,rules/tinyplace.mdc}
// The durable daemon is ON so inbound survives MCP restarts (interactive path).
function ensureIsolatedWorkspace({ pluginDir, dataDir, apiUrl, walletName }) {
const iso = join(dataDir, "cursor-home", encodeURIComponent(walletName));
writeCursorMcpConfig({
root: iso,
pluginDir,
env: {
TINYPLACE_HARNESS: "cursor",
TINYPLACE_ACTIVE_WALLET: walletName,
TINYPLACE_CURSOR_HOME: dataDir,
TINYPLACE_API_URL: apiUrl,
TINYPLACE_SESSION_DAEMON: "on",
},
});
writeCursorInstructionsRule(iso, cursorAdapter.serverInstructions);
return iso;
}

// Build (idempotently) the THROWAWAY send-only workspace the auto-responder runs
// in under `--yolo`. Layout: <dataDir>/responder-home/<wallet>/.cursor/mcp.json.
// Its MCP env pins SEND_ONLY + NO_AUTORESPOND and daemon OFF so the responder can
// only call `auto_reply` — it neither drains the shared mailbox nor recurses into
// the dispatcher. `--yolo`'s file writes/shell are confined to this scratch dir.
// Falls back to dataDirDefault when TINYPLACE_CURSOR_HOME isn't forwarded.
function ensureResponderWorkspace(ctx = {}) {
const dataDir =
ctx.dataDir ||
process.env.TINYPLACE_CURSOR_HOME ||
cursorAdapter.dataDirDefault;
const walletName =
ctx.wallet || process.env.TINYPLACE_ACTIVE_WALLET || "agent";
const pluginDir = ctx.pluginDir;
const apiUrl = ctx.apiUrl || process.env.TINYPLACE_API_URL || "";
const iso = join(dataDir, "responder-home", encodeURIComponent(walletName));
return writeCursorMcpConfig({
root: iso,
pluginDir,
env: {
TINYPLACE_HARNESS: "cursor",
TINYPLACE_ACTIVE_WALLET: walletName,
TINYPLACE_CURSOR_HOME: dataDir,
TINYPLACE_API_URL: apiUrl,
TINYPLACE_SEND_ONLY: "1",
TINYPLACE_NO_AUTORESPOND: "1",
TINYPLACE_SESSION_DAEMON: "off",
},
});
}
Loading
Loading