@ory/argus 1.5.3 → 1.7.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.
@@ -14,9 +14,8 @@
14
14
  * tool through.
15
15
  * - `enforce` — return `{ kind: "deny", … }` so the caller blocks.
16
16
  * 3. On a thrown {@link OryError}, inspects the classified code:
17
- * - Infrastructure errors (`network_error`, `rate_limited`,
18
- * `not_found`, `unknown`) — returns `{ kind: "fail_open", … }`.
19
- * Callers should log and allow.
17
+ * - Network, rate-limit and server failures deny in enforce mode;
18
+ * observe mode remains permissive.
20
19
  * - Auth-rejected check calls (`forbidden`, `session_inactive`,
21
20
  * `session_aal2_required` — the check itself was rejected, so
22
21
  * its result cannot be trusted) — in `enforce` mode returns
@@ -32,9 +31,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
32
31
  exports.resolveCheckRelation = resolveCheckRelation;
33
32
  exports.applyPermissionMode = applyPermissionMode;
34
33
  exports.gateToolCall = gateToolCall;
34
+ exports.gateMcpToolCall = gateMcpToolCall;
35
+ exports.mostRestrictiveDecision = mostRestrictiveDecision;
35
36
  exports.decomposeAndCheck = decomposeAndCheck;
36
37
  exports.checkAndDecide = checkAndDecide;
37
38
  const bash_parser_js_1 = require("./bash-parser.js");
39
+ const mcp_js_1 = require("./mcp.js");
38
40
  const config_js_1 = require("./config.js");
39
41
  const permission_mode_js_1 = require("./permission-mode.js");
40
42
  const read_credential_js_1 = require("./read-credential.js");
@@ -54,8 +56,7 @@ function resolveCheckRelation(relation) {
54
56
  * authentication/authorization reasons — an expired or misconfigured
55
57
  * agent credential, an inactive session, or a missing MFA step. The
56
58
  * check result cannot be trusted, so `enforce` mode denies instead of
57
- * failing open. Infrastructure errors (`network_error`, `rate_limited`,
58
- * `not_found`, `unknown`) keep the fail-open posture.
59
+ * failing open. Network and rate-limit failures are likewise untrusted.
59
60
  */
60
61
  const CHECK_UNTRUSTED_CODES = new Set([
61
62
  // The check reached Ory and was refused — an expired or misconfigured agent
@@ -69,7 +70,27 @@ const CHECK_UNTRUSTED_CODES = new Set([
69
70
  // cert problem silently downgraded an `enforce` project to `observe`, and
70
71
  // breaking TLS was sufficient to disable all tool gating (#218).
71
72
  "tls_error",
73
+ "network_error",
74
+ "rate_limited",
72
75
  ]);
76
+ function isUntrustedCheckError(error) {
77
+ return !!error && (CHECK_UNTRUSTED_CODES.has(error.code) || (error.status !== undefined && error.status >= 500));
78
+ }
79
+ function isUnavailableCheckError(error) {
80
+ return error.code === "network_error" || error.code === "rate_limited" || (error.status !== undefined && error.status >= 500);
81
+ }
82
+ function rejectedCheckDecision(check, error, mode) {
83
+ const attrs = buildDecisionAttributes(mode, check.subjectId, check.subjectSet);
84
+ if (mode === "enforce") {
85
+ return {
86
+ kind: "deny",
87
+ result: { allowed: false, checkedAt: new Date().toISOString(), check },
88
+ mode: "enforce",
89
+ activityAttributes: { ...attrs, checkRejected: error.code },
90
+ };
91
+ }
92
+ return { kind: "fail_open", error, mode, activityAttributes: attrs };
93
+ }
73
94
  /**
74
95
  * Max distinct command words checked per shell command (issue #76). A command
75
96
  * with more than this is pathological (no legitimate command runs 100+ distinct
@@ -248,10 +269,13 @@ async function gateToolCall(client, args) {
248
269
  namespace: args.check.namespace,
249
270
  object: args.check.object,
250
271
  principals: args.principals,
251
- }),
272
+ strict: mode === "enforce",
273
+ }).catch((error) => ({ error })),
252
274
  ]);
253
275
  if (!blocked)
254
276
  return decision;
277
+ if ("error" in blocked)
278
+ return rejectedCheckDecision(args.check, blocked.error, mode);
255
279
  // Already denied on the user's own check — nothing to add to the outcome, but
256
280
  // the block is worth recording as the stronger reason it would not have run.
257
281
  if (decision.kind === "deny") {
@@ -284,6 +308,7 @@ async function batchNonShellGate(client, args) {
284
308
  object: args.check.object,
285
309
  relation: opl_js_1.RELATION_BLOCKED_SUBJECTS,
286
310
  ...ref,
311
+ ...(args.check.mcp ? { mcp: args.check.mcp } : {}),
287
312
  })),
288
313
  ];
289
314
  try {
@@ -313,15 +338,70 @@ async function batchNonShellGate(client, args) {
313
338
  }
314
339
  return applyBlockedDecision(client, args, mode, decision, blocked);
315
340
  }
316
- catch {
341
+ catch (err) {
317
342
  const mode = args.modeOverride ?? (await (0, permission_mode_js_1.warmPermissionModeCache)(client, modeSubject, { additionalSubjects })).mode;
343
+ const error = err?.status !== undefined || err?.code === "network_error" || err?.code === "rate_limited"
344
+ ? err : client.classifyError(err);
345
+ if (isUnavailableCheckError(error))
346
+ return rejectedCheckDecision(args.check, error, mode);
318
347
  const [decision, blocked] = await Promise.all([
319
348
  checkAndDecide(client, args.check, { activityAttributes: args.activityAttributes, modeOverride: mode }),
320
- findBlockedPrincipal(client, { namespace: args.check.namespace, object: args.check.object, principals: args.principals }),
349
+ findBlockedPrincipal(client, {
350
+ namespace: args.check.namespace,
351
+ object: args.check.object,
352
+ principals: args.principals,
353
+ mcp: args.check.mcp,
354
+ strict: mode === "enforce",
355
+ }).catch((error) => ({ error })),
321
356
  ]);
357
+ if (blocked && "error" in blocked)
358
+ return rejectedCheckDecision(args.check, blocked.error, mode);
322
359
  return applyBlockedDecision(client, args, mode, decision, blocked);
323
360
  }
324
361
  }
362
+ /**
363
+ * Gate one MCP tool call against the Agent Security MCP namespaces.
364
+ *
365
+ * The call is checked twice with the native tool model: `McpServer:<server>`
366
+ * (No access blocks every tool on the server, cataloged or not) and
367
+ * `McpTool:<server>:<tool>` (Read and Write levels block tool tiers). Each check
368
+ * covers the user's `use` permit and every machine-principal block, so an MCP
369
+ * level set on the Project, an Identity, an Agent, or a Sub-Agent applies here.
370
+ * The server name is normalized to its lowercased form, matching the server
371
+ * catalog. The most restrictive outcome wins.
372
+ */
373
+ async function gateMcpToolCall(client, args) {
374
+ await (0, read_credential_js_1.ensureReadCredential)(client, { harness: args.harness });
375
+ const serverName = (0, mcp_js_1.normalizeMcpServerName)(args.mcpTool.serverName);
376
+ const mcp = (0, mcp_js_1.mcpServerHint)(args.mcpTool);
377
+ // The tool is checked even when the harness reports no tool name: Agent
378
+ // Security checks an unnamed tool as Admin, so Read and Write still deny it.
379
+ // A resource read has no tool and is checked on the server alone.
380
+ const objects = [
381
+ { namespace: opl_js_1.MCP_SERVER_NAMESPACE, object: serverName },
382
+ ...(args.mcpTool.resource
383
+ ? []
384
+ : [{ namespace: opl_js_1.MCP_TOOL_NAMESPACE, object: (0, mcp_js_1.mcpToolObject)(serverName, args.mcpTool.toolName ?? "") }]),
385
+ ];
386
+ const decisions = await Promise.all(objects.map(({ namespace, object }) => batchNonShellGate(client, {
387
+ harness: args.harness,
388
+ toolName: args.toolName,
389
+ check: { namespace, object, relation: opl_js_1.PERMIT_USE, ...args.subject, mcp },
390
+ activityAttributes: args.activityAttributes,
391
+ modeOverride: args.modeOverride,
392
+ principals: args.principals,
393
+ })));
394
+ return mostRestrictiveDecision(decisions);
395
+ }
396
+ const DECISION_SEVERITY = {
397
+ allow: 0,
398
+ fail_open: 1,
399
+ observe: 2,
400
+ deny: 3,
401
+ };
402
+ function mostRestrictiveDecision(decisions) {
403
+ return decisions.reduce((current, next) => DECISION_SEVERITY[next.kind] > DECISION_SEVERITY[current.kind] ? next : current);
404
+ }
325
405
  function applyBlockedDecision(client, args, mode, decision, blocked) {
326
406
  if (!blocked)
327
407
  return decision;
@@ -389,9 +469,9 @@ function applyPrincipalBlock(client, ctx) {
389
469
  * Aggregation: **any deny → deny** (naming the offending word); else any observe
390
470
  * → observe (pass through, per-word `permission.observe_deny` events recorded);
391
471
  * else allow. A too-complex / unparseable command is **fail-closed** in enforce
392
- * (deny + alert) and logged-then-passed in observe. A parser-unavailable or
393
- * infrastructure error is **fail-open** (top-level decision only) — availability
394
- * must not hinge on the parser.
472
+ * (deny + alert) and logged-then-passed in observe. A parser-unavailable
473
+ * condition falls back to the top-level decision. Network, rate-limit, and
474
+ * server failures in word checks deny in enforce mode.
395
475
  */
396
476
  async function decomposeAndCheck(client, args) {
397
477
  // The word checks do not depend on the top-level result until aggregation, so
@@ -411,7 +491,7 @@ async function decomposeAndCheck(client, args) {
411
491
  || parsed.words.length > MAX_SHELL_WORDS
412
492
  || (parsed.tooComplex && expectedMode === "enforce")
413
493
  ? undefined
414
- : fetchShellWordResults(client, args, namespace, parsed.words, subject);
494
+ : fetchShellWordResults(client, { ...args, modeOverride: expectedMode }, namespace, parsed.words, subject);
415
495
  const [top, wordFetch] = await Promise.all([topPromise, wordsPromise]);
416
496
  // Top-level Bash denied (enforce) or unreachable → no point decomposing.
417
497
  if (top.kind === "deny" || top.kind === "fail_open")
@@ -486,10 +566,10 @@ async function decomposeAndCheck(client, args) {
486
566
  let sawObserve = false;
487
567
  let matchedBlocks = new Map();
488
568
  if (words.length > 0) {
489
- const fetched = wordFetch ?? await fetchShellWordResults(client, args, namespace, words, subject);
569
+ const fetched = wordFetch ?? await fetchShellWordResults(client, { ...args, modeOverride: mode }, namespace, words, subject);
490
570
  if (!fetched.ok) {
491
571
  const oryErr = fetched.error;
492
- if (mode === "enforce" && oryErr && CHECK_UNTRUSTED_CODES.has(oryErr.code)) {
572
+ if (mode === "enforce" && isUntrustedCheckError(oryErr)) {
493
573
  return {
494
574
  kind: "deny",
495
575
  result: { allowed: false, checkedAt: new Date().toISOString(), check: args.check },
@@ -670,6 +750,7 @@ async function fetchShellWordResults(client, args, namespace, words, subject) {
670
750
  namespace,
671
751
  objects: chunk,
672
752
  principals: args.principals,
753
+ strict: args.modeOverride === "enforce",
673
754
  }),
674
755
  ]);
675
756
  return { ok: true, results: batch.results, blocked };
@@ -694,8 +775,8 @@ async function fetchShellWordResults(client, args, namespace, words, subject) {
694
775
  /**
695
776
  * Run a permission check and resolve the configured mode against the
696
777
  * result. Never throws — infrastructure errors surface as a typed
697
- * `{ kind: "fail_open" }` decision, and auth-rejected check calls in
698
- * enforce mode as `{ kind: "deny" }` (see
778
+ * `{ kind: "fail_open" }` decision in observe mode, and untrusted or unavailable
779
+ * check calls in enforce mode as `{ kind: "deny" }` (see
699
780
  * {@link CHECK_UNTRUSTED_CODES}).
700
781
  */
701
782
  async function checkAndDecide(client, check, opts = {}) {
@@ -716,9 +797,8 @@ async function checkAndDecide(client, check, opts = {}) {
716
797
  }
717
798
  catch (err) {
718
799
  const oryErr = err;
719
- if (mode === "enforce" && oryErr && CHECK_UNTRUSTED_CODES.has(oryErr.code)) {
720
- // The check call itself was rejected for auth reasons — the
721
- // result cannot be trusted, so enforce mode must not fail open.
800
+ if (mode === "enforce" && isUntrustedCheckError(oryErr)) {
801
+ // The result cannot be trusted, so enforce mode must not fail open.
722
802
  client.logger.warn("permission.check_rejected", {
723
803
  namespace: check.namespace,
724
804
  object: check.object,
@@ -812,6 +892,8 @@ async function findBlockedPrincipal(client, args) {
812
892
  namespace: args.namespace,
813
893
  objects: [args.object],
814
894
  principals: args.principals,
895
+ mcp: args.mcp,
896
+ strict: args.strict,
815
897
  })).get(args.object);
816
898
  }
817
899
  async function findBlockedPrincipals(client, args) {
@@ -843,6 +925,7 @@ async function findBlockedPrincipals(client, args) {
843
925
  object,
844
926
  relation: opl_js_1.RELATION_BLOCKED_SUBJECTS,
845
927
  ...ref,
928
+ ...(args.mcp ? { mcp: args.mcp } : {}),
846
929
  }))), { activityAttributes: { source: "principal_block", scope } });
847
930
  for (const [i, result] of batch.results.entries()) {
848
931
  const object = args.objects[Math.floor(i / candidates.length)];
@@ -856,6 +939,9 @@ async function findBlockedPrincipals(client, args) {
856
939
  }
857
940
  }
858
941
  catch (err) {
942
+ const classified = err?.code ? err : client.classifyError(err);
943
+ if (args.strict && isUntrustedCheckError(classified))
944
+ throw classified;
859
945
  client.logger.debug("permission.principal_block.failed", {
860
946
  scope,
861
947
  note: `${scope} principal block read failed; falling open for that scope`,
@@ -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 {
package/dist/testing.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * common assertion patterns so that harness plugin tests focus on
6
6
  * harness-specific behavior rather than duplicating boilerplate.
7
7
  */
8
- import { vi } from "vitest";
8
+ import { type Mock } from "vitest";
9
9
  import { OryAgentClient } from "./client.js";
10
10
  import type { ActivityEntry } from "./logger.js";
11
11
  import type { PermissionMode } from "./config.js";
@@ -13,8 +13,8 @@ export { runHarnessContractSuite, type HarnessContractAdapter, type ContractCont
13
13
  export interface SdkLazySessionContractAdapter {
14
14
  name: string;
15
15
  harness: string;
16
- createSessionStarterMock: ReturnType<typeof vi.fn>;
17
- ensureSessionMock: ReturnType<typeof vi.fn>;
16
+ createSessionStarterMock: Mock;
17
+ ensureSessionMock: Mock;
18
18
  createSubject(client: OryAgentClient, projectUrl?: string): {
19
19
  invoke(index: number): Promise<unknown>;
20
20
  };
@@ -40,7 +40,7 @@ export declare function createMockClient(overrides?: Partial<ConstructorParamete
40
40
  export declare function spyRecordDelegation(client: OryAgentClient, result?: {
41
41
  nodeId: string;
42
42
  delegationChain: string[];
43
- }): import("vitest").Mock<(input: import("./client.js").RecordDelegationInput, options?: {
43
+ }): Mock<(input: import("./client.js").RecordDelegationInput, options?: {
44
44
  fetchImpl?: typeof fetch;
45
45
  activityAttributes?: Record<string, unknown>;
46
46
  agentToken?: string;
@@ -52,7 +52,7 @@ export declare function spyRecordDelegation(client: OryAgentClient, result?: {
52
52
  * Stub an internal API instance method on the client.
53
53
  * Returns a vi.fn mock so callers can assert on calls.
54
54
  */
55
- export declare function stubApi<K extends "frontend" | "oauth2" | "permission">(client: OryAgentClient, api: K, method: string, impl: (...args: unknown[]) => unknown): ReturnType<typeof vi.fn>;
55
+ export declare function stubApi<K extends "frontend" | "oauth2" | "permission">(client: OryAgentClient, api: K, method: string, impl: (...args: unknown[]) => unknown): Mock;
56
56
  /** Successful session verification response (wraps Ory API shape). */
57
57
  export declare const MOCK_SESSION_RESPONSE: {
58
58
  data: {
@@ -76,7 +76,6 @@ export declare const MOCK_SESSION_RESPONSE: {
76
76
  /** Inactive session (same shape, active: false). */
77
77
  export declare const MOCK_INACTIVE_SESSION_RESPONSE: {
78
78
  data: {
79
- active: boolean;
80
79
  id: string;
81
80
  authenticated_at: string;
82
81
  expires_at: string;
@@ -91,6 +90,7 @@ export declare const MOCK_INACTIVE_SESSION_RESPONSE: {
91
90
  email: string;
92
91
  };
93
92
  };
93
+ active: boolean;
94
94
  };
95
95
  };
96
96
  /** Successful OAuth2 token introspection response. */
@@ -238,19 +238,19 @@ export declare function disconnectSecurity(which?: "neither" | "no-client-id" |
238
238
  */
239
239
  export declare function clearOryEnv(): void;
240
240
  /** Stub verifySession to succeed. */
241
- export declare function stubSessionSuccess(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
241
+ export declare function stubSessionSuccess(client: OryAgentClient): Mock;
242
242
  /** Stub verifySession to return inactive. */
243
- export declare function stubSessionInactive(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
243
+ export declare function stubSessionInactive(client: OryAgentClient): Mock;
244
244
  /** Stub verifySession to throw a network error. */
245
- export declare function stubSessionNetworkError(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
245
+ export declare function stubSessionNetworkError(client: OryAgentClient): Mock;
246
246
  /** Stub verifySession to throw MFA required. */
247
- export declare function stubSessionMfaRequired(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
247
+ export declare function stubSessionMfaRequired(client: OryAgentClient): Mock;
248
248
  /** Stub verifySession to throw session_inactive. */
249
- export declare function stubSessionExpired(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
249
+ export declare function stubSessionExpired(client: OryAgentClient): Mock;
250
250
  /** Stub introspectToken to succeed. */
251
- export declare function stubOAuth2Success(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
251
+ export declare function stubOAuth2Success(client: OryAgentClient): Mock;
252
252
  /** Stub introspectToken to return inactive. */
253
- export declare function stubOAuth2Inactive(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
253
+ export declare function stubOAuth2Inactive(client: OryAgentClient): Mock;
254
254
  /**
255
255
  * Set the mode the server-read stub reports. Also clears the in-memory
256
256
  * permission-mode cache so the new value takes effect immediately (tests run
@@ -258,26 +258,38 @@ export declare function stubOAuth2Inactive(client: OryAgentClient): import("vite
258
258
  */
259
259
  export declare function setTestPermissionMode(mode: PermissionMode): void;
260
260
  /** Stub checkPermission to allow. */
261
- export declare function stubPermissionAllowed(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable> & {
262
- batch: import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
261
+ export declare function stubPermissionAllowed(client: OryAgentClient): import("vitest").MockInstance<import("../../../node_modules/vitest/dist/chunks/config.d.QBNYCgFU.js", { with: { "resolution-mode": "import" } }).v> & {
262
+ (...args: any[]): any;
263
+ new (...args: any[]): any;
264
+ } & {} & {
265
+ batch: Mock;
263
266
  };
264
267
  /** Stub checkPermission to deny (the tool check; the mode check still answers `currentTestMode`). */
265
- export declare function stubPermissionDenied(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable> & {
266
- batch: import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
268
+ export declare function stubPermissionDenied(client: OryAgentClient): import("vitest").MockInstance<import("../../../node_modules/vitest/dist/chunks/config.d.QBNYCgFU.js", { with: { "resolution-mode": "import" } }).v> & {
269
+ (...args: any[]): any;
270
+ new (...args: any[]): any;
271
+ } & {} & {
272
+ batch: Mock;
267
273
  };
268
274
  /** Stub checkPermission to throw a network error. */
269
- export declare function stubPermissionNetworkError(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable> & {
270
- batch: import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
275
+ export declare function stubPermissionNetworkError(client: OryAgentClient): import("vitest").MockInstance<import("../../../node_modules/vitest/dist/chunks/config.d.QBNYCgFU.js", { with: { "resolution-mode": "import" } }).v> & {
276
+ (...args: any[]): any;
277
+ new (...args: any[]): any;
278
+ } & {} & {
279
+ batch: Mock;
271
280
  };
272
281
  /** Stub checkPermission to throw a rate-limit error. */
273
- export declare function stubPermissionRateLimited(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable> & {
274
- batch: import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
282
+ export declare function stubPermissionRateLimited(client: OryAgentClient): import("vitest").MockInstance<import("../../../node_modules/vitest/dist/chunks/config.d.QBNYCgFU.js", { with: { "resolution-mode": "import" } }).v> & {
283
+ (...args: any[]): any;
284
+ new (...args: any[]): any;
285
+ } & {} & {
286
+ batch: Mock;
275
287
  };
276
- /** Stub both checkPermission (server allow) and batchCheckPermission (both allow). */
277
- export declare function stubMcpAllowed(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
278
- /** Stub checkPermission to deny (server-only MCP check). */
279
- export declare function stubMcpServerDenied(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
280
- /** Stub batchCheckPermission: server allowed, tool denied. */
281
- export declare function stubMcpToolDenied(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
282
- /** Stub checkPermission to throw network error (MCP fail-open). */
283
- export declare function stubMcpNetworkError(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
288
+ /** Stub MCP checks so the server and the tool are both allowed. */
289
+ export declare function stubMcpAllowed(client: OryAgentClient): Mock;
290
+ /** Stub MCP checks so the server is denied (No access). */
291
+ export declare function stubMcpServerDenied(client: OryAgentClient): Mock;
292
+ /** Stub MCP checks so the server is allowed but the tool is denied (its tier is blocked). */
293
+ export declare function stubMcpToolDenied(client: OryAgentClient): Mock;
294
+ /** Stub MCP checks to throw a network error (MCP fail-open). */
295
+ export declare function stubMcpNetworkError(client: OryAgentClient): Mock;
package/dist/testing.js CHANGED
@@ -553,25 +553,47 @@ function stubPermissionRateLimited(client) {
553
553
  return Object.assign(check, { batch });
554
554
  }
555
555
  // ─── MCP Stub Presets ─────────────────────────────────────────────
556
- /** Stub both checkPermission (server allow) and batchCheckPermission (both allow). */
557
- function stubMcpAllowed(client) {
556
+ /**
557
+ * Answer MCP gate checks tuple by tuple: the `McpServer` and `McpTool` use
558
+ * permits get the given answers, mode checks answer `currentTestMode`, block
559
+ * probes find no block, and any other use permit is allowed.
560
+ */
561
+ function stubMcpDecision(client, answers) {
558
562
  ensurePermissionCredential(client);
559
- stubApi(client, "permission", "checkPermission", checkPermissionResponder(exports.PERMISSION_ALLOWED));
560
- return stubApi(client, "permission", "batchCheckPermission", () => Promise.resolve(exports.BATCH_BOTH_ALLOWED));
563
+ const answer = (tuple) => {
564
+ if (tuple.object === opl_js_1.PERMISSION_MODE_OBJECT && tuple.relation === opl_js_1.RELATION_ENFORCED_SUBJECTS) {
565
+ return currentTestMode === "enforce";
566
+ }
567
+ if (tuple.relation === "blockedSubjects")
568
+ return false;
569
+ if (tuple.namespace === "McpServer")
570
+ return answers.server;
571
+ if (tuple.namespace === "McpTool")
572
+ return answers.tool;
573
+ return true;
574
+ };
575
+ stubApi(client, "permission", "checkPermission", (params) => Promise.resolve(answer(params) ? exports.PERMISSION_ALLOWED : exports.PERMISSION_DENIED));
576
+ return stubApi(client, "permission", "batchCheckPermission", (request) => {
577
+ const tuples = request
578
+ .batchCheckPermissionBody?.tuples ?? [];
579
+ return Promise.resolve({ data: { results: tuples.map((tuple) => ({ allowed: answer(tuple) })) } });
580
+ });
581
+ }
582
+ /** Stub MCP checks so the server and the tool are both allowed. */
583
+ function stubMcpAllowed(client) {
584
+ return stubMcpDecision(client, { server: true, tool: true });
561
585
  }
562
- /** Stub checkPermission to deny (server-only MCP check). */
586
+ /** Stub MCP checks so the server is denied (No access). */
563
587
  function stubMcpServerDenied(client) {
564
- ensurePermissionCredential(client);
565
- return stubApi(client, "permission", "checkPermission", checkPermissionResponder(exports.PERMISSION_DENIED));
588
+ return stubMcpDecision(client, { server: false, tool: true });
566
589
  }
567
- /** Stub batchCheckPermission: server allowed, tool denied. */
590
+ /** Stub MCP checks so the server is allowed but the tool is denied (its tier is blocked). */
568
591
  function stubMcpToolDenied(client) {
569
- ensurePermissionCredential(client);
570
- stubApi(client, "permission", "checkPermission", checkPermissionResponder(exports.PERMISSION_ALLOWED));
571
- return stubApi(client, "permission", "batchCheckPermission", () => Promise.resolve(exports.BATCH_SERVER_ALLOWED_TOOL_DENIED));
592
+ return stubMcpDecision(client, { server: true, tool: false });
572
593
  }
573
- /** Stub checkPermission to throw network error (MCP fail-open). */
594
+ /** Stub MCP checks to throw a network error (MCP fail-open). */
574
595
  function stubMcpNetworkError(client) {
575
596
  ensurePermissionCredential(client);
597
+ stubApi(client, "permission", "batchCheckPermission", () => Promise.reject(makeNetworkError()));
576
598
  return stubApi(client, "permission", "checkPermission", () => Promise.reject(makeNetworkError()));
577
599
  }
package/dist/types.d.ts CHANGED
@@ -32,6 +32,19 @@ export interface PermissionCheck {
32
32
  object: string;
33
33
  relation: string;
34
34
  };
35
+ /**
36
+ * The MCP server an `McpServer` or `McpTool` check targets, as the harness
37
+ * reported it. Agent Security resolves it to the catalog server it checks.
38
+ */
39
+ mcp?: McpServerHint;
40
+ }
41
+ export interface McpServerHint {
42
+ /** The server name as the harness reports it. */
43
+ server: string;
44
+ /** The MCP tool name as the server defines it. */
45
+ tool?: string;
46
+ /** Hashed server identifiers from {@link mcpServerFingerprints}. */
47
+ fingerprints?: string[];
35
48
  }
36
49
  export interface PermissionResult {
37
50
  allowed: boolean;
@@ -50,8 +63,7 @@ export type OryErrorCode = "session_inactive" | "session_aal2_required" | "forbi
50
63
  /**
51
64
  * TLS/certificate verification failed — an expired, self-signed, or otherwise
52
65
  * untrusted certificate on the project host. Distinct from `network_error`
53
- * because the posture differs: a socket-level blip is a transport failure and
54
- * fails open, whereas a certificate failure means we cannot establish *who we
66
+ * because a certificate failure means we cannot establish *who we
55
67
  * are talking to*, so the check result cannot be trusted at all. It is also
56
68
  * precisely the failure an active network attacker produces, so under
57
69
  * `enforce` it denies rather than passing the tool through.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ory/argus",
3
- "version": "1.5.3",
3
+ "version": "1.7.0",
4
4
  "description": "Ory Argus: the core API for building authentication, authorization, and audit into AI agent harness plugins, extensions, and custom integrations",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://ory.com",
@@ -62,10 +62,10 @@
62
62
  "!dist/**/*.tsbuildinfo"
63
63
  ],
64
64
  "dependencies": {
65
- "@ory/client": "^1.22.37",
65
+ "@ory/client": "^1.22.66",
66
66
  "@vscode/tree-sitter-wasm": "^0.3.1",
67
- "open": "^11.0.0",
68
- "web-tree-sitter": "^0.26.11"
67
+ "open": "^11.0.4",
68
+ "web-tree-sitter": "^0.27.1"
69
69
  },
70
70
  "engines": {
71
71
  "node": ">=22"