@ory/cursor 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -104,17 +104,36 @@ Then a denied tool is actually blocked (`preToolUse` returns `permission: "deny"
104
104
  | Cursor hook | What the plugin does |
105
105
  |---|---|
106
106
  | `sessionStart` | User + agent auth gates, user→agent delegation (never blocks) |
107
- | `preToolUse` | The permission gate — blocks with `permission: "deny"` in enforce mode |
107
+ | `preToolUse` | The permission gate for native tools — blocks with `permission: "deny"` in enforce mode |
108
+ | `beforeMCPExecution` | The permission gate for MCP tools — checks `McpServer` and `McpTool` and blocks with `permission: "deny"` in enforce mode |
108
109
  | `postToolUse` / `postToolUseFailure` | Record `tool.complete` / `tool.fail` activity |
109
110
  | `beforeSubmitPrompt` | Record `user.prompt` activity (never blocks the human) |
110
111
  | `subagentStart` / `subagentStop` | Spawn-scoped child Talos identity + delegation activity |
111
112
 
112
- The fine-grained `beforeShellExecution` / `beforeMCPExecution` / `beforeReadFile`
113
- hooks are **not** registered: `preToolUse` already gates those categories, so
114
- registering them would only spawn extra hook subprocesses without adding any
115
- enforcement. (When they do fire, the handler records activity only.)
113
+ The fine-grained `beforeShellExecution` / `beforeReadFile` hooks are **not**
114
+ registered: `preToolUse` already gates those categories, so registering them
115
+ would only spawn extra hook subprocesses without adding any enforcement. (When
116
+ they do fire, the handler records activity only.)
116
117
 
117
- The `preToolUse` gate is registered fail-closed: if the hook can't produce a verdict, Cursor blocks the tool (exit code 2), so a crashed or timed-out gate can't silently defeat enforce mode. The audit/identity hooks stay fail-open — none of them gate a tool, and session start must never block.
118
+ ### MCP servers
119
+
120
+ `preToolUse` names an MCP tool `MCP:<tool>` without its server, so the plugin
121
+ gates MCP calls in `beforeMCPExecution`, which [Cursor documents](https://cursor.com/docs/hooks)
122
+ with the server's `mcp.json` key (`mcp_server_name`), the server's own
123
+ `tool_name`, and its stdio launch `command` or HTTP/SSE `url`. Each call checks
124
+ `McpServer:<server>` and `McpTool:<server>:<tool>`; Agent Security resolves the
125
+ reported key to a cataloged server, helped by hashed fingerprints of the
126
+ launched package, container image, or URL host (raw commands, arguments, and
127
+ URLs never leave the machine). `preToolUse` still checks `MCP:<tool>` as a
128
+ native tool, as it did before MCP gating, but records no activity, so one call
129
+ is counted once.
130
+
131
+ Cursor documents that `beforeMCPExecution` does not run in cloud agents, and
132
+ that cloud agents load only project hooks, not `~/.cursor/hooks.json`. With a
133
+ `--project` install, MCP calls a cloud agent makes get only that native
134
+ `MCP:<tool>` check, so per-server MCP levels do not apply to them.
135
+
136
+ Both gates are registered fail-closed: if the hook can't produce a verdict, Cursor blocks the tool (exit code 2), so a crashed or timed-out gate can't silently defeat enforce mode. The audit/identity hooks stay fail-open — none of them gate a tool, and session start must never block. Re-run `install` after upgrading to register `beforeMCPExecution`.
118
137
 
119
138
  ## Connect to Ory Agent Security
120
139
 
@@ -136,7 +155,7 @@ npx -y -p @ory/cursor ory-cursor configure \
136
155
 
137
156
  **Ory Network or OEL.** Either works. For an Ory Network project the URL is `https://<slug>.projects.oryapis.com`; for a self-hosted **Ory Enterprise License** deployment, point `--project-url` at that deployment's base URL and use the sign-in client id from its Agent Security configuration. Everything downstream — sign-in, checks, delegation — is identical.
138
157
 
139
- **There is nothing else for you to create.** The sign-in client, permission model, native-tool blocks, MCP grants, and observe/enforce posture are provisioned in the Console by someone with project access. At session start the plugin loads or enrolls Cursor's Talos runtime credential in the OS credential store; tool paths only read it. Installing needs no workspace privilege.
158
+ **There is nothing else for you to create.** The sign-in client, permission model, native-tool blocks, MCP server access, and observe/enforce posture are provisioned in the Console by someone with project access. At session start the plugin loads or enrolls Cursor's Talos runtime credential in the OS credential store; tool paths only read it. Installing needs no workspace privilege.
140
159
 
141
160
  Sign-in runs at the start of every session and never blocks — a declined, skipped, or timed-out login simply leaves that session without a user identity.
142
161
 
package/dist/cli/setup.js CHANGED
@@ -93,7 +93,7 @@ function writeJson(file, value) {
93
93
  /**
94
94
  * Resolve this install's runtime and write its shims.
95
95
  *
96
- * Cursor's `preToolUse` gate is registered `failClosed: true` (see
96
+ * Cursor's `preToolUse` and `beforeMCPExecution` gates are registered `failClosed: true` (see
97
97
  * {@link oryHooksBlock}), so a hook that cannot answer blocks every tool. That
98
98
  * makes the shim's runtime-missing path load-bearing: it has to emit an
99
99
  * explicit allow, not just exit 0.
@@ -110,21 +110,24 @@ function wireCursorRuntime(args) {
110
110
  }
111
111
  /**
112
112
  * Build the Ory hooks block. Only the load-bearing events are registered:
113
- * `preToolUse` is the permission gate; the rest are audit/identity. The
114
- * fine-grained shell/MCP/file hooks are intentionally omitted — `preToolUse`
115
- * already covers those categories, and registering them would spawn a hook
116
- * subprocess per file read / shell line for no added enforcement.
113
+ * `preToolUse` is the permission gate for native tools and `beforeMCPExecution`
114
+ * the gate for MCP tools; the rest are audit/identity. `beforeMCPExecution` is
115
+ * the only hook that reports an MCP call's server (its `mcp.json` key and launch
116
+ * command or URL) — `preToolUse` sees just `MCP:<tool>` and defers those calls
117
+ * to it. The other fine-grained shell/file hooks are intentionally omitted —
118
+ * `preToolUse` already covers those categories, and registering them would
119
+ * spawn a hook subprocess per file read / shell line for no added enforcement.
117
120
  *
118
- * The `preToolUse` gate is registered `failClosed: true`. Cursor's hook default
119
- * is fail-open: a crashed / timed-out / malformed-JSON hook lets the tool
120
- * through. For the one hook that makes the permission decision that would
121
- * silently defeat `enforce` mode (the whole point of the gate), so we opt it
122
- * into fail-closed — if the gate can't produce a verdict, Cursor blocks the
123
- * tool. The gate still fails *open* on the paths Ory treats as non-blocking
124
- * (network / rate-limit), because those are handled inside the hook, which
125
- * exits 0 with an allow verdict; `failClosed` only governs the hook process
126
- * itself failing to answer. The audit/identity hooks stay fail-open — none of
127
- * them gate a tool, and session start must never block.
121
+ * Both gates are registered `failClosed: true`. Cursor's hook default is
122
+ * fail-open: a crashed / timed-out / malformed-JSON hook lets the tool through.
123
+ * For a hook that makes the permission decision that would silently defeat
124
+ * `enforce` mode (the whole point of the gate), so we opt it into fail-closed —
125
+ * if the gate can't produce a verdict, Cursor blocks the tool. The gate still
126
+ * fails *open* on the paths Ory treats as non-blocking (network / rate-limit),
127
+ * because those are handled inside the hook, which exits 0 with an allow
128
+ * verdict; `failClosed` only governs the hook process itself failing to answer.
129
+ * The audit/identity hooks stay fail-open — none of them gate a tool, and
130
+ * session start must never block.
128
131
  */
129
132
  function oryHooksBlock(command) {
130
133
  const entry = (timeout, failClosed = false) => [
@@ -134,6 +137,7 @@ function oryHooksBlock(command) {
134
137
  sessionStart: entry(SESSION_START_TIMEOUT_SEC),
135
138
  sessionEnd: entry(TOOL_HOOK_TIMEOUT_SEC),
136
139
  preToolUse: entry(TOOL_HOOK_TIMEOUT_SEC, true),
140
+ beforeMCPExecution: entry(TOOL_HOOK_TIMEOUT_SEC, true),
137
141
  postToolUse: entry(TOOL_HOOK_TIMEOUT_SEC),
138
142
  postToolUseFailure: entry(TOOL_HOOK_TIMEOUT_SEC),
139
143
  beforeSubmitPrompt: entry(TOOL_HOOK_TIMEOUT_SEC),
package/dist/handlers.js CHANGED
@@ -52,16 +52,19 @@ async function handleHookEvent(input, client, deps = {}) {
52
52
  return await handleAudit(input, client, "compaction", {
53
53
  trigger: input.trigger,
54
54
  });
55
- // Fine-grained shell / MCP / file hooks surface activity to the user and
56
- // are activity-only — `preToolUse` already ran the permission gate for the
57
- // corresponding tool category, so gating here would double-count.
55
+ // MCP calls are gated here, not in `preToolUse`: only this hook carries
56
+ // the server's `mcp.json` key and how the server is launched or reached.
57
+ case "beforeMCPExecution":
58
+ return await handleBeforeMcpExecution(input, client);
59
+ // The remaining fine-grained hooks are activity-only — `preToolUse`
60
+ // already ran the permission gate for the corresponding tool category,
61
+ // so gating here would double-count.
58
62
  case "beforeShellExecution":
59
63
  case "afterShellExecution":
60
64
  return await handleAudit(input, client, "user.interaction", {
61
65
  kind: event,
62
66
  hasCommand: input.command !== undefined,
63
67
  });
64
- case "beforeMCPExecution":
65
68
  case "afterMCPExecution":
66
69
  return await handleAudit(input, client, "user.interaction", {
67
70
  kind: event,
@@ -106,10 +109,34 @@ async function handlePreToolUse(input, client) {
106
109
  toolName,
107
110
  toolInput: input.tool_input,
108
111
  });
112
+ // `preToolUse` names an MCP tool `MCP:<tool>` with no server. Locally the
113
+ // install's `beforeMCPExecution` fires for the same call with the server's
114
+ // `mcp.json` key and owns the MCP decision and its activity. Cloud agents do
115
+ // not run `beforeMCPExecution`, so the native check still applies here, as
116
+ // it did before MCP gating existed, but without recording the call twice.
117
+ if (isMcpPreToolUse(toolName)) {
118
+ const session = sessionKey(input);
119
+ const result = await (0, argus_1.decideTool)(client, {
120
+ harness: HARNESS,
121
+ toolName,
122
+ toolArgs: input.tool_input,
123
+ subjectFallback: session ? `session:${session}` : undefined,
124
+ principals: subAgentPrincipals(input),
125
+ });
126
+ if (result.blocked) {
127
+ const reason = result.denialMessage;
128
+ return { permission: "deny", agent_message: reason, user_message: reason };
129
+ }
130
+ return {};
131
+ }
109
132
  // Only offer a `session:` fallback subject when Cursor actually sent a
110
133
  // session — interpolating an absent one yields the literal
111
134
  // `session:undefined`, a subject that matches nothing yet looks like a real
112
135
  // one in the audit trail.
136
+ return gateCursorTool(input, client, toolName, input.tool_input);
137
+ }
138
+ /** The acting sub-agent's principals, when Cursor identifies one. */
139
+ function subAgentPrincipals(input) {
113
140
  const session = sessionKey(input);
114
141
  const subAgentType = input.subagent_type;
115
142
  const subAgent = subAgentType
@@ -117,19 +144,20 @@ async function handlePreToolUse(input, client) {
117
144
  ? (0, argus_1.loadSubAgentDynamicCredentials)(HARNESS, session, subAgentType)
118
145
  : (0, argus_1.loadSubAgentDynamicCredentials)(HARNESS, subAgentType)
119
146
  : undefined;
147
+ return subAgent
148
+ ? { subAgentClientId: subAgent.clientId, subAgentType, perSpawnId: input.subagent_id, sessionId: session }
149
+ : undefined;
150
+ }
151
+ /** Gate one tool call, as the acting sub-agent when Cursor identifies it. */
152
+ async function gateCursorTool(input, client, toolName, toolArgs, mcpTool) {
153
+ const session = sessionKey(input);
120
154
  const result = await (0, argus_1.gate)(client, {
121
155
  harness: HARNESS,
122
156
  toolName,
123
- toolArgs: input.tool_input,
157
+ toolArgs,
124
158
  subjectFallback: session ? `session:${session}` : undefined,
125
- principals: subAgent
126
- ? {
127
- subAgentClientId: subAgent.clientId,
128
- subAgentType,
129
- perSpawnId: input.subagent_id,
130
- sessionId: session,
131
- }
132
- : undefined,
159
+ ...(mcpTool ? { mcpTool } : {}),
160
+ principals: subAgentPrincipals(input),
133
161
  });
134
162
  if (result.blocked) {
135
163
  const reason = result.denialMessage;
@@ -137,6 +165,50 @@ async function handlePreToolUse(input, client) {
137
165
  }
138
166
  return {};
139
167
  }
168
+ // ─── beforeMCPExecution — the MCP permission gate ───────────────────
169
+ /** Cursor's `preToolUse` / `postToolUse` name for an MCP tool: `MCP:<tool>`. */
170
+ const MCP_TOOL_PREFIX = "MCP:";
171
+ function isMcpPreToolUse(toolName) {
172
+ return toolName.startsWith(MCP_TOOL_PREFIX);
173
+ }
174
+ /**
175
+ * Gate an MCP call with the server Cursor reports: `mcp_server_name` is the
176
+ * server's `mcp.json` key and `tool_name` the server's own tool name. The
177
+ * launch command (stdio) or URL (HTTP/SSE) becomes hashed fingerprints, so a
178
+ * cataloged server is recognized whatever the user named it locally.
179
+ */
180
+ async function handleBeforeMcpExecution(input, client) {
181
+ const serverName = input.mcp_server_name?.trim() ?? "";
182
+ const mcpToolName = input.tool_name ?? "";
183
+ client.logger.info("lifecycle.before_mcp_execution", {
184
+ sessionId: sessionKey(input),
185
+ mcpServer: serverName,
186
+ mcpTool: mcpToolName,
187
+ });
188
+ const fingerprints = (0, argus_1.mcpServerFingerprints)({
189
+ url: input.url ?? input.mcp_server_url,
190
+ command: input.command,
191
+ });
192
+ // A missing server key still reaches Agent Security, which treats an
193
+ // unrecognized server as uncatalogued rather than letting it pass unchecked.
194
+ const mcpTool = {
195
+ serverName: serverName || "unknown",
196
+ toolName: mcpToolName,
197
+ ...(fingerprints.length > 0 ? { fingerprints } : {}),
198
+ };
199
+ return gateCursorTool(input, client, `${MCP_TOOL_PREFIX}${mcpToolName || "unknown"}`, parseToolInput(input.tool_input), mcpTool);
200
+ }
201
+ /** `beforeMCPExecution` sends the arguments as a JSON string. */
202
+ function parseToolInput(toolInput) {
203
+ if (typeof toolInput !== "string")
204
+ return toolInput;
205
+ try {
206
+ return JSON.parse(toolInput);
207
+ }
208
+ catch {
209
+ return undefined;
210
+ }
211
+ }
140
212
  // ─── postToolUse ────────────────────────────────────────────────────
141
213
  async function handlePostToolUse(input, client) {
142
214
  const toolName = input.tool_name ?? "unknown";
package/dist/types.d.ts CHANGED
@@ -30,7 +30,11 @@ export interface CursorHookInput {
30
30
  reason?: string;
31
31
  duration_ms?: number;
32
32
  tool_name?: string;
33
- tool_input?: Record<string, unknown>;
33
+ /**
34
+ * The tool arguments. An object on `preToolUse`; a JSON-encoded string on
35
+ * `beforeMCPExecution`.
36
+ */
37
+ tool_input?: Record<string, unknown> | string;
34
38
  /** postToolUse — JSON-stringified tool output. */
35
39
  tool_output?: string;
36
40
  tool_use_id?: string;
@@ -41,8 +45,12 @@ export interface CursorHookInput {
41
45
  command?: string;
42
46
  sandbox?: boolean;
43
47
  output?: string;
44
- /** MCP url (remote) — present for MCP hooks. */
48
+ /** The server's key in `mcp.json` (for example `linear`). */
49
+ mcp_server_name?: string;
50
+ /** Remote server URL — present only for HTTP/SSE servers. */
45
51
  url?: string;
52
+ /** Same as `url`; present only for HTTP/SSE servers. */
53
+ mcp_server_url?: string;
46
54
  file_path?: string;
47
55
  edits?: Array<{
48
56
  old_string?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ory/cursor",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Ory plugin for Cursor: scaffolding rules, a local Ory instance, and authentication, authorization, and audit for every tool call",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/ory/ory-agent-plugins/tree/main/packages/cursor",
@@ -57,10 +57,10 @@
57
57
  ],
58
58
  "dependencies": {
59
59
  "reova": "^0.7.0",
60
- "@ory/argus": "1.5.2"
60
+ "@ory/argus": "1.6.0"
61
61
  },
62
62
  "devDependencies": {
63
- "typescript": "^6.0.2"
63
+ "typescript": "^7.0.2"
64
64
  },
65
65
  "engines": {
66
66
  "node": ">=22"