@ory/argus 1.3.1 → 1.4.1

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": "850c169a0e1af835b8eeef9fba07c3e39dd7e8c1",
4
- "commitShort": "850c169",
3
+ "commit": "5901fea77c25766073ad2f41fab14875d20f4652",
4
+ "commitShort": "5901fea",
5
5
  "branch": "main",
6
- "commitDate": "2026-09-28T21:20:31-07:00",
6
+ "commitDate": "2026-10-01T13:04:44-07:00",
7
7
  "dirty": false,
8
- "builtAt": "2026-09-29T04:24:31.448Z"
8
+ "builtAt": "2026-10-01T20:08:53.706Z"
9
9
  }
package/dist/lifecycle.js CHANGED
@@ -139,8 +139,8 @@ exports.HARNESS_LIFECYCLE_MAP = {
139
139
  "tool.call": "tool.before",
140
140
  "tool.result": "tool.after",
141
141
  },
142
- // Pi runs session start in the extension factory body (awaited before
143
- // startup); `session_start` is the harness's equivalent event name.
142
+ // Pi's extension factory registers lifecycle handlers; session_start supplies
143
+ // the session manager shared by CLI and SDK tool events.
144
144
  pi: {
145
145
  session_start: "session.start",
146
146
  tool_call: "tool.before",
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ory/argus",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
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",