@ory/argus 1.6.0 → 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.
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "repo": "ory-agent-plugins",
3
- "commit": "ebcd620c5f7cb4a27f47fd98bcb50b3d11d92b2e",
4
- "commitShort": "ebcd620c",
3
+ "commit": "11290e2d19d1b0cabd49b94f8eaab8c3eb6f5f8e",
4
+ "commitShort": "11290e2d",
5
5
  "branch": "main",
6
- "commitDate": "2026-10-28T20:25:41-07:00",
6
+ "commitDate": "2026-10-31T18:27:13-07:00",
7
7
  "dirty": false,
8
- "builtAt": "2026-10-29T03:28:58.821Z"
8
+ "builtAt": "2026-11-01T01:30:26.563Z"
9
9
  }
package/dist/client.js CHANGED
@@ -241,6 +241,9 @@ class OryAgentClient {
241
241
  || (result.error !== undefined && typeof result.error !== "string"))) {
242
242
  throw httpError(403, "Agent Security returned an invalid permission result");
243
243
  }
244
+ if (data.results.some((result) => result.error !== undefined)) {
245
+ throw httpError(503, "Agent Security could not complete a permission check");
246
+ }
244
247
  results.push(...data.results);
245
248
  headers = response.headers;
246
249
  }
@@ -720,6 +723,9 @@ class OryAgentClient {
720
723
  ...(check.mcp ? { mcp: check.mcp } : {}),
721
724
  });
722
725
  const data = response.data;
726
+ if (data.error !== undefined && data.error !== null) {
727
+ throw httpError(503, "Agent Security could not complete a permission check");
728
+ }
723
729
  const oryRequestId = extractOryRequestId(response.headers);
724
730
  const result = {
725
731
  allowed: data.allowed,
@@ -789,6 +795,9 @@ class OryAgentClient {
789
795
  },
790
796
  });
791
797
  const data = response.data;
798
+ if (data.results.some((result) => result.error !== undefined && result.error !== null)) {
799
+ throw httpError(503, "Agent Security could not complete a permission check");
800
+ }
792
801
  const oryRequestId = extractOryRequestId(response.headers);
793
802
  const result = {
794
803
  results: data.results.map((r) => ({
@@ -966,6 +975,9 @@ class OryAgentClient {
966
975
  cause: err,
967
976
  };
968
977
  }
978
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
979
+ return { code: "network_error", message: "Permission request timed out or was aborted", cause: err };
980
+ }
969
981
  const socketCode = findSocketErrorCode(err);
970
982
  if (socketCode) {
971
983
  return {
@@ -1257,6 +1269,10 @@ const SOCKET_ERROR_CODES = new Set([
1257
1269
  "ENETUNREACH",
1258
1270
  "EPIPE",
1259
1271
  "ECONNABORTED",
1272
+ "UND_ERR_CONNECT_TIMEOUT",
1273
+ "UND_ERR_HEADERS_TIMEOUT",
1274
+ "UND_ERR_BODY_TIMEOUT",
1275
+ "UND_ERR_SOCKET",
1260
1276
  ]);
1261
1277
  /**
1262
1278
  * The socket-level error code on `err`, walking `cause` the same way
@@ -167,8 +167,8 @@ function runHarnessContractSuite(adapter) {
167
167
  { check: "allowed", mode: "enforce", blocked: false, invoke: true, block: false },
168
168
  { check: "denied", mode: "enforce", blocked: toolCanBlock, invoke: false, block: true },
169
169
  { check: "denied", mode: "observe", blocked: false, invoke: true, block: true },
170
- { check: "network_error", mode: "enforce", blocked: false, invoke: true, block: false },
171
- { check: "rate_limited", mode: "enforce", blocked: false, invoke: true, block: false },
170
+ { check: "network_error", mode: "enforce", blocked: toolCanBlock, invoke: false, block: true },
171
+ { check: "rate_limited", mode: "enforce", blocked: toolCanBlock, invoke: false, block: true },
172
172
  ])("check=$check × $mode → blocked=$blocked", async ({ check, mode, blocked, invoke, block }) => {
173
173
  const stubs = {
174
174
  allowed: testing_js_1.stubPermissionAllowed,
package/dist/denial.d.ts CHANGED
@@ -39,6 +39,8 @@ export interface DenialContext {
39
39
  * `activityAttributes.blockReason === "explicit_block"`.
40
40
  */
41
41
  blocked?: boolean;
42
+ /** The check failed before a trustworthy permission decision was available. */
43
+ checkRejected?: string;
42
44
  }
43
45
  /**
44
46
  * Activity attributes that mark a denial as a security alert.
@@ -82,7 +84,7 @@ export declare function formatAlertSummary(ctx: DenialContext): string;
82
84
  */
83
85
  export declare function shellDenialFields(decision: {
84
86
  activityAttributes?: unknown;
85
- }): Pick<DenialContext, "shellWord" | "shellDeniedWords" | "shellTooComplex" | "shellNamespace" | "blocked">;
87
+ }): Pick<DenialContext, "shellWord" | "shellDeniedWords" | "shellTooComplex" | "shellNamespace" | "blocked" | "checkRejected">;
86
88
  /**
87
89
  * Human-facing denial reason. Shown in agent UIs (Claude Code's `decision`
88
90
  * reason, Gemini CLI's systemMessage, OpenClaw's blockReason, OpenCode's
package/dist/denial.js CHANGED
@@ -70,6 +70,7 @@ function shellDenialFields(decision) {
70
70
  shellTooComplex: sa.shellTooComplex,
71
71
  shellNamespace: (0, tool_catalog_js_1.resolveShellCommandNamespace)(),
72
72
  blocked: sa.blockReason === "explicit_block",
73
+ checkRejected: sa.checkRejected,
73
74
  };
74
75
  }
75
76
  /**
@@ -78,6 +79,9 @@ function shellDenialFields(decision) {
78
79
  * thrown error message).
79
80
  */
80
81
  function formatDenialMessage(ctx) {
82
+ if (ctx.checkRejected) {
83
+ return `Ory: tool "${ctx.tool}" blocked because its permission check could not be completed (${ctx.checkRejected}). Check Agent Security availability and credentials, then retry.`;
84
+ }
81
85
  if (ctx.shellTooComplex) {
82
86
  return (`Ory: shell command blocked — it could not be safely parsed and is ` +
83
87
  `denied in enforce mode. Subject "${ctx.subjectId}". ` +
@@ -127,6 +131,8 @@ function formatDenialMessage(ctx) {
127
131
  * systemMessage which appears inline in the agent's TUI).
128
132
  */
129
133
  function formatDenialSummary(ctx) {
134
+ if (ctx.checkRejected)
135
+ return `Ory: permission check unavailable (${ctx.checkRejected}); tool blocked.`;
130
136
  if (ctx.shellTooComplex) {
131
137
  return `Ory: shell command blocked (could not be safely parsed). ${ADMIN_HINT}`;
132
138
  }
@@ -39,7 +39,7 @@
39
39
  * 3. **Persistent cache** — when the server is unreachable, fall back to the
40
40
  * last value read from the server, persisted in `config.json`
41
41
  * (`source: "cache"`).
42
- * 4. **`observe`** — nothing was ever cached (`source: "default"`).
42
+ * 4. **`enforce`** — a failed read and no applicable cache (`source: "default"`).
43
43
  *
44
44
  * The persistent cache is written by {@link warmPermissionModeCache} at session
45
45
  * start and when a gate first discovers a new acting-machine scope. It only
@@ -76,7 +76,7 @@ export declare function preparePermissionModeBatch(client: OryAgentClient, subje
76
76
  }): PermissionModeBatchPlan;
77
77
  /**
78
78
  * Resolve the current permission mode for `subject`. Never throws — a Keto
79
- * error falls back to the persistent cache, then `observe`. See the module
79
+ * error falls back to the persistent cache, then `enforce`. See the module
80
80
  * doc for the precedence. Does **not** persist (that is
81
81
  * {@link warmPermissionModeCache}'s job); `opts.now` is injectable for tests.
82
82
  */
@@ -40,7 +40,7 @@
40
40
  * 3. **Persistent cache** — when the server is unreachable, fall back to the
41
41
  * last value read from the server, persisted in `config.json`
42
42
  * (`source: "cache"`).
43
- * 4. **`observe`** — nothing was ever cached (`source: "default"`).
43
+ * 4. **`enforce`** — a failed read and no applicable cache (`source: "default"`).
44
44
  *
45
45
  * The persistent cache is written by {@link warmPermissionModeCache} at session
46
46
  * start and when a gate first discovers a new acting-machine scope. It only
@@ -225,7 +225,7 @@ function fallbackPermissionMode(projectUrl, subjectKeys) {
225
225
  ? cache.mode
226
226
  : undefined;
227
227
  const mode = scopedMode ?? (legacyMode === "enforce" ? "enforce" : undefined);
228
- return mode ? { mode, source: "cache" } : { mode: "observe", source: "default" };
228
+ return mode ? { mode, source: "cache" } : { mode: "enforce", source: "default" };
229
229
  }
230
230
  function persistPermissionModeMemo(client, projectUrl, now, requestedKeys) {
231
231
  const updates = {};
@@ -259,7 +259,7 @@ function persistPermissionModeMemo(client, projectUrl, now, requestedKeys) {
259
259
  }
260
260
  /**
261
261
  * Resolve the current permission mode for `subject`. Never throws — a Keto
262
- * error falls back to the persistent cache, then `observe`. See the module
262
+ * error falls back to the persistent cache, then `enforce`. See the module
263
263
  * doc for the precedence. Does **not** persist (that is
264
264
  * {@link warmPermissionModeCache}'s job); `opts.now` is injectable for tests.
265
265
  */
@@ -378,9 +378,9 @@ async function resolvePermissionMode(client, subject, opts = {}) {
378
378
  }
379
379
  client.logger.debug("permission.mode_default", {
380
380
  code: oryErr?.code,
381
- note: "mode check failed and no cached value; defaulting to observe",
381
+ note: "mode check failed and no cached value; defaulting to enforce",
382
382
  });
383
- return { mode: "observe", source: "default" };
383
+ return { mode: "enforce", source: "default" };
384
384
  }
385
385
  }
386
386
  /**
@@ -13,9 +13,8 @@
13
13
  * tool through.
14
14
  * - `enforce` — return `{ kind: "deny", … }` so the caller blocks.
15
15
  * 3. On a thrown {@link OryError}, inspects the classified code:
16
- * - Infrastructure errors (`network_error`, `rate_limited`,
17
- * `not_found`, `unknown`) — returns `{ kind: "fail_open", … }`.
18
- * Callers should log and allow.
16
+ * - Network, rate-limit and server failures deny in enforce mode;
17
+ * observe mode remains permissive.
19
18
  * - Auth-rejected check calls (`forbidden`, `session_inactive`,
20
19
  * `session_aal2_required` — the check itself was rejected, so
21
20
  * its result cannot be trusted) — in `enforce` mode returns
@@ -42,7 +41,7 @@ import type { OryError, OryErrorCode, PermissionCheck, PermissionResult } from "
42
41
  *
43
42
  * Threaded onto the decision's activity attributes and into the observe-mode
44
43
  * audit event so "explicitly blocked" is distinguishable from "never granted"
45
- * in activity logs, mirroring how `checkRejected` distinguishes an auth-rejected
44
+ * in activity logs, mirroring how `checkRejected` distinguishes a failed
46
45
  * deny.
47
46
  */
48
47
  export type BlockReason = "not_granted" | "explicit_block";
@@ -66,7 +65,7 @@ export interface DecisionActivityAttributes {
66
65
  /** SubjectSet rendered as `<namespace>:<object>#<relation>` when used. */
67
66
  subjectSet?: string;
68
67
  /**
69
- * Set when the check call itself was auth-rejected and enforce mode
68
+ * Set when the check call itself failed and enforce mode
70
69
  * turned that into a deny — the classified {@link OryErrorCode} that
71
70
  * caused it. Makes the "denied because the check failed" case
72
71
  * distinguishable from a normal Keto deny in the audit trail.
@@ -305,16 +304,16 @@ export declare function mostRestrictiveDecision(decisions: PermissionDecision[])
305
304
  * Aggregation: **any deny → deny** (naming the offending word); else any observe
306
305
  * → observe (pass through, per-word `permission.observe_deny` events recorded);
307
306
  * else allow. A too-complex / unparseable command is **fail-closed** in enforce
308
- * (deny + alert) and logged-then-passed in observe. A parser-unavailable or
309
- * infrastructure error is **fail-open** (top-level decision only) — availability
310
- * must not hinge on the parser.
307
+ * (deny + alert) and logged-then-passed in observe. A parser-unavailable
308
+ * condition falls back to the top-level decision. Network, rate-limit, and
309
+ * server failures in word checks deny in enforce mode.
311
310
  */
312
311
  export declare function decomposeAndCheck(client: OryAgentClient, args: GateToolCallArgs): Promise<PermissionDecision>;
313
312
  /**
314
313
  * Run a permission check and resolve the configured mode against the
315
314
  * result. Never throws — infrastructure errors surface as a typed
316
- * `{ kind: "fail_open" }` decision, and auth-rejected check calls in
317
- * enforce mode as `{ kind: "deny" }` (see
315
+ * `{ kind: "fail_open" }` decision in observe mode, and untrusted or unavailable
316
+ * check calls in enforce mode as `{ kind: "deny" }` (see
318
317
  * {@link CHECK_UNTRUSTED_CODES}).
319
318
  */
320
319
  export declare function checkAndDecide(client: OryAgentClient, check: PermissionCheck, opts?: CheckAndDecideOptions): Promise<PermissionDecision>;
@@ -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
@@ -57,8 +56,7 @@ function resolveCheckRelation(relation) {
57
56
  * authentication/authorization reasons — an expired or misconfigured
58
57
  * agent credential, an inactive session, or a missing MFA step. The
59
58
  * check result cannot be trusted, so `enforce` mode denies instead of
60
- * failing open. Infrastructure errors (`network_error`, `rate_limited`,
61
- * `not_found`, `unknown`) keep the fail-open posture.
59
+ * failing open. Network and rate-limit failures are likewise untrusted.
62
60
  */
63
61
  const CHECK_UNTRUSTED_CODES = new Set([
64
62
  // The check reached Ory and was refused — an expired or misconfigured agent
@@ -72,7 +70,27 @@ const CHECK_UNTRUSTED_CODES = new Set([
72
70
  // cert problem silently downgraded an `enforce` project to `observe`, and
73
71
  // breaking TLS was sufficient to disable all tool gating (#218).
74
72
  "tls_error",
73
+ "network_error",
74
+ "rate_limited",
75
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
+ }
76
94
  /**
77
95
  * Max distinct command words checked per shell command (issue #76). A command
78
96
  * with more than this is pathological (no legitimate command runs 100+ distinct
@@ -251,10 +269,13 @@ async function gateToolCall(client, args) {
251
269
  namespace: args.check.namespace,
252
270
  object: args.check.object,
253
271
  principals: args.principals,
254
- }),
272
+ strict: mode === "enforce",
273
+ }).catch((error) => ({ error })),
255
274
  ]);
256
275
  if (!blocked)
257
276
  return decision;
277
+ if ("error" in blocked)
278
+ return rejectedCheckDecision(args.check, blocked.error, mode);
258
279
  // Already denied on the user's own check — nothing to add to the outcome, but
259
280
  // the block is worth recording as the stronger reason it would not have run.
260
281
  if (decision.kind === "deny") {
@@ -317,8 +338,12 @@ async function batchNonShellGate(client, args) {
317
338
  }
318
339
  return applyBlockedDecision(client, args, mode, decision, blocked);
319
340
  }
320
- catch {
341
+ catch (err) {
321
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);
322
347
  const [decision, blocked] = await Promise.all([
323
348
  checkAndDecide(client, args.check, { activityAttributes: args.activityAttributes, modeOverride: mode }),
324
349
  findBlockedPrincipal(client, {
@@ -326,8 +351,11 @@ async function batchNonShellGate(client, args) {
326
351
  object: args.check.object,
327
352
  principals: args.principals,
328
353
  mcp: args.check.mcp,
329
- }),
354
+ strict: mode === "enforce",
355
+ }).catch((error) => ({ error })),
330
356
  ]);
357
+ if (blocked && "error" in blocked)
358
+ return rejectedCheckDecision(args.check, blocked.error, mode);
331
359
  return applyBlockedDecision(client, args, mode, decision, blocked);
332
360
  }
333
361
  }
@@ -441,9 +469,9 @@ function applyPrincipalBlock(client, ctx) {
441
469
  * Aggregation: **any deny → deny** (naming the offending word); else any observe
442
470
  * → observe (pass through, per-word `permission.observe_deny` events recorded);
443
471
  * else allow. A too-complex / unparseable command is **fail-closed** in enforce
444
- * (deny + alert) and logged-then-passed in observe. A parser-unavailable or
445
- * infrastructure error is **fail-open** (top-level decision only) — availability
446
- * 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.
447
475
  */
448
476
  async function decomposeAndCheck(client, args) {
449
477
  // The word checks do not depend on the top-level result until aggregation, so
@@ -463,7 +491,7 @@ async function decomposeAndCheck(client, args) {
463
491
  || parsed.words.length > MAX_SHELL_WORDS
464
492
  || (parsed.tooComplex && expectedMode === "enforce")
465
493
  ? undefined
466
- : fetchShellWordResults(client, args, namespace, parsed.words, subject);
494
+ : fetchShellWordResults(client, { ...args, modeOverride: expectedMode }, namespace, parsed.words, subject);
467
495
  const [top, wordFetch] = await Promise.all([topPromise, wordsPromise]);
468
496
  // Top-level Bash denied (enforce) or unreachable → no point decomposing.
469
497
  if (top.kind === "deny" || top.kind === "fail_open")
@@ -538,10 +566,10 @@ async function decomposeAndCheck(client, args) {
538
566
  let sawObserve = false;
539
567
  let matchedBlocks = new Map();
540
568
  if (words.length > 0) {
541
- const fetched = wordFetch ?? await fetchShellWordResults(client, args, namespace, words, subject);
569
+ const fetched = wordFetch ?? await fetchShellWordResults(client, { ...args, modeOverride: mode }, namespace, words, subject);
542
570
  if (!fetched.ok) {
543
571
  const oryErr = fetched.error;
544
- if (mode === "enforce" && oryErr && CHECK_UNTRUSTED_CODES.has(oryErr.code)) {
572
+ if (mode === "enforce" && isUntrustedCheckError(oryErr)) {
545
573
  return {
546
574
  kind: "deny",
547
575
  result: { allowed: false, checkedAt: new Date().toISOString(), check: args.check },
@@ -722,6 +750,7 @@ async function fetchShellWordResults(client, args, namespace, words, subject) {
722
750
  namespace,
723
751
  objects: chunk,
724
752
  principals: args.principals,
753
+ strict: args.modeOverride === "enforce",
725
754
  }),
726
755
  ]);
727
756
  return { ok: true, results: batch.results, blocked };
@@ -746,8 +775,8 @@ async function fetchShellWordResults(client, args, namespace, words, subject) {
746
775
  /**
747
776
  * Run a permission check and resolve the configured mode against the
748
777
  * result. Never throws — infrastructure errors surface as a typed
749
- * `{ kind: "fail_open" }` decision, and auth-rejected check calls in
750
- * 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
751
780
  * {@link CHECK_UNTRUSTED_CODES}).
752
781
  */
753
782
  async function checkAndDecide(client, check, opts = {}) {
@@ -768,9 +797,8 @@ async function checkAndDecide(client, check, opts = {}) {
768
797
  }
769
798
  catch (err) {
770
799
  const oryErr = err;
771
- if (mode === "enforce" && oryErr && CHECK_UNTRUSTED_CODES.has(oryErr.code)) {
772
- // The check call itself was rejected for auth reasons — the
773
- // 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.
774
802
  client.logger.warn("permission.check_rejected", {
775
803
  namespace: check.namespace,
776
804
  object: check.object,
@@ -865,6 +893,7 @@ async function findBlockedPrincipal(client, args) {
865
893
  objects: [args.object],
866
894
  principals: args.principals,
867
895
  mcp: args.mcp,
896
+ strict: args.strict,
868
897
  })).get(args.object);
869
898
  }
870
899
  async function findBlockedPrincipals(client, args) {
@@ -910,6 +939,9 @@ async function findBlockedPrincipals(client, args) {
910
939
  }
911
940
  }
912
941
  catch (err) {
942
+ const classified = err?.code ? err : client.classifyError(err);
943
+ if (args.strict && isUntrustedCheckError(classified))
944
+ throw classified;
913
945
  client.logger.debug("permission.principal_block.failed", {
914
946
  scope,
915
947
  note: `${scope} principal block read failed; falling open for that scope`,
package/dist/types.d.ts CHANGED
@@ -63,8 +63,7 @@ export type OryErrorCode = "session_inactive" | "session_aal2_required" | "forbi
63
63
  /**
64
64
  * TLS/certificate verification failed — an expired, self-signed, or otherwise
65
65
  * untrusted certificate on the project host. Distinct from `network_error`
66
- * because the posture differs: a socket-level blip is a transport failure and
67
- * fails open, whereas a certificate failure means we cannot establish *who we
66
+ * because a certificate failure means we cannot establish *who we
68
67
  * are talking to*, so the check result cannot be trusted at all. It is also
69
68
  * precisely the failure an active network attacker produces, so under
70
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.6.0",
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",