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
5 changes: 5 additions & 0 deletions .changeset/enforce-permissions.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 4 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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
Expand All @@ -56,10 +56,6 @@ The action runs on `ubuntu-latest`, `windows-latest`, and `macos-latest`.

<!-- reference:end -->

> [!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.<id>.outputs.summary }}` directly into a `run:` script or a `github-script` body — interpolation splices the text into the script before it executes.
Expand Down Expand Up @@ -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

Expand Down
36 changes: 36 additions & 0 deletions __tests__/permissions.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
12 changes: 10 additions & 2 deletions __tests__/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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")
);
});
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
35 changes: 33 additions & 2 deletions dist/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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 () => {
Expand Down
10 changes: 10 additions & 0 deletions docs/content/behaviour.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions docs/content/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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."
Expand All @@ -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: |
Expand Down
6 changes: 3 additions & 3 deletions docs/content/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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.
10 changes: 4 additions & 6 deletions docs/content/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
47 changes: 47 additions & 0 deletions src/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { AgentOptions } from "@cursor/sdk";

import type { Permission } from "./types";

export type ToolRestrictions = Pick<AgentOptions, "disallowedTools" | "tools">;

/**
* 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)}`);
}
}
};
9 changes: 3 additions & 6 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 = "";
Expand All @@ -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);
Expand Down
Loading