diff --git a/.changeset/enforce-permissions.md b/.changeset/enforce-permissions.md new file mode 100644 index 0000000..1b67259 --- /dev/null +++ b/.changeset/enforce-permissions.md @@ -0,0 +1,5 @@ +--- +"@pungrumpy/cursor-action": major +--- + +Enforce the `permissions` input through the Cursor SDK's tool restrictions. `read-only` (the default) now offers the agent read, search, and web tools only; `read-write` removes `shell` and `task` from the default toolset; `full` leaves the SDK default. Workflows that relied on `read-only` not restricting anything must set `permissions: full`. Pin `uses: PunGrumpy/cursor-action@v2` to pick this up; `@v1` keeps the previous behaviour. diff --git a/README.md b/README.md index 4355b1b..7a5f90d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Run [Cursor](https://cursor.com) agents in GitHub Actions using the official [`@ ```yaml - name: Run Cursor Agent id: cursor - uses: PunGrumpy/cursor-action@v1 + uses: PunGrumpy/cursor-action@v2 with: api-key: ${{ secrets.CURSOR_API_KEY }} prompt: "Review this PR for security issues and summarize your findings." @@ -39,7 +39,7 @@ The action runs on `ubuntu-latest`, `windows-latest`, and `macos-latest`. | `prompt` | ✅ | — | Prompt to pass to cursor-agent | | `model` | ❌ | `default` | Model id for the agent (e.g. default, composer-2). The Cursor SDK does not accept auto. | | `working-directory` | ❌ | `.` | Working directory for the agent to operate in | -| `permissions` | ❌ | `read-only` | Validated but NOT enforced: 'read-only' does not stop the agent from editing files or running shell commands. Wired to the SDK in v2. | +| `permissions` | ❌ | `read-only` | Built-in tools the agent may use: read-only (read, search, and web tools), read-write (adds file edits; no shell or subagents), or full (the SDK's default toolset). | | `timeout` | ❌ | `300` | Timeout in seconds for the agent run | ### Outputs @@ -56,10 +56,6 @@ The action runs on `ubuntu-latest`, `windows-latest`, and `macos-latest`. -> [!WARNING] -> -> `permissions` does not restrict the agent today. The value is validated and then discarded — tool access follows whatever your API key and account allow, so `read-only` does **not** stop the agent from editing files or running shell commands. It is wired to the SDK's tool restrictions in v2. - > [!IMPORTANT] > > Treat `summary` as untrusted model output. Pass it through `env:` rather than interpolating `${{ steps..outputs.summary }}` directly into a `run:` script or a `github-script` body — interpolation splices the text into the script before it executes. @@ -114,8 +110,8 @@ env "INPUT_API-KEY=$CURSOR_API_KEY" \ - `CI` runs `typecheck`, `lint`, `test`, `build`, and a `dist/` freshness check on every push and pull request. The `Docs` job regenerates the reference from `action.yml`, typechecks the site, and builds it. - The `Integration` jobs install the action's runtime dependencies and run it with an invalid key on Ubuntu, Windows, and macOS. Being rejected at authentication is the pass condition: it proves the dependency tree resolves and the SDK reaches Cursor, without spending an agent run. - `Smoke Test` runs the action for real and needs a `CURSOR_API_KEY` on a paid plan, so it only runs from the Actions tab (`workflow_dispatch`). -- `Release` runs Changesets on pushes to `main` to open a release PR or publish, then moves the `v1` tag to the published version. -- `uses: PunGrumpy/cursor-action@v1` tracks the latest `v1.x.x`. Pin a full tag or a commit SHA if you want a frozen version. +- `Release` runs Changesets on pushes to `main` to open a release PR or publish, then moves the major version tag (`v2`) to the published version. +- `uses: PunGrumpy/cursor-action@v2` tracks the latest `v2.x.x`. Pin a full tag or a commit SHA if you want a frozen version. ## Versioning diff --git a/__tests__/permissions.test.ts b/__tests__/permissions.test.ts new file mode 100644 index 0000000..23ba917 --- /dev/null +++ b/__tests__/permissions.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; + +import { toolRestrictionsFor } from "../src/permissions"; + +describe("toolRestrictionsFor", () => { + it("allows only read, search, and web tools for read-only", () => { + const restrictions = toolRestrictionsFor("read-only"); + expect(restrictions.disallowedTools).toBeUndefined(); + expect(restrictions.tools).toEqual([ + "read", + "grep", + "glob", + "ls", + "semSearch", + "readLints", + "webSearch", + "webFetch", + "readTodos", + "updateTodos", + ]); + expect(restrictions.tools).not.toContain("shell"); + expect(restrictions.tools).not.toContain("edit"); + expect(restrictions.tools).not.toContain("task"); + expect(restrictions.tools).not.toContain("mcp"); + }); + + it("denies shell and subagents for read-write", () => { + expect(toolRestrictionsFor("read-write")).toEqual({ + disallowedTools: ["shell", "task"], + }); + }); + + it("leaves the default toolset for full", () => { + expect(toolRestrictionsFor("full")).toEqual({}); + }); +}); diff --git a/__tests__/runner.test.ts b/__tests__/runner.test.ts index 08d33a6..1f8ba47 100644 --- a/__tests__/runner.test.ts +++ b/__tests__/runner.test.ts @@ -120,6 +120,7 @@ describe("runAgent", () => { apiKey: "test-key", local: { cwd: expect.any(String) }, model: { id: "default" }, + tools: expect.arrayContaining(["read", "grep"]), }); expect(mockAgentSend).toHaveBeenCalledWith("Analyze this code"); @@ -130,10 +131,17 @@ describe("runAgent", () => { expect(result.stderr).toBe(""); }); - it("warns when permissions is not read-only that the SDK does not use this input", async () => { + it("passes the permission level to Agent.create as tool restrictions", async () => { await runAgent({ ...baseInputs, permissions: "read-write" }); + expect(mockAgentCreate).toHaveBeenLastCalledWith( + expect.objectContaining({ disallowedTools: ["shell", "task"] }) + ); - expect(mockWarning).toHaveBeenCalledWith( + await runAgent({ ...baseInputs, permissions: "full" }); + const fullCall = mockAgentCreate.mock.calls.at(-1)?.[0]; + expect(fullCall).not.toHaveProperty("tools"); + expect(fullCall).not.toHaveProperty("disallowedTools"); + expect(mockWarning).not.toHaveBeenCalledWith( expect.stringContaining("permissions") ); }); diff --git a/action.yml b/action.yml index 322478a..edba557 100644 --- a/action.yml +++ b/action.yml @@ -31,7 +31,7 @@ inputs: default: "." permissions: - description: "Validated but NOT enforced: 'read-only' does not stop the agent from editing files or running shell commands. Wired to the SDK in v2." + description: "Built-in tools the agent may use: read-only (read, search, and web tools), read-write (adds file edits; no shell or subagents), or full (the SDK's default toolset)." required: false default: "read-only" diff --git a/dist/index.mjs b/dist/index.mjs index 26d102d..ee5116e 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -95,6 +95,36 @@ const setOutputs = async (result) => { }; const maskSecret = (apiKey) => setSecret(apiKey); //#endregion +//#region src/permissions.ts +/** +* Tool names come from the SDK's `ToolName` vocabulary +* (node_modules/@cursor/sdk/dist/esm/options.d.ts). `Agent.create` throws a +* ConfigurationError for a name it does not know, so only use names from that +* literal list. +*/ +const READ_ONLY_TOOLS = [ + "read", + "grep", + "glob", + "ls", + "semSearch", + "readLints", + "webSearch", + "webFetch", + "readTodos", + "updateTodos" +]; +/** File edits stay available; commands and subagents (which carry their own shell) do not. */ +const READ_WRITE_DISALLOWED_TOOLS = ["shell", "task"]; +const toolRestrictionsFor = (permission) => { + switch (permission) { + case "read-only": return { tools: [...READ_ONLY_TOOLS] }; + case "read-write": return { disallowedTools: [...READ_WRITE_DISALLOWED_TOOLS] }; + case "full": return {}; + default: throw new Error(`Unhandled permissions value: ${String(permission)}`); + } +}; +//#endregion //#region src/runner.ts /** Longest delay Node's timers accept; larger values would fire immediately. */ const MAX_TIMER_MS = 2147483647; @@ -178,7 +208,7 @@ const runAgent = async (inputs, options = {}) => { const disposeTimeoutMs = options.disposeTimeoutMs ?? DEFAULT_DISPOSE_TIMEOUT_MS; info(`Running Cursor Agent in: ${cwd}`); info(`Model: ${inputs.model}`); - if (inputs.permissions !== "read-only") warning("The `permissions` input is not passed to Cursor SDK Agent.create; tool access follows your API key / account, not this field."); + info(`Permissions: ${inputs.permissions}`); let stdout = ""; let stderr = ""; let exitCode = 0; @@ -190,7 +220,8 @@ const runAgent = async (inputs, options = {}) => { agent = await Agent.create({ apiKey: inputs.apiKey, local: { cwd }, - model: { id: inputs.model } + model: { id: inputs.model }, + ...toolRestrictionsFor(inputs.permissions) }); const run = await agent.send(inputs.prompt); const { result: runResult, timedOut } = await settleRun(run, (async () => { diff --git a/docs/content/behaviour.mdx b/docs/content/behaviour.mdx index a0abcbc..26df2ab 100644 --- a/docs/content/behaviour.mdx +++ b/docs/content/behaviour.mdx @@ -26,6 +26,16 @@ The action is a composite action: it sets up Node.js 24, runs `npm ci --omit=dev `dist/` is committed and holds only this repository's own code, a few kB. +## Permissions + +The `permissions` input is passed to the SDK as a tool allow or deny list. It restricts the main agent loop; it is not a sandbox. + +| `permissions` | Tools offered | +| --- | --- | +| `read-only` | `read`, `grep`, `glob`, `ls`, `semSearch`, `readLints`, `webSearch`, `webFetch`, `readTodos`, `updateTodos` | +| `read-write` | The default toolset without `shell` and `task` (no commands, no subagents) | +| `full` | The SDK's default toolset | + ## Treat the summary as untrusted `summary` is model output. Interpolating it into a `run:` script or a `github-script` body splices the text into the script before it executes. Pass it through `env:` instead. Every example on [Examples](/examples) does. diff --git a/docs/content/examples.mdx b/docs/content/examples.mdx index c31d5cd..13d58a4 100644 --- a/docs/content/examples.mdx +++ b/docs/content/examples.mdx @@ -22,7 +22,7 @@ jobs: - name: Run Cursor Agent id: review - uses: PunGrumpy/cursor-action@v1 + uses: PunGrumpy/cursor-action@v2 with: api-key: ${{ secrets.CURSOR_API_KEY }} prompt: | @@ -49,7 +49,7 @@ Pull requests from forks get a read-only `GITHUB_TOKEN`, so the comment step fai ## Pick a model and a working directory ```yaml -- uses: PunGrumpy/cursor-action@v1 +- uses: PunGrumpy/cursor-action@v2 with: api-key: ${{ secrets.CURSOR_API_KEY }} prompt: "Summarize the TypeScript errors you can find." @@ -65,7 +65,7 @@ Pull requests from forks get a read-only `GITHUB_TOKEN`, so the comment step fai ```yaml - name: Run Cursor Agent id: audit - uses: PunGrumpy/cursor-action@v1 + uses: PunGrumpy/cursor-action@v2 with: api-key: ${{ secrets.CURSOR_API_KEY }} prompt: | diff --git a/docs/content/quickstart.mdx b/docs/content/quickstart.mdx index 7ad1018..57847f5 100644 --- a/docs/content/quickstart.mdx +++ b/docs/content/quickstart.mdx @@ -12,7 +12,7 @@ Add a repository secret named `CURSOR_API_KEY`, then use the action: ```yaml - name: Run Cursor Agent id: cursor - uses: PunGrumpy/cursor-action@v1 + uses: PunGrumpy/cursor-action@v2 with: api-key: ${{ secrets.CURSOR_API_KEY }} prompt: "Review this PR for security issues and summarize your findings." @@ -25,8 +25,8 @@ Add a repository secret named `CURSOR_API_KEY`, then use the action: The action runs on `ubuntu-latest`, `windows-latest`, and `macos-latest`. -## What it does not do yet +## Permissions -The `permissions` input does not restrict the agent today. Passing `read-only` will not stop it from editing files or running shell commands. Tool restrictions connect to the SDK in v2. +`permissions` defaults to `read-only`, which lets the agent read and search the checkout and fetch the web, but not edit files, run commands, or spawn subagents. See [Behaviour](/behaviour) for the full mapping. Read [Behaviour](/behaviour) before wiring this into anything that gates a merge. diff --git a/docs/content/reference.mdx b/docs/content/reference.mdx index ce3661e..c104247 100644 --- a/docs/content/reference.mdx +++ b/docs/content/reference.mdx @@ -16,7 +16,7 @@ The tables below are generated from `action.yml` by `bun run docs:reference`, an | `prompt` | ✅ | — | Prompt to pass to cursor-agent | | `model` | ❌ | `default` | Model id for the agent (e.g. default, composer-2). The Cursor SDK does not accept auto. | | `working-directory` | ❌ | `.` | Working directory for the agent to operate in | -| `permissions` | ❌ | `read-only` | Validated but NOT enforced: 'read-only' does not stop the agent from editing files or running shell commands. Wired to the SDK in v2. | +| `permissions` | ❌ | `read-only` | Built-in tools the agent may use: read-only (read, search, and web tools), read-write (adds file edits; no shell or subagents), or full (the SDK's default toolset). | | `timeout` | ❌ | `300` | Timeout in seconds for the agent run | ### Outputs @@ -33,17 +33,15 @@ The tables below are generated from `action.yml` by `bun run docs:reference`, an {/* reference:end */} -## Inputs that do nothing +## Notes on inputs ### `permissions` -Validated against `read-only`, `read-write` and `full`, then discarded. It is never passed to the SDK, so tool access follows whatever your API key and account allow. `read-only` is not a sandbox and does not prevent file edits or shell commands. - -v2 wires it to the SDK's `disallowedTools`, renamed to `agent-permissions` to stop it colliding with the workflow-level `permissions:` key. +Mapped to the SDK's tool allow and deny lists: `read-only` offers the read, search, and web tools only; `read-write` removes `shell` and `task` from the default toolset; `full` leaves the SDK default. It restricts the main agent loop and is not a sandbox. The full table is on [Behaviour](/behaviour). ### `cursor-version` -Ignored since v1.0.0. The SDK resolves the agent version itself, and pinning a Cursor build is no longer possible from this action. Passing anything other than `latest` emits a warning. It is removed in v2. +Ignored since v1.0.0. The SDK resolves the agent version itself, and pinning a Cursor build is no longer possible from this action. Passing anything other than `latest` emits a warning. The input is kept so existing workflows keep validating. ## Outputs diff --git a/src/permissions.ts b/src/permissions.ts new file mode 100644 index 0000000..47efe4b --- /dev/null +++ b/src/permissions.ts @@ -0,0 +1,47 @@ +import type { AgentOptions } from "@cursor/sdk"; + +import type { Permission } from "./types"; + +export type ToolRestrictions = Pick; + +/** + * Tool names come from the SDK's `ToolName` vocabulary + * (node_modules/@cursor/sdk/dist/esm/options.d.ts). `Agent.create` throws a + * ConfigurationError for a name it does not know, so only use names from that + * literal list. + */ +const READ_ONLY_TOOLS = [ + "read", + "grep", + "glob", + "ls", + "semSearch", + "readLints", + "webSearch", + "webFetch", + "readTodos", + "updateTodos", +] as const; + +/** File edits stay available; commands and subagents (which carry their own shell) do not. */ +const READ_WRITE_DISALLOWED_TOOLS = ["shell", "task"] as const; + +export const toolRestrictionsFor = ( + permission: Permission +): ToolRestrictions => { + switch (permission) { + case "read-only": { + return { tools: [...READ_ONLY_TOOLS] }; + } + case "read-write": { + return { disallowedTools: [...READ_WRITE_DISALLOWED_TOOLS] }; + } + case "full": { + return {}; + } + default: { + const unhandled: never = permission; + throw new Error(`Unhandled permissions value: ${String(unhandled)}`); + } + } +}; diff --git a/src/runner.ts b/src/runner.ts index f17d6bf..a97cc41 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -5,6 +5,7 @@ import { info, warning } from "@actions/core"; import { Agent } from "@cursor/sdk"; import type { Run, RunResult, SDKAgent, SDKMessage } from "@cursor/sdk"; +import { toolRestrictionsFor } from "./permissions"; import type { ActionInputs, AgentResult, TokenUsageStats } from "./types"; /** Longest delay Node's timers accept; larger values would fire immediately. */ @@ -159,12 +160,7 @@ export const runAgent = async ( info(`Running Cursor Agent in: ${cwd}`); info(`Model: ${inputs.model}`); - if (inputs.permissions !== "read-only") { - warning( - "The `permissions` input is not passed to Cursor SDK Agent.create; " + - "tool access follows your API key / account, not this field." - ); - } + info(`Permissions: ${inputs.permissions}`); let stdout = ""; let stderr = ""; @@ -179,6 +175,7 @@ export const runAgent = async ( apiKey: inputs.apiKey, local: { cwd }, model: { id: inputs.model }, + ...toolRestrictionsFor(inputs.permissions), }); const run = await agent.send(inputs.prompt);