@ory/argus 1.5.3 → 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/dist/mcp.js CHANGED
@@ -4,15 +4,25 @@
4
4
  *
5
5
  * Provides types, parsers, and permission check helpers for securing
6
6
  * MCP tool calls across all harness plugins. Each harness represents
7
- * MCP tools differently — parsers normalize them into McpToolIdentifier,
8
- * and checkMcpPermission runs server-level (and optionally tool-level)
9
- * Ory permission checks.
7
+ * MCP tools differently — parsers normalize them into McpToolIdentifier.
8
+ * Adapters pass that identifier to `gate`, which checks the McpServer and
9
+ * McpTool namespaces with the same block and mode semantics as native tools.
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.parseClaudeCodeMcpTool = parseClaudeCodeMcpTool;
13
13
  exports.parseGeminiMcpTool = parseGeminiMcpTool;
14
+ exports.parseMcpToolWithSeparator = parseMcpToolWithSeparator;
14
15
  exports.parseMcpToolGeneric = parseMcpToolGeneric;
16
+ exports.mcpFingerprint = mcpFingerprint;
17
+ exports.mcpServerFingerprints = mcpServerFingerprints;
18
+ exports.mcpServerHint = mcpServerHint;
19
+ exports.normalizeMcpServerName = normalizeMcpServerName;
20
+ exports.mcpToolObject = mcpToolObject;
21
+ exports.parseMcpToolName = parseMcpToolName;
22
+ exports.parseMcpToolArgs = parseMcpToolArgs;
15
23
  exports.checkMcpPermission = checkMcpPermission;
24
+ const node_crypto_1 = require("node:crypto");
25
+ const opl_js_1 = require("./opl.js");
16
26
  const read_credential_js_1 = require("./read-credential.js");
17
27
  // ─── Parsers ───────────────────────────────────────────────────────
18
28
  /**
@@ -37,16 +47,53 @@ function parseClaudeCodeMcpTool(toolName) {
37
47
  }
38
48
  /**
39
49
  * Parse MCP context from Gemini CLI hook input.
40
- * Gemini provides server info via the `mcp_context` field.
50
+ *
51
+ * Gemini provides server info via the `mcp_context` field: the configured
52
+ * `server_name`, the server's own `tool_name` (the hook `tool_name` is the
53
+ * prefixed `mcp_<server>_<tool>` form), and how the server is launched or
54
+ * reached (`command`, `args`, `url`).
41
55
  */
42
56
  function parseGeminiMcpTool(toolName, mcpContext) {
43
57
  if (!mcpContext || typeof mcpContext.server_name !== "string")
44
58
  return null;
59
+ const fingerprints = mcpServerFingerprints({
60
+ url: typeof mcpContext.url === "string" ? mcpContext.url : undefined,
61
+ command: typeof mcpContext.command === "string" ? mcpContext.command : undefined,
62
+ args: Array.isArray(mcpContext.args)
63
+ ? mcpContext.args.filter((arg) => typeof arg === "string")
64
+ : undefined,
65
+ });
45
66
  return {
46
67
  serverName: mcpContext.server_name,
47
- toolName,
68
+ toolName: typeof mcpContext.tool_name === "string" && mcpContext.tool_name !== "" ? mcpContext.tool_name : toolName,
69
+ ...(fingerprints.length > 0 ? { fingerprints } : {}),
48
70
  };
49
71
  }
72
+ /**
73
+ * Parse a `<server><separator><tool>` tool name, as Goose (`__`), OpenClaw
74
+ * (`__`), Cline (`__`), and OpenCode (`_`) build them. With `servers`, the
75
+ * longest configured server name that prefixes the tool name wins, so server
76
+ * names containing the separator split correctly and native tools without a
77
+ * server prefix return null. Without `servers`, the first separator splits.
78
+ */
79
+ function parseMcpToolWithSeparator(toolName, separator, servers) {
80
+ if (servers) {
81
+ let match;
82
+ for (const server of servers) {
83
+ if (server !== "" &&
84
+ toolName.length > server.length + separator.length &&
85
+ toolName.startsWith(server + separator) &&
86
+ server.length > (match?.length ?? -1)) {
87
+ match = server;
88
+ }
89
+ }
90
+ return match === undefined ? null : { serverName: match, toolName: toolName.slice(match.length + separator.length) };
91
+ }
92
+ const index = toolName.indexOf(separator);
93
+ if (index <= 0 || index + separator.length >= toolName.length)
94
+ return null;
95
+ return { serverName: toolName.slice(0, index), toolName: toolName.slice(index + separator.length) };
96
+ }
50
97
  /**
51
98
  * Generic MCP tool parser for harnesses that use a `mcp:<server>:<tool>`
52
99
  * convention. Returns null if the tool ID doesn't start with the prefix.
@@ -56,6 +103,8 @@ function parseMcpToolGeneric(toolId, mcpPrefix = "mcp:") {
56
103
  return null;
57
104
  const rest = toolId.slice(mcpPrefix.length);
58
105
  const sepIdx = rest.indexOf(":");
106
+ if (sepIdx === 0 || rest === "")
107
+ return null;
59
108
  if (sepIdx === -1) {
60
109
  return { serverName: rest, toolName: "" };
61
110
  }
@@ -64,40 +113,224 @@ function parseMcpToolGeneric(toolId, mcpPrefix = "mcp:") {
64
113
  toolName: rest.slice(sepIdx + 1),
65
114
  };
66
115
  }
116
+ // ─── Fingerprints ──────────────────────────────────────────────────
117
+ const MAX_MCP_FINGERPRINTS = 4;
118
+ const PACKAGE_RUNNERS = new Set(["npx", "bunx", "uvx", "pipx", "deno"]);
119
+ const PACKAGE_RUNNER_SUBCOMMANDS = new Set(["dlx", "exec", "run", "x", "tool"]);
120
+ /** Runners whose package follows one of {@link PACKAGE_RUNNER_SUBCOMMANDS} (`deno run npm:@scope/mcp`). */
121
+ const SUBCOMMAND_RUNNERS = new Set(["pnpm", "yarn", "bun", "uv", "pipx", "deno"]);
122
+ const CONTAINER_RUNNERS = new Set(["docker", "podman"]);
123
+ /** Container global flags, before the subcommand, that take a separate value argument. */
124
+ const CONTAINER_GLOBAL_VALUE_FLAGS = new Set([
125
+ "-H", "--host", "-c", "--context", "--config", "-l", "--log-level", "--tlscacert", "--tlscert", "--tlskey",
126
+ "--connection", "--url", "--identity", "--root", "--runroot", "--storage-driver", "--cgroup-manager",
127
+ ]);
128
+ /** Container `run` flags that take a separate value argument. Unlisted flags are treated as boolean. */
129
+ const CONTAINER_VALUE_FLAGS = new Set([
130
+ "-a", "--attach", "--annotation", "--add-host", "--blkio-weight", "--blkio-weight-device", "--cap-add", "--cap-drop",
131
+ "--cgroup-parent", "--cgroupns", "--cidfile", "-c", "--cpu-shares", "--cpu-period", "--cpu-quota", "--cpu-rt-period",
132
+ "--cpu-rt-runtime", "--cpus", "--cpuset-cpus", "--cpuset-mems", "--detach-keys", "--device", "--device-cgroup-rule",
133
+ "--device-read-bps", "--device-read-iops", "--device-write-bps", "--device-write-iops", "--dns", "--dns-option",
134
+ "--dns-search", "--domainname", "-e", "--env", "--env-file", "--entrypoint", "--expose", "--gpus", "--group-add",
135
+ "--health-cmd", "--health-interval", "--health-retries", "--health-start-interval", "--health-start-period",
136
+ "--health-timeout", "-h", "--hostname", "--ip", "--ip6", "--ipc", "--isolation", "--kernel-memory", "-l", "--label",
137
+ "--label-file", "--link", "--link-local-ip", "--log-driver", "--log-opt", "--mac-address", "-m", "--memory",
138
+ "--memory-reservation", "--memory-swap", "--memory-swappiness", "--mount", "--name", "--network", "--net",
139
+ "--network-alias", "--oom-score-adj", "-p", "--publish", "--pid", "--pids-limit", "--platform", "--pull",
140
+ "--restart", "--runtime", "--security-opt", "--shm-size", "--stop-signal", "--stop-timeout", "--storage-opt",
141
+ "--sysctl", "--tmpfs", "-u", "--user", "--ulimit", "--userns", "--uts", "-v", "--volume", "--volume-driver",
142
+ "--volumes-from", "-w", "--workdir", "--arch", "--os", "--variant", "--secret", "--hooks-dir", "--pod",
143
+ "--uidmap", "--gidmap", "--subuidname", "--subgidname", "--userns-uid-map", "--userns-gid-map",
144
+ ]);
145
+ /**
146
+ * The hash Agent Security matches against cataloged server identifiers: the
147
+ * first 16 hex characters of the SHA-256 of the trimmed, lowercased identifier.
148
+ */
149
+ function mcpFingerprint(identifier) {
150
+ return (0, node_crypto_1.createHash)("sha256").update(identifier.trim().toLowerCase()).digest("hex").slice(0, 16);
151
+ }
152
+ /**
153
+ * Hash the identifiers that recognize an MCP server whatever it is named: the
154
+ * remote URL host, the package a runner launches (`npx -y @playwright/mcp@1`
155
+ * yields `@playwright/mcp`), or the container image (`ghcr.io/github/github-mcp-server`).
156
+ * Only hashes leave the process; paths, arguments, and credentials are never sent.
157
+ */
158
+ function mcpServerFingerprints(source) {
159
+ const identifiers = new Set();
160
+ const host = urlHost(source.url);
161
+ if (host)
162
+ identifiers.add(host);
163
+ const words = [...(source.command ? source.command.trim().split(/\s+/) : []), ...(source.args ?? [])].filter((word) => word !== "");
164
+ const launched = launchedIdentifier(words);
165
+ if (launched)
166
+ identifiers.add(launched);
167
+ return [...identifiers].slice(0, MAX_MCP_FINGERPRINTS).map(mcpFingerprint);
168
+ }
169
+ function urlHost(value) {
170
+ if (!value)
171
+ return undefined;
172
+ try {
173
+ return new URL(value).hostname.toLowerCase() || undefined;
174
+ }
175
+ catch {
176
+ return undefined;
177
+ }
178
+ }
179
+ function launchedIdentifier(words) {
180
+ const [command, ...rest] = words;
181
+ if (!command)
182
+ return undefined;
183
+ const runner = command.split(/[\\/]/).pop().toLowerCase().replace(/\.(exe|cmd)$/, "");
184
+ if (CONTAINER_RUNNERS.has(runner)) {
185
+ const runIndex = containerRunIndex(rest);
186
+ if (runIndex < 0)
187
+ return undefined;
188
+ for (let index = runIndex + 1; index < rest.length; index++) {
189
+ const word = rest[index];
190
+ if (word.startsWith("-")) {
191
+ if (!word.includes("=") && CONTAINER_VALUE_FLAGS.has(word))
192
+ index++;
193
+ continue;
194
+ }
195
+ return containerImage(word);
196
+ }
197
+ return undefined;
198
+ }
199
+ let args = rest;
200
+ if (SUBCOMMAND_RUNNERS.has(runner)) {
201
+ while (args[0] && PACKAGE_RUNNER_SUBCOMMANDS.has(args[0]))
202
+ args = args.slice(1);
203
+ }
204
+ else if (!PACKAGE_RUNNERS.has(runner)) {
205
+ return undefined;
206
+ }
207
+ for (let index = 0; index < args.length; index++) {
208
+ const word = args[index];
209
+ if (word === "-p" || word === "--package" || word === "--from") {
210
+ const value = args[index + 1];
211
+ return value ? packageName(value) : undefined;
212
+ }
213
+ if (word.startsWith("-"))
214
+ continue;
215
+ return packageName(word);
216
+ }
217
+ return undefined;
218
+ }
219
+ /**
220
+ * The index of the `run` subcommand in a container command's arguments, after
221
+ * any global flags (`docker --context remote run …`, `podman container run …`),
222
+ * or -1 when the command runs nothing.
223
+ */
224
+ function containerRunIndex(args) {
225
+ for (let index = 0; index < args.length; index++) {
226
+ const word = args[index];
227
+ if (word.startsWith("-")) {
228
+ if (!word.includes("=") && CONTAINER_GLOBAL_VALUE_FLAGS.has(word))
229
+ index++;
230
+ continue;
231
+ }
232
+ if (word === "container" && args[index + 1] === "run")
233
+ return index + 1;
234
+ return word === "run" ? index : -1;
235
+ }
236
+ return -1;
237
+ }
238
+ function packageName(spec) {
239
+ let name = spec.replace(/^npm:|^jsr:/, "");
240
+ const versionAt = name.lastIndexOf("@");
241
+ if (versionAt > 0)
242
+ name = name.slice(0, versionAt);
243
+ name = name.replace(/\[.*\]$/, "").replace(/[=<>~!].*$/, "");
244
+ if (name === "" || name.includes("://") || name.startsWith(".") || name.startsWith("/"))
245
+ return undefined;
246
+ return name.toLowerCase();
247
+ }
248
+ function containerImage(reference) {
249
+ let image = reference.toLowerCase().split("@")[0];
250
+ const lastSlash = image.lastIndexOf("/");
251
+ const tag = image.indexOf(":", lastSlash + 1);
252
+ if (tag >= 0)
253
+ image = image.slice(0, tag);
254
+ image = image.replace(/^(docker\.io|index\.docker\.io)\//, "").replace(/^library\//, "");
255
+ return image || undefined;
256
+ }
257
+ /** The `mcp` hint Agent Security resolves an MCP check's server from. */
258
+ function mcpServerHint(mcpTool) {
259
+ return {
260
+ server: mcpTool.serverName,
261
+ ...(mcpTool.toolName ? { tool: mcpTool.toolName } : {}),
262
+ ...(mcpTool.fingerprints?.length ? { fingerprints: mcpTool.fingerprints.slice(0, MAX_MCP_FINGERPRINTS) } : {}),
263
+ };
264
+ }
265
+ // ─── Normalization ─────────────────────────────────────────────────
266
+ /**
267
+ * Normalize a configured MCP server name to the reported `McpServer` object:
268
+ * trimmed and lowercased. Agent Security resolves it, with the check's `mcp`
269
+ * hint, to the catalog server it enforces.
270
+ */
271
+ function normalizeMcpServerName(serverName) {
272
+ return serverName.trim().toLowerCase();
273
+ }
274
+ /** The `McpTool` object for one call: `<server>:<tool>`. */
275
+ function mcpToolObject(serverName, toolName) {
276
+ return `${normalizeMcpServerName(serverName)}:${toolName}`;
277
+ }
278
+ /**
279
+ * Parse a harness MCP tool name in either the `mcp__<server>__<tool>` form
280
+ * (Claude Code, Claude Agent SDK, Codex) or the `mcp:<server>:<tool>` form
281
+ * (OpenCode, OpenClaw, and older Codex builds).
282
+ */
283
+ function parseMcpToolName(toolName) {
284
+ return parseClaudeCodeMcpTool(toolName) ?? parseMcpToolGeneric(toolName);
285
+ }
286
+ /**
287
+ * Parse the arguments of a generic "call an MCP tool" harness tool, such as
288
+ * Cline's `use_mcp_tool` (`{ server_name, tool_name, arguments }`). Returns null
289
+ * unless both a server and a tool name are present.
290
+ */
291
+ function parseMcpToolArgs(toolArgs) {
292
+ if (!toolArgs || typeof toolArgs !== "object")
293
+ return null;
294
+ const args = toolArgs;
295
+ const serverName = [args.server_name, args.serverName, args.ServerName].find((value) => typeof value === "string" && value.trim() !== "");
296
+ const toolName = [args.tool_name, args.toolName, args.ToolName].find((value) => typeof value === "string" && value.trim() !== "");
297
+ return serverName && toolName ? { serverName, toolName } : null;
298
+ }
67
299
  // ─── Permission Check ──────────────────────────────────────────────
68
300
  /**
69
- * Check MCP server-level (and optionally tool-level) permissions.
301
+ * Check the user's MCP `use` permits without machine-principal blocks or mode.
70
302
  *
71
- * Server check: namespace=mcp_servers, object=<serverName>, relation=use
72
- * Tool check: namespace=mcp_tools, object=<server>:<tool>, relation=invoke
303
+ * Server check: `McpServer:<server>#use`. Tool check, when the tool name is
304
+ * known and `checkToolLevel` is not false: `McpTool:<server>:<tool>#use`. The
305
+ * server name is normalized with {@link normalizeMcpServerName}.
73
306
  *
74
- * The subject is whatever the caller resolved (SubjectSet or direct ID).
307
+ * Harness adapters should pass `mcpTool` to `gate` / `decideTool` instead, which
308
+ * also applies Agent, Sub-Agent, and Project blocks and the permission mode.
75
309
  * Throws on API errors — callers handle fail-open behavior.
76
310
  */
77
311
  async function checkMcpPermission(client, mcpTool, options) {
78
- const serverNamespace = options.serverNamespace ??
79
- (process.env.ORY_MCP_SERVER_NAMESPACE ?? "mcp_servers");
80
- const toolNamespace = options.toolNamespace ??
81
- (process.env.ORY_MCP_TOOL_NAMESPACE ?? "mcp_tools");
82
- const checkToolLevel = options.checkToolLevel ??
83
- process.env.ORY_MCP_TOOL_LEVEL_CHECKS === "true";
312
+ const serverNamespace = options.serverNamespace ?? opl_js_1.MCP_SERVER_NAMESPACE;
313
+ const toolNamespace = options.toolNamespace ?? opl_js_1.MCP_TOOL_NAMESPACE;
314
+ const checkToolLevel = options.checkToolLevel ?? true;
315
+ const serverName = normalizeMcpServerName(mcpTool.serverName);
84
316
  // MCP tool calls arrive on the same subprocess lifecycle events as any other
85
317
  // tool, so this read needs a credential resolved for exactly the reasons
86
318
  // `gateToolCall` does (#242).
87
319
  await (0, read_credential_js_1.ensureReadCredential)(client);
320
+ const mcp = mcpServerHint(mcpTool);
88
321
  const serverCheck = {
89
322
  namespace: serverNamespace,
90
- object: mcpTool.serverName,
91
- relation: "users",
323
+ object: serverName,
324
+ relation: opl_js_1.PERMIT_USE,
92
325
  ...options.subject,
326
+ mcp,
93
327
  };
94
328
  const activityAttributes = {
95
329
  mcpServer: mcpTool.serverName,
96
330
  ...(mcpTool.toolName ? { mcpTool: mcpTool.toolName } : {}),
97
331
  ...options.activityAttributes,
98
332
  };
99
- if (!checkToolLevel || !mcpTool.toolName) {
100
- // Server-only check
333
+ if (!checkToolLevel) {
101
334
  const result = await client.checkPermission(serverCheck, { activityAttributes });
102
335
  return {
103
336
  serverAllowed: result.allowed,
@@ -107,12 +340,12 @@ async function checkMcpPermission(client, mcpTool, options) {
107
340
  mcpTool,
108
341
  };
109
342
  }
110
- // Batch check: server + tool
111
343
  const toolCheck = {
112
344
  namespace: toolNamespace,
113
- object: `${mcpTool.serverName}:${mcpTool.toolName}`,
114
- relation: "invoke",
345
+ object: mcpToolObject(serverName, mcpTool.toolName ?? ""),
346
+ relation: opl_js_1.PERMIT_USE,
115
347
  ...options.subject,
348
+ mcp,
116
349
  };
117
350
  const batchResult = await client.batchCheckPermissions([serverCheck, toolCheck], { activityAttributes });
118
351
  const serverAllowed = batchResult.results[0]?.allowed ?? false;
package/dist/opl.d.ts CHANGED
@@ -86,6 +86,16 @@ export declare const DEFAULT_NAMESPACE = "AgentTool";
86
86
  * `resolveShellCommandNamespace()` in tool-catalog.ts.
87
87
  */
88
88
  export declare const DEFAULT_SHELL_NAMESPACE = "ShellTool";
89
+ /**
90
+ * Namespaces for MCP calls. A call is checked as `McpServer:<server>#use` and
91
+ * `McpTool:<server>:<tool>#use`, where `<server>` is the lowercased configured
92
+ * server name. Same default-allow / explicit-block model as the native tool
93
+ * namespaces; Agent Security projects per-server Admin, Write, Read, and No
94
+ * access levels into them. Not operator-configurable: the server catalog owns
95
+ * the objects.
96
+ */
97
+ export declare const MCP_SERVER_NAMESPACE = "McpServer";
98
+ export declare const MCP_TOOL_NAMESPACE = "McpTool";
89
99
  /**
90
100
  * Namespace for delegation nodes — the current-state graph of who delegated to
91
101
  * whom (`Delegation:agent:<join>`, `Delegation:subagent:<join>|<type>`). Its own
@@ -232,7 +242,7 @@ export declare function renderPrincipalClass(name: string): string;
232
242
  /**
233
243
  * Render a native tool-namespace class body: the `blockedSubjects` relation and
234
244
  * the default-allow `use` permit Argus checks. The single definition is used for
235
- * both AgentTool and ShellTool; MCP namespaces remain grant-based elsewhere.
245
+ * AgentTool, ShellTool, McpServer, and McpTool.
236
246
  */
237
247
  export declare function renderToolNamespaceClass(namespace?: string): string;
238
248
  /**
package/dist/opl.js CHANGED
@@ -35,7 +35,7 @@
35
35
  * grant relation is the plural `users` and the permit the singular verb `use`.
36
36
  */
37
37
  Object.defineProperty(exports, "__esModule", { value: true });
38
- exports.NODE_REFERENCE_NAMESPACES = exports.PRINCIPAL_NAMESPACES = exports.OPL_IMPORT_SYMBOLS = exports.OPL_IMPORT_MODULE = exports.PERMIT_USE = exports.PROJECT_POSTURE_OBJECT = exports.PERMISSION_MODE_OBJECT = exports.PERMISSION_MODE_NAMESPACE = exports.RELATION_OPTED_OUT_SUBJECTS = exports.AUTO_ENROLL_OBJECT = exports.ENROLLMENT_NAMESPACE = exports.RELATION_MEMBERS = exports.SUBAGENT_TYPE_NAMESPACE = exports.AGENT_TYPE_NAMESPACE = exports.AGENT_ROLE_NAMESPACE = exports.SUBAGENT_NODE_NAMESPACE = exports.AGENT_NODE_NAMESPACE = exports.DELEGATION_NAMESPACE = exports.DEFAULT_SHELL_NAMESPACE = exports.DEFAULT_NAMESPACE = exports.RELATION_ENFORCED_SUBJECTS = exports.RELATION_CREDENTIALS = exports.RELATION_BLOCKED_SUBJECTS = exports.RELATION_DELEGATES = exports.RELATION_USERS = void 0;
38
+ exports.NODE_REFERENCE_NAMESPACES = exports.PRINCIPAL_NAMESPACES = exports.OPL_IMPORT_SYMBOLS = exports.OPL_IMPORT_MODULE = exports.PERMIT_USE = exports.PROJECT_POSTURE_OBJECT = exports.PERMISSION_MODE_OBJECT = exports.PERMISSION_MODE_NAMESPACE = exports.RELATION_OPTED_OUT_SUBJECTS = exports.AUTO_ENROLL_OBJECT = exports.ENROLLMENT_NAMESPACE = exports.RELATION_MEMBERS = exports.SUBAGENT_TYPE_NAMESPACE = exports.AGENT_TYPE_NAMESPACE = exports.AGENT_ROLE_NAMESPACE = exports.SUBAGENT_NODE_NAMESPACE = exports.AGENT_NODE_NAMESPACE = exports.DELEGATION_NAMESPACE = exports.MCP_TOOL_NAMESPACE = exports.MCP_SERVER_NAMESPACE = exports.DEFAULT_SHELL_NAMESPACE = exports.DEFAULT_NAMESPACE = exports.RELATION_ENFORCED_SUBJECTS = exports.RELATION_CREDENTIALS = exports.RELATION_BLOCKED_SUBJECTS = exports.RELATION_DELEGATES = exports.RELATION_USERS = void 0;
39
39
  exports.renderOplImport = renderOplImport;
40
40
  exports.renderPrincipalClass = renderPrincipalClass;
41
41
  exports.renderToolNamespaceClass = renderToolNamespaceClass;
@@ -99,6 +99,16 @@ exports.DEFAULT_NAMESPACE = "AgentTool";
99
99
  * `resolveShellCommandNamespace()` in tool-catalog.ts.
100
100
  */
101
101
  exports.DEFAULT_SHELL_NAMESPACE = "ShellTool";
102
+ /**
103
+ * Namespaces for MCP calls. A call is checked as `McpServer:<server>#use` and
104
+ * `McpTool:<server>:<tool>#use`, where `<server>` is the lowercased configured
105
+ * server name. Same default-allow / explicit-block model as the native tool
106
+ * namespaces; Agent Security projects per-server Admin, Write, Read, and No
107
+ * access levels into them. Not operator-configurable: the server catalog owns
108
+ * the objects.
109
+ */
110
+ exports.MCP_SERVER_NAMESPACE = "McpServer";
111
+ exports.MCP_TOOL_NAMESPACE = "McpTool";
102
112
  /**
103
113
  * Namespace for delegation nodes — the current-state graph of who delegated to
104
114
  * whom (`Delegation:agent:<join>`, `Delegation:subagent:<join>|<type>`). Its own
@@ -259,7 +269,7 @@ function renderPrincipalClass(name) {
259
269
  /**
260
270
  * Render a native tool-namespace class body: the `blockedSubjects` relation and
261
271
  * the default-allow `use` permit Argus checks. The single definition is used for
262
- * both AgentTool and ShellTool; MCP namespaces remain grant-based elsewhere.
272
+ * AgentTool, ShellTool, McpServer, and McpTool.
263
273
  */
264
274
  function renderToolNamespaceClass(namespace = exports.DEFAULT_NAMESPACE) {
265
275
  return `class ${namespace} implements Namespace {
@@ -406,6 +416,13 @@ function buildAgentSecurityOpl(namespace = exports.DEFAULT_NAMESPACE, shellNames
406
416
  "// default-allow / explicit-block model as the tool namespace above.",
407
417
  renderToolNamespaceClass(shellNamespace),
408
418
  "",
419
+ `// MCP calls: \`${exports.MCP_SERVER_NAMESPACE}:<server>\` and \`${exports.MCP_TOOL_NAMESPACE}:<server>:<tool>\`. Agent`,
420
+ "// Security links a scope to per-server tier anchors to apply Admin, Write,",
421
+ "// Read, or No access. Same default-allow / explicit-block model.",
422
+ renderToolNamespaceClass(exports.MCP_SERVER_NAMESPACE),
423
+ "",
424
+ renderToolNamespaceClass(exports.MCP_TOOL_NAMESPACE),
425
+ "",
409
426
  "// The shared role whose members can be blocked as a group.",
410
427
  renderAgentRoleNamespaceClass(),
411
428
  "",
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Everything that *writes* to the project is provisioned in the **Ory Console**
13
13
  * (Agent Security), not from the plugin: the permission model, explicit native
14
- * blocks, MCP grants, and the observe/enforce posture. That is what lets a
14
+ * blocks, MCP server access, and the observe/enforce posture. That is what lets a
15
15
  * plugin be installed with nothing but a project URL — the public OAuth2 client
16
16
  * id has a reserved default, with no workspace privilege, project API key, or admin write path. The
17
17
  * plugin is a read-only client of the project's permission state.
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * Everything that *writes* to the project is provisioned in the **Ory Console**
14
14
  * (Agent Security), not from the plugin: the permission model, explicit native
15
- * blocks, MCP grants, and the observe/enforce posture. That is what lets a
15
+ * blocks, MCP server access, and the observe/enforce posture. That is what lets a
16
16
  * plugin be installed with nothing but a project URL — the public OAuth2 client
17
17
  * id has a reserved default, with no workspace privilege, project API key, or admin write path. The
18
18
  * plugin is a read-only client of the project's permission state.
@@ -78,7 +78,7 @@ function printPermissionsHelp(binName) {
78
78
  console.log(" observe — Log an audit event and allow the tool through. The default.");
79
79
  console.log(" enforce — Block the tool. The production posture.");
80
80
  console.log("");
81
- console.log("The plugin only ever *reads* permissions. Native tool blocks, MCP grants,");
81
+ console.log("The plugin only ever *reads* permissions. Native tool blocks, MCP server access,");
82
82
  console.log("the permission model, and the observe/enforce posture are all managed in");
83
83
  console.log("the Ory Console (Agent Security) by someone with access to the project.");
84
84
  console.log("");
@@ -97,7 +97,7 @@ function printProvisioningMovedToConsole(binName, attempted) {
97
97
  console.error("Permissions are provisioned in the Ory Console (Agent Security):");
98
98
  console.error(" - the permission model applied to the project");
99
99
  console.error(" - explicit native-tool blocks for a user, agent, or group");
100
- console.error(" - MCP grants");
100
+ console.error(" - MCP server access");
101
101
  console.error(" - the observe / enforce posture, project-wide or per principal");
102
102
  console.error("");
103
103
  console.error("The plugin reads that state and enforces it. To see what it resolves:");
@@ -27,6 +27,7 @@
27
27
  * opencode `throw OryDenialError`, etc.) without re-implementing the
28
28
  * mode logic.
29
29
  */
30
+ import { type McpToolIdentifier } from "./mcp.js";
30
31
  import type { OryAgentClient } from "./client.js";
31
32
  import { type PermissionMode } from "./config.js";
32
33
  import type { OryError, OryErrorCode, PermissionCheck, PermissionResult } from "./types.js";
@@ -34,8 +35,8 @@ import type { OryError, OryErrorCode, PermissionCheck, PermissionResult } from "
34
35
  * Why a permission check came back denied, once the block-aware OPL schema is
35
36
  * in play:
36
37
  *
37
- * - `not_granted` — compatibility for grant-based checks outside native
38
- * AgentTool/ShellTool policy (notably MCP).
38
+ * - `not_granted` — compatibility for grant-based checks outside the native
39
+ * AgentTool, ShellTool, McpServer, and McpTool policy.
39
40
  * - `explicit_block` — a native `use` permit denied because a
40
41
  * `blockedSubjects` relation matched.
41
42
  *
@@ -272,6 +273,30 @@ export interface GateToolCallArgs {
272
273
  * semantics; the new `interactive` kind means "do nothing else."
273
274
  */
274
275
  export declare function gateToolCall(client: OryAgentClient, args: GateToolCallArgs): Promise<ToolGateOutcome>;
276
+ export interface GateMcpToolCallArgs {
277
+ harness: string;
278
+ /** The harness-side tool name, kept for activity and denial context. */
279
+ toolName: string;
280
+ mcpTool: McpToolIdentifier;
281
+ /** The user subject checked for `use`, built with `resolveUserSubject`. */
282
+ subject: Pick<PermissionCheck, "subjectId" | "subjectSet">;
283
+ activityAttributes?: Record<string, unknown>;
284
+ modeOverride?: PermissionMode;
285
+ principals?: PrincipalBlockSubjects;
286
+ }
287
+ /**
288
+ * Gate one MCP tool call against the Agent Security MCP namespaces.
289
+ *
290
+ * The call is checked twice with the native tool model: `McpServer:<server>`
291
+ * (No access blocks every tool on the server, cataloged or not) and
292
+ * `McpTool:<server>:<tool>` (Read and Write levels block tool tiers). Each check
293
+ * covers the user's `use` permit and every machine-principal block, so an MCP
294
+ * level set on the Project, an Identity, an Agent, or a Sub-Agent applies here.
295
+ * The server name is normalized to its lowercased form, matching the server
296
+ * catalog. The most restrictive outcome wins.
297
+ */
298
+ export declare function gateMcpToolCall(client: OryAgentClient, args: GateMcpToolCallArgs): Promise<PermissionDecision>;
299
+ export declare function mostRestrictiveDecision(decisions: PermissionDecision[]): PermissionDecision;
275
300
  /**
276
301
  * Gate a shell tool by decomposing its command into the program/builtin words
277
302
  * that will run and checking each as `ShellTool:<word>#use`, on top of the
@@ -32,9 +32,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
32
32
  exports.resolveCheckRelation = resolveCheckRelation;
33
33
  exports.applyPermissionMode = applyPermissionMode;
34
34
  exports.gateToolCall = gateToolCall;
35
+ exports.gateMcpToolCall = gateMcpToolCall;
36
+ exports.mostRestrictiveDecision = mostRestrictiveDecision;
35
37
  exports.decomposeAndCheck = decomposeAndCheck;
36
38
  exports.checkAndDecide = checkAndDecide;
37
39
  const bash_parser_js_1 = require("./bash-parser.js");
40
+ const mcp_js_1 = require("./mcp.js");
38
41
  const config_js_1 = require("./config.js");
39
42
  const permission_mode_js_1 = require("./permission-mode.js");
40
43
  const read_credential_js_1 = require("./read-credential.js");
@@ -284,6 +287,7 @@ async function batchNonShellGate(client, args) {
284
287
  object: args.check.object,
285
288
  relation: opl_js_1.RELATION_BLOCKED_SUBJECTS,
286
289
  ...ref,
290
+ ...(args.check.mcp ? { mcp: args.check.mcp } : {}),
287
291
  })),
288
292
  ];
289
293
  try {
@@ -317,11 +321,59 @@ async function batchNonShellGate(client, args) {
317
321
  const mode = args.modeOverride ?? (await (0, permission_mode_js_1.warmPermissionModeCache)(client, modeSubject, { additionalSubjects })).mode;
318
322
  const [decision, blocked] = await Promise.all([
319
323
  checkAndDecide(client, args.check, { activityAttributes: args.activityAttributes, modeOverride: mode }),
320
- findBlockedPrincipal(client, { namespace: args.check.namespace, object: args.check.object, principals: args.principals }),
324
+ findBlockedPrincipal(client, {
325
+ namespace: args.check.namespace,
326
+ object: args.check.object,
327
+ principals: args.principals,
328
+ mcp: args.check.mcp,
329
+ }),
321
330
  ]);
322
331
  return applyBlockedDecision(client, args, mode, decision, blocked);
323
332
  }
324
333
  }
334
+ /**
335
+ * Gate one MCP tool call against the Agent Security MCP namespaces.
336
+ *
337
+ * The call is checked twice with the native tool model: `McpServer:<server>`
338
+ * (No access blocks every tool on the server, cataloged or not) and
339
+ * `McpTool:<server>:<tool>` (Read and Write levels block tool tiers). Each check
340
+ * covers the user's `use` permit and every machine-principal block, so an MCP
341
+ * level set on the Project, an Identity, an Agent, or a Sub-Agent applies here.
342
+ * The server name is normalized to its lowercased form, matching the server
343
+ * catalog. The most restrictive outcome wins.
344
+ */
345
+ async function gateMcpToolCall(client, args) {
346
+ await (0, read_credential_js_1.ensureReadCredential)(client, { harness: args.harness });
347
+ const serverName = (0, mcp_js_1.normalizeMcpServerName)(args.mcpTool.serverName);
348
+ const mcp = (0, mcp_js_1.mcpServerHint)(args.mcpTool);
349
+ // The tool is checked even when the harness reports no tool name: Agent
350
+ // Security checks an unnamed tool as Admin, so Read and Write still deny it.
351
+ // A resource read has no tool and is checked on the server alone.
352
+ const objects = [
353
+ { namespace: opl_js_1.MCP_SERVER_NAMESPACE, object: serverName },
354
+ ...(args.mcpTool.resource
355
+ ? []
356
+ : [{ namespace: opl_js_1.MCP_TOOL_NAMESPACE, object: (0, mcp_js_1.mcpToolObject)(serverName, args.mcpTool.toolName ?? "") }]),
357
+ ];
358
+ const decisions = await Promise.all(objects.map(({ namespace, object }) => batchNonShellGate(client, {
359
+ harness: args.harness,
360
+ toolName: args.toolName,
361
+ check: { namespace, object, relation: opl_js_1.PERMIT_USE, ...args.subject, mcp },
362
+ activityAttributes: args.activityAttributes,
363
+ modeOverride: args.modeOverride,
364
+ principals: args.principals,
365
+ })));
366
+ return mostRestrictiveDecision(decisions);
367
+ }
368
+ const DECISION_SEVERITY = {
369
+ allow: 0,
370
+ fail_open: 1,
371
+ observe: 2,
372
+ deny: 3,
373
+ };
374
+ function mostRestrictiveDecision(decisions) {
375
+ return decisions.reduce((current, next) => DECISION_SEVERITY[next.kind] > DECISION_SEVERITY[current.kind] ? next : current);
376
+ }
325
377
  function applyBlockedDecision(client, args, mode, decision, blocked) {
326
378
  if (!blocked)
327
379
  return decision;
@@ -812,6 +864,7 @@ async function findBlockedPrincipal(client, args) {
812
864
  namespace: args.namespace,
813
865
  objects: [args.object],
814
866
  principals: args.principals,
867
+ mcp: args.mcp,
815
868
  })).get(args.object);
816
869
  }
817
870
  async function findBlockedPrincipals(client, args) {
@@ -843,6 +896,7 @@ async function findBlockedPrincipals(client, args) {
843
896
  object,
844
897
  relation: opl_js_1.RELATION_BLOCKED_SUBJECTS,
845
898
  ...ref,
899
+ ...(args.mcp ? { mcp: args.mcp } : {}),
846
900
  }))), { activityAttributes: { source: "principal_block", scope } });
847
901
  for (const [i, result] of batch.results.entries()) {
848
902
  const object = args.objects[Math.floor(i / candidates.length)];
@@ -16,7 +16,7 @@
16
16
  * plugin logs activity locally and gates nothing. Nothing else changes either way.
17
17
  *
18
18
  * Everything *else* an Ory project needs to serve Agent Security — the login
19
- * OAuth2 client, the permission model, native-tool blocks, MCP grants,
19
+ * OAuth2 client, the permission model, native-tool blocks, MCP server access,
20
20
  * and the observe/enforce posture — is provisioned in the **Ory Console**
21
21
  * (Agent Security), by someone with access to the project. The plugin never
22
22
  * writes project configuration, which is why installing it needs only a
@@ -17,7 +17,7 @@
17
17
  * plugin logs activity locally and gates nothing. Nothing else changes either way.
18
18
  *
19
19
  * Everything *else* an Ory project needs to serve Agent Security — the login
20
- * OAuth2 client, the permission model, native-tool blocks, MCP grants,
20
+ * OAuth2 client, the permission model, native-tool blocks, MCP server access,
21
21
  * and the observe/enforce posture — is provisioned in the **Ory Console**
22
22
  * (Agent Security), by someone with access to the project. The plugin never
23
23
  * writes project configuration, which is why installing it needs only a
@@ -112,7 +112,7 @@ async function runPostInstall(binName, _harness, args = []) {
112
112
  if (security.connected) {
113
113
  ui.success("Ory Agent Security is connected.");
114
114
  ui.hint("Sign-in and permission checks run on the next session.");
115
- ui.hint("Native-tool blocks, MCP grants, and the observe/enforce posture are managed in the Ory Console (Agent Security).");
115
+ ui.hint("Native-tool blocks, MCP server access, and the observe/enforce posture are managed in the Ory Console (Agent Security).");
116
116
  ui.hint(`Inspect what this machine resolves with \`${binName} permissions status\`.`);
117
117
  }
118
118
  else {