@ory/argus 1.4.0 → 1.4.2

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": "e6bbb4cddd091bb9adade5a2b21f6064c68f4abb",
4
- "commitShort": "e6bbb4c",
3
+ "commit": "42429b6ed5f9b4201bb43bedb7977e19b53783ac",
4
+ "commitShort": "42429b6",
5
5
  "branch": "main",
6
- "commitDate": "2026-09-30T14:07:23-07:00",
6
+ "commitDate": "2026-10-01T17:08:44-07:00",
7
7
  "dirty": false,
8
- "builtAt": "2026-09-30T21:11:41.105Z"
8
+ "builtAt": "2026-10-02T00:12:26.601Z"
9
9
  }
package/dist/client.js CHANGED
@@ -44,6 +44,7 @@ const agent_auth_js_1 = require("./agent-auth.js");
44
44
  const context_js_1 = require("./context.js");
45
45
  const config_js_1 = require("./config.js");
46
46
  const auth_store_js_1 = require("./auth-store.js");
47
+ const subject_js_1 = require("./subject.js");
47
48
  const runtime_credential_js_1 = require("./runtime-credential.js");
48
49
  const BROKER_PERMISSION_BATCH_SIZE = 10;
49
50
  function permissionCheckFromRequest(request) {
@@ -261,7 +262,13 @@ class OryAgentClient {
261
262
  || this.config.userSubjectNamespace
262
263
  || "User";
263
264
  if (subject.namespace === ownerNamespace) {
264
- if (subject.object === this.userPrincipal.subject) {
265
+ const userSubject = this.userPrincipal.subject;
266
+ if (subject.object === userSubject) {
267
+ return { kind: "owner", namespace: subject.namespace };
268
+ }
269
+ // A preprovisioned runtime with no local user: the broker derives the
270
+ // owner from the authenticated runtime, and no owner ID is sent.
271
+ if (userSubject === undefined && subject.object === subject_js_1.RUNTIME_OWNER_SUBJECT && this._runtimeContext) {
265
272
  return { kind: "owner", namespace: subject.namespace };
266
273
  }
267
274
  throw httpError(403, "Permission owner does not match the authenticated user");
@@ -43,7 +43,12 @@ export declare class AgentSecurityEventReporter {
43
43
  private scheduled;
44
44
  private activeRequest;
45
45
  private flushDeadline;
46
- private suppressRetry;
46
+ /**
47
+ * Set when a flush deadline passes. The current delivery stops starting new
48
+ * requests so a short-lived hook process can exit on time; undelivered events
49
+ * stay queued (and in the durable outbox) for a later delivery.
50
+ */
51
+ private flushExpired;
47
52
  private retryTimer;
48
53
  private retryAttempt;
49
54
  private readonly retryBaseMs;
@@ -56,6 +61,11 @@ export declare class AgentSecurityEventReporter {
56
61
  enqueue(entry: ActivityEntry, principalKey?: string): void;
57
62
  /** Attempt all currently deliverable batches, bounded by the caller's deadline. */
58
63
  flush(timeoutMs?: number): Promise<void>;
64
+ /**
65
+ * The request timeout shares the flush deadline and can abort a request before
66
+ * the flush timer fires, so check the deadline itself as well as the flag.
67
+ */
68
+ private flushDeadlinePassed;
59
69
  private schedule;
60
70
  private startDelivery;
61
71
  private scheduleRetry;
@@ -99,7 +99,12 @@ class AgentSecurityEventReporter {
99
99
  scheduled = false;
100
100
  activeRequest;
101
101
  flushDeadline;
102
- suppressRetry = false;
102
+ /**
103
+ * Set when a flush deadline passes. The current delivery stops starting new
104
+ * requests so a short-lived hook process can exit on time; undelivered events
105
+ * stay queued (and in the durable outbox) for a later delivery.
106
+ */
107
+ flushExpired = false;
103
108
  retryTimer;
104
109
  retryAttempt = 0;
105
110
  retryBaseMs;
@@ -173,7 +178,7 @@ class AgentSecurityEventReporter {
173
178
  work,
174
179
  new Promise((resolve) => {
175
180
  timer = setTimeout(() => {
176
- this.suppressRetry = true;
181
+ this.flushExpired = true;
177
182
  this.activeRequest?.abort();
178
183
  resolve();
179
184
  }, timeoutMs);
@@ -185,6 +190,13 @@ class AgentSecurityEventReporter {
185
190
  if (this.flushDeadline === deadline)
186
191
  this.flushDeadline = undefined;
187
192
  }
193
+ /**
194
+ * The request timeout shares the flush deadline and can abort a request before
195
+ * the flush timer fires, so check the deadline itself as well as the flag.
196
+ */
197
+ flushDeadlinePassed() {
198
+ return this.flushExpired || (this.flushDeadline !== undefined && Date.now() >= this.flushDeadline);
199
+ }
188
200
  schedule() {
189
201
  if (this.scheduled || this.delivery)
190
202
  return;
@@ -207,7 +219,7 @@ class AgentSecurityEventReporter {
207
219
  void work.finally(() => {
208
220
  if (this.delivery === work)
209
221
  this.delivery = undefined;
210
- this.suppressRetry = false;
222
+ this.flushExpired = false;
211
223
  if (this.queue.length === 0)
212
224
  this.retryAttempt = 0;
213
225
  else if (shouldRetry)
@@ -236,6 +248,8 @@ class AgentSecurityEventReporter {
236
248
  this.prune();
237
249
  const retainedScopes = new Set();
238
250
  while (this.queue.length > 0) {
251
+ if (this.flushDeadlinePassed())
252
+ return true;
239
253
  const first = this.queue.find((pending) => !retainedScopes.has(scopeKey(pendingScope(pending))));
240
254
  if (!first)
241
255
  return shouldRetry;
@@ -280,6 +294,10 @@ class AgentSecurityEventReporter {
280
294
  const remove = new Set();
281
295
  let retry = false;
282
296
  for (const event of batch) {
297
+ if (this.flushDeadlinePassed()) {
298
+ retry = true;
299
+ break;
300
+ }
283
301
  const isolated = await this.deliver([event], scope);
284
302
  for (const eventId of isolated.remove)
285
303
  remove.add(eventId);
@@ -394,49 +394,29 @@ function applyPrincipalBlock(client, ctx) {
394
394
  * must not hinge on the parser.
395
395
  */
396
396
  async function decomposeAndCheck(client, args) {
397
- const top = await checkAndDecide(client, args.check, {
397
+ // The word checks do not depend on the top-level result until aggregation, so
398
+ // send them alongside the top-level check: one round trip instead of two.
399
+ const topPromise = checkAndDecide(client, args.check, {
398
400
  activityAttributes: args.activityAttributes,
399
401
  modeOverride: args.modeOverride,
400
402
  });
403
+ let parsed = await parseShellWords(args);
404
+ const subject = { subjectId: args.check.subjectId, subjectSet: args.check.subjectSet };
405
+ const namespace = (0, tool_catalog_js_1.resolveShellCommandNamespace)();
406
+ // Mirrors the mode checkAndDecide resolves, so a command that will be denied
407
+ // as too-complex in enforce sends no word checks, exactly as before.
408
+ const expectedMode = args.modeOverride ?? (0, config_js_1.resolveConfig)().permissionMode;
409
+ const wordsPromise = parsed.parserUnavailable
410
+ || parsed.words.length === 0
411
+ || parsed.words.length > MAX_SHELL_WORDS
412
+ || (parsed.tooComplex && expectedMode === "enforce")
413
+ ? undefined
414
+ : fetchShellWordResults(client, args, namespace, parsed.words, subject);
415
+ const [top, wordFetch] = await Promise.all([topPromise, wordsPromise]);
401
416
  // Top-level Bash denied (enforce) or unreachable → no point decomposing.
402
417
  if (top.kind === "deny" || top.kind === "fail_open")
403
418
  return top;
404
419
  const mode = top.mode;
405
- // The parser is written to be total, but this is a security boundary: if it
406
- // ever throws, treat it as too-complex (fail-closed in enforce) rather than
407
- // letting the exception bubble to the harness catch, which fails open.
408
- let parsed;
409
- try {
410
- const commands = typeof args.shellCommand === "string"
411
- ? [args.shellCommand]
412
- : args.shellCommand ?? [];
413
- const words = [];
414
- const seen = new Set();
415
- let tooComplex = false;
416
- let parserUnavailable = false;
417
- for (const command of commands) {
418
- const result = await (0, bash_parser_js_1.extractBashCommandWords)(command);
419
- tooComplex ||= result.tooComplex;
420
- parserUnavailable ||= result.parserUnavailable === true;
421
- for (const word of result.words) {
422
- if (!seen.has(word)) {
423
- seen.add(word);
424
- words.push(word);
425
- }
426
- }
427
- }
428
- parsed = { words, tooComplex, parserUnavailable };
429
- }
430
- catch {
431
- parsed = { words: [], tooComplex: true };
432
- }
433
- if (args.toolName.toLowerCase() === "powershell"
434
- || (args.harness === "cline" && args.toolName === "run_commands" && process.platform === "win32")) {
435
- // The bundled grammar models POSIX shell syntax, not PowerShell. Retain its
436
- // Never retain words parsed with the incompatible grammar: PowerShell
437
- // literals could otherwise be persisted as if they were command names.
438
- parsed = { words: [], tooComplex: true, parserUnavailable: false };
439
- }
440
420
  if (parsed.parserUnavailable) {
441
421
  client.logger.warn("bash.parser_unavailable", {
442
422
  harness: args.harness,
@@ -453,7 +433,6 @@ async function decomposeAndCheck(client, args) {
453
433
  if (parsed.words.length > MAX_SHELL_WORDS) {
454
434
  parsed = { words: [], tooComplex: true };
455
435
  }
456
- const subject = { subjectId: args.check.subjectId, subjectSet: args.check.subjectSet };
457
436
  const baseAttrs = buildDecisionAttributes(mode, subject.subjectId, subject.subjectSet);
458
437
  // Decomposition attributes attached to activity so the shell breakdown is
459
438
  // observable in real time — binary/builtin *names* only. The RAW command is
@@ -501,64 +480,34 @@ async function decomposeAndCheck(client, args) {
501
480
  modeOverride: args.modeOverride,
502
481
  });
503
482
  }
504
- // Check each distinct command word. Chunk into batches no larger than Keto's
505
- // `max_batch_check_size` (see DEFAULT_SHELL_BATCH_SIZE) so a word-heavy command
506
- // never trips the server's batch limit into a fail-open fallback.
507
- const namespace = (0, tool_catalog_js_1.resolveShellCommandNamespace)();
508
483
  const words = parsed.words;
509
484
  const deniedWords = [];
510
485
  const affectedWords = [];
511
486
  let sawObserve = false;
512
- const matchedBlocks = new Map();
487
+ let matchedBlocks = new Map();
513
488
  if (words.length > 0) {
514
- const batchSize = resolveShellBatchSize();
515
- const results = [];
516
- for (let start = 0; start < words.length; start += batchSize) {
517
- const chunk = words.slice(start, start + batchSize);
518
- const wordRelation = opl_js_1.PERMIT_USE;
519
- const checks = chunk.map((word) => ({
520
- namespace,
521
- object: word,
522
- relation: wordRelation,
523
- ...subject,
524
- }));
525
- try {
526
- const [batch, blocked] = await Promise.all([
527
- client.batchCheckPermissions(checks, {
528
- activityAttributes: { ...args.activityAttributes, ...decompAttrs, shellDecomposition: true },
529
- }),
530
- findBlockedPrincipals(client, {
531
- namespace,
532
- objects: chunk,
533
- principals: args.principals,
534
- }),
535
- ]);
536
- results.push(...batch.results);
537
- for (const [word, principal] of blocked) {
538
- if (!matchedBlocks.has(word))
539
- matchedBlocks.set(word, principal);
540
- }
541
- }
542
- catch (err) {
543
- const oryErr = err;
544
- if (mode === "enforce" && oryErr && CHECK_UNTRUSTED_CODES.has(oryErr.code)) {
545
- return {
546
- kind: "deny",
547
- result: { allowed: false, checkedAt: new Date().toISOString(), check: args.check },
548
- mode: "enforce",
549
- activityAttributes: { ...baseAttrs, checkRejected: oryErr.code },
550
- };
551
- }
552
- // Infrastructure error — fail open to the top-level decision.
553
- client.logger.warn("bash.batch_check_failed", {
554
- harness: args.harness,
555
- toolName: args.toolName,
556
- code: oryErr?.code,
557
- note: "shell sub-tool batch check failed; falling back to top-level decision",
558
- });
559
- return top;
489
+ const fetched = wordFetch ?? await fetchShellWordResults(client, args, namespace, words, subject);
490
+ if (!fetched.ok) {
491
+ const oryErr = fetched.error;
492
+ if (mode === "enforce" && oryErr && CHECK_UNTRUSTED_CODES.has(oryErr.code)) {
493
+ return {
494
+ kind: "deny",
495
+ result: { allowed: false, checkedAt: new Date().toISOString(), check: args.check },
496
+ mode: "enforce",
497
+ activityAttributes: { ...baseAttrs, checkRejected: oryErr.code },
498
+ };
560
499
  }
500
+ // Infrastructure error — fail open to the top-level decision.
501
+ client.logger.warn("bash.batch_check_failed", {
502
+ harness: args.harness,
503
+ toolName: args.toolName,
504
+ code: oryErr?.code,
505
+ note: "shell sub-tool batch check failed; falling back to top-level decision",
506
+ });
507
+ return top;
561
508
  }
509
+ const results = fetched.results;
510
+ matchedBlocks = fetched.matchedBlocks;
562
511
  for (let i = 0; i < words.length; i++) {
563
512
  const word = words[i];
564
513
  const allowed = results[i]?.allowed ?? false;
@@ -653,6 +602,95 @@ async function decomposeAndCheck(client, args) {
653
602
  activityAttributes: { ...top.activityAttributes, ...decompAttrs },
654
603
  };
655
604
  }
605
+ async function parseShellWords(args) {
606
+ // The parser is written to be total, but this is a security boundary: if it
607
+ // ever throws, treat it as too-complex (fail-closed in enforce) rather than
608
+ // letting the exception bubble to the harness catch, which fails open.
609
+ let parsed;
610
+ try {
611
+ const commands = typeof args.shellCommand === "string"
612
+ ? [args.shellCommand]
613
+ : args.shellCommand ?? [];
614
+ const words = [];
615
+ const seen = new Set();
616
+ let tooComplex = false;
617
+ let parserUnavailable = false;
618
+ for (const command of commands) {
619
+ const result = await (0, bash_parser_js_1.extractBashCommandWords)(command);
620
+ tooComplex ||= result.tooComplex;
621
+ parserUnavailable ||= result.parserUnavailable === true;
622
+ for (const word of result.words) {
623
+ if (!seen.has(word)) {
624
+ seen.add(word);
625
+ words.push(word);
626
+ }
627
+ }
628
+ }
629
+ parsed = { words, tooComplex, parserUnavailable };
630
+ }
631
+ catch {
632
+ parsed = { words: [], tooComplex: true };
633
+ }
634
+ if (args.toolName.toLowerCase() === "powershell"
635
+ || (args.harness === "cline" && args.toolName === "run_commands" && process.platform === "win32")) {
636
+ // The bundled grammar models POSIX shell syntax, not PowerShell. Never
637
+ // retain words parsed with the incompatible grammar: PowerShell literals
638
+ // could otherwise be persisted as if they were command names.
639
+ parsed = { words: [], tooComplex: true, parserUnavailable: false };
640
+ }
641
+ return parsed;
642
+ }
643
+ /**
644
+ * Check each distinct command word. Chunk into batches no larger than Keto's
645
+ * `max_batch_check_size` (see DEFAULT_SHELL_BATCH_SIZE) so a word-heavy command
646
+ * never trips the server's batch limit into a fail-open fallback. Chunks are
647
+ * sent concurrently and aggregated in order, so the first failing chunk decides
648
+ * the outcome exactly as a sequential pass would. Never rejects.
649
+ */
650
+ async function fetchShellWordResults(client, args, namespace, words, subject) {
651
+ const decompAttrs = { shellWords: words, shellCommandCount: words.length };
652
+ const batchSize = resolveShellBatchSize();
653
+ const chunks = [];
654
+ for (let start = 0; start < words.length; start += batchSize) {
655
+ chunks.push(words.slice(start, start + batchSize));
656
+ }
657
+ const outcomes = await Promise.all(chunks.map(async (chunk) => {
658
+ const checks = chunk.map((word) => ({
659
+ namespace,
660
+ object: word,
661
+ relation: opl_js_1.PERMIT_USE,
662
+ ...subject,
663
+ }));
664
+ try {
665
+ const [batch, blocked] = await Promise.all([
666
+ client.batchCheckPermissions(checks, {
667
+ activityAttributes: { ...args.activityAttributes, ...decompAttrs, shellDecomposition: true },
668
+ }),
669
+ findBlockedPrincipals(client, {
670
+ namespace,
671
+ objects: chunk,
672
+ principals: args.principals,
673
+ }),
674
+ ]);
675
+ return { ok: true, results: batch.results, blocked };
676
+ }
677
+ catch (err) {
678
+ return { ok: false, error: err };
679
+ }
680
+ }));
681
+ const results = [];
682
+ const matchedBlocks = new Map();
683
+ for (const outcome of outcomes) {
684
+ if (!outcome.ok)
685
+ return outcome;
686
+ results.push(...outcome.results);
687
+ for (const [word, principal] of outcome.blocked) {
688
+ if (!matchedBlocks.has(word))
689
+ matchedBlocks.set(word, principal);
690
+ }
691
+ }
692
+ return { ok: true, results, matchedBlocks };
693
+ }
656
694
  /**
657
695
  * Run a permission check and resolve the configured mode against the
658
696
  * result. Never throws — infrastructure errors surface as a typed
package/dist/subject.d.ts CHANGED
@@ -23,6 +23,14 @@ import type { OryAgentClient } from "./client.js";
23
23
  * provisioning all use `User`, so it is the out-of-the-box default.
24
24
  */
25
25
  export declare const DEFAULT_USER_SUBJECT_NAMESPACE = "User";
26
+ /**
27
+ * Owner object for a preprovisioned runtime (a Talos, SPIFFE or mTLS agent
28
+ * deployment) that has no local user principal. The client never knows that
29
+ * owner's identity; the broker derives it from the authenticated runtime, so
30
+ * this object is only ever sent as `{ kind: "owner" }`. It is not a UUID, so it
31
+ * cannot collide with a real identity ID.
32
+ */
33
+ export declare const RUNTIME_OWNER_SUBJECT = "runtime-owner";
26
34
  /** Namespace the agent principal is addressed under (delegation subjects). */
27
35
  export declare const AGENT_NAMESPACE = "Agent";
28
36
  /** Namespace a sub-agent principal is addressed under. */
@@ -53,7 +61,8 @@ export declare function runWithUserSubject<T>(subject: string | undefined, fn: (
53
61
  * Resolve the user subject for permission checks. Prefers a per-call
54
62
  * override installed via {@link runWithUserSubject}, then the user
55
63
  * login's `userPrincipal.subject`, falls back to `ORY_USER_SUBJECT_ID`, then the
56
- * legacy `ORY_AGENT_SUBJECT_ID`, then the caller-supplied `fallback`
64
+ * legacy `ORY_AGENT_SUBJECT_ID`, then the deployment owner of a preprovisioned
65
+ * runtime ({@link RUNTIME_OWNER_SUBJECT}), then the caller-supplied `fallback`
57
66
  * (typically `session:<id>`).
58
67
  *
59
68
  * Always returns a SubjectSet. A resolved principal is addressed under the
package/dist/subject.js CHANGED
@@ -18,7 +18,7 @@
18
18
  * toggles between SubjectSet and a legacy direct SubjectID.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.AGENT_SUBJECT_ID_ENV_VAR = exports.SUBJECT_AXIS_SEPARATOR = exports.SESSION_NAMESPACE = exports.SUBAGENT_NAMESPACE = exports.AGENT_NAMESPACE = exports.DEFAULT_USER_SUBJECT_NAMESPACE = void 0;
21
+ exports.AGENT_SUBJECT_ID_ENV_VAR = exports.SUBJECT_AXIS_SEPARATOR = exports.SESSION_NAMESPACE = exports.SUBAGENT_NAMESPACE = exports.AGENT_NAMESPACE = exports.RUNTIME_OWNER_SUBJECT = exports.DEFAULT_USER_SUBJECT_NAMESPACE = void 0;
22
22
  exports.runWithUserSubject = runWithUserSubject;
23
23
  exports.resolveUserSubject = resolveUserSubject;
24
24
  exports.subjectLabel = subjectLabel;
@@ -35,6 +35,14 @@ const node_async_hooks_1 = require("node:async_hooks");
35
35
  * provisioning all use `User`, so it is the out-of-the-box default.
36
36
  */
37
37
  exports.DEFAULT_USER_SUBJECT_NAMESPACE = "User";
38
+ /**
39
+ * Owner object for a preprovisioned runtime (a Talos, SPIFFE or mTLS agent
40
+ * deployment) that has no local user principal. The client never knows that
41
+ * owner's identity; the broker derives it from the authenticated runtime, so
42
+ * this object is only ever sent as `{ kind: "owner" }`. It is not a UUID, so it
43
+ * cannot collide with a real identity ID.
44
+ */
45
+ exports.RUNTIME_OWNER_SUBJECT = "runtime-owner";
38
46
  /** Namespace the agent principal is addressed under (delegation subjects). */
39
47
  exports.AGENT_NAMESPACE = "Agent";
40
48
  /** Namespace a sub-agent principal is addressed under. */
@@ -72,7 +80,8 @@ function subjectSet(namespace, object) {
72
80
  * Resolve the user subject for permission checks. Prefers a per-call
73
81
  * override installed via {@link runWithUserSubject}, then the user
74
82
  * login's `userPrincipal.subject`, falls back to `ORY_USER_SUBJECT_ID`, then the
75
- * legacy `ORY_AGENT_SUBJECT_ID`, then the caller-supplied `fallback`
83
+ * legacy `ORY_AGENT_SUBJECT_ID`, then the deployment owner of a preprovisioned
84
+ * runtime ({@link RUNTIME_OWNER_SUBJECT}), then the caller-supplied `fallback`
76
85
  * (typically `session:<id>`).
77
86
  *
78
87
  * Always returns a SubjectSet. A resolved principal is addressed under the
@@ -94,6 +103,11 @@ function resolveUserSubject(client, fallback) {
94
103
  || exports.DEFAULT_USER_SUBJECT_NAMESPACE;
95
104
  if (principal)
96
105
  return subjectSet(ns, principal);
106
+ // A preprovisioned runtime acts for its deployment's owner, whom only the
107
+ // broker knows. Without this, a headless deployment resolves no subject and
108
+ // every native tool is denied in enforce mode (#338).
109
+ if (client.runtimeContext)
110
+ return subjectSet(ns, exports.RUNTIME_OWNER_SUBJECT);
97
111
  // No principal — use the caller's fallback. A `session:<id>` fallback is
98
112
  // addressed under the Session namespace so it is a well-formed SubjectSet that
99
113
  // could match a Session-scoped grant, rather than being wrapped under `User`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ory/argus",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
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",