From 893dcfe83153db1c5617df642c0a659ea459f192 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:59:05 +0700 Subject: [PATCH 1/2] feat(runner)!: enforce the permissions input via SDK tool restrictions The permissions input was validated and then discarded, so read-only never stopped the agent from editing files or running commands. Map each level to the SDK's tools and disallowedTools options so the value does what its name says. This is a breaking change for workflows that relied on read-only not restricting anything. They now need permissions: full to keep the old behaviour. --- .changeset/enforce-permissions.md | 5 ++++ __tests__/permissions.test.ts | 36 +++++++++++++++++++++++ __tests__/runner.test.ts | 12 ++++++-- dist/index.mjs | 35 +++++++++++++++++++++-- src/permissions.ts | 47 +++++++++++++++++++++++++++++++ src/runner.ts | 9 ++---- 6 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 .changeset/enforce-permissions.md create mode 100644 __tests__/permissions.test.ts create mode 100644 src/permissions.ts 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/__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/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/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); From a58b65012c865545ccc8508734199ba508fff45a Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:59:48 +0700 Subject: [PATCH 2/2] docs: describe the permissions mapping and point consumers at v2 Replace the not-enforced warnings with the tool mapping for each permissions level, regenerate the input reference from action.yml, and switch the usage examples to the v2 tag. --- README.md | 12 ++++-------- action.yml | 2 +- docs/content/behaviour.mdx | 10 ++++++++++ docs/content/examples.mdx | 6 +++--- docs/content/quickstart.mdx | 6 +++--- docs/content/reference.mdx | 10 ++++------ 6 files changed, 25 insertions(+), 21 deletions(-) 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/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/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