@ory/argus 0.13.1 → 0.13.3

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": "8a7c3508e54376b6dda634dc54f8c5891d499a7a",
4
- "commitShort": "8a7c350",
3
+ "commit": "c04784bfd67acdce0133aca0ebcd335e326b9251",
4
+ "commitShort": "c04784b",
5
5
  "branch": "main",
6
- "commitDate": "2026-07-14T12:09:14-07:00",
6
+ "commitDate": "2026-07-14T15:46:39-07:00",
7
7
  "dirty": false,
8
- "builtAt": "2026-07-14T19:13:39.008Z"
8
+ "builtAt": "2026-07-14T22:51:01.241Z"
9
9
  }
@@ -650,15 +650,20 @@ async function completeFullSetup(binName, harness, ctx) {
650
650
  * The tuples reference the permission namespace (default `AgentTools`), which
651
651
  * must be defined in the project's permission model or the write fails with
652
652
  * `NotFound`. So we inspect the model first (see {@link inspectPermissionModel})
653
- * and act on what's actually there:
653
+ * and act on what's actually there. We provision in every case *except* when
654
+ * the namespace already exists:
654
655
  *
656
+ * - **Namespace already defined** → grant directly, touch nothing.
655
657
  * - **No model yet** (a freshly created Ory Network project) → offer to
656
658
  * provision a minimal `<namespace>` model (`ory update opl`), then grant.
657
- * - **Namespace already defined**grant directly.
658
- * - **Model exists but defines only *other* namespaces** we can't
659
- * auto-provision, because `ory update opl` overwrites the whole OPL and
660
- * would clobber the existing model. Print the exact snippet to add and skip
661
- * the grant (it would only `NotFound`).
659
+ * - **Model exists (OPL source fetchable) but lacks `<namespace>`** offer
660
+ * to *merge* the namespace into the existing OPL and re-upload it, leaving
661
+ * the other namespaces untouched, then grant. Ory Network serves the OPL
662
+ * source at a `location` URL in the permission config, which makes this
663
+ * non-destructive merge possible.
664
+ * - **Model provably exists but its source couldn't be read** → we can't
665
+ * merge and `ory update opl` overwrites the whole OPL, so we refuse to
666
+ * clobber it: print the exact snippet to add and skip the grant.
662
667
  *
663
668
  * Best-effort throughout: any failure prints a short, actionable note rather
664
669
  * than a wall of errors. Observe mode (the install default) keeps tools working
@@ -670,10 +675,36 @@ async function bootstrapPermissionsViaOry(runner, projectId, harness, subject, p
670
675
  if (tools.length === 0)
671
676
  return;
672
677
  // Ensure the namespace the tuples reference exists, or the write NotFounds.
673
- const modelState = inspectPermissionModel(runner, projectId, namespace);
674
- if (modelState === "empty") {
675
- // No model yet safe to provision a minimal one (prompted).
676
- ui.heading(`This project has no permission model, so '${namespace}' isn't defined yet —`);
678
+ // We only skip provisioning when the namespace is already defined; every
679
+ // other state provisions. When we can fetch the existing OPL source we merge
680
+ // our namespace into it (non-destructive); otherwise we create a fresh
681
+ // minimal model. The one case we refuse is a model that provably exists but
682
+ // whose source we couldn't read — overwriting it would clobber it.
683
+ const model = await inspectPermissionModel(runner, projectId, namespace);
684
+ if (model.kind === "merge") {
685
+ // A model exists and we have its OPL source — append our namespace without
686
+ // disturbing the others (prompted).
687
+ ui.heading(`This project has a permission model, but it doesn't define '${namespace}'.`);
688
+ ui.hint("It can be added to the existing model without changing the other namespaces.");
689
+ ui.blank();
690
+ const answer = await prompt(ui.promptLine(`Add '${namespace}' to this project's permission model now?`, {
691
+ hint: "[Y/n]",
692
+ }));
693
+ if (isNo(answer)) {
694
+ printPermissionModelHelp(namespace);
695
+ return;
696
+ }
697
+ if (!provisionPermissionModel(runner, projectId, namespace, model.opl)) {
698
+ ui.warning(`Could not add '${namespace}' to the permission model automatically.`);
699
+ printPermissionModelHelp(namespace);
700
+ return;
701
+ }
702
+ ui.success(`Added '${namespace}' to the permission model.`);
703
+ }
704
+ else if (model.kind === "empty" || model.kind === "unknown") {
705
+ // No model (or one we couldn't read at all) — provision a minimal one
706
+ // (prompted). Reading the config failing is treated as "nothing there".
707
+ ui.heading(`This project has no '${namespace}' permission model yet —`);
677
708
  ui.hint("without it, permission checks can't be granted or enforced.");
678
709
  ui.blank();
679
710
  const answer = await prompt(ui.promptLine(`Create a minimal '${namespace}' permission model on this project now?`, { hint: "[Y/n]" }));
@@ -688,16 +719,15 @@ async function bootstrapPermissionsViaOry(runner, projectId, harness, subject, p
688
719
  }
689
720
  ui.success(`Created the '${namespace}' permission model.`);
690
721
  }
691
- else if (modelState === "missing") {
692
- // A model exists but defines other namespaces provisioning would
693
- // overwrite it, so guide the user instead of clobbering their model.
694
- ui.heading(`This project has a permission model, but it doesn't define '${namespace}'.`);
695
- ui.hint("Adding it automatically would overwrite the existing model, so we won't.");
722
+ else if (model.kind === "blocked") {
723
+ // A model provably exists but we couldn't fetch its source, so we can't
724
+ // merge and `ory update opl` would overwrite it. Guide instead of clobber.
725
+ ui.heading(`This project has a permission model, but it doesn't define '${namespace}'`);
726
+ ui.hint("and its source couldn't be read, so adding it automatically might overwrite it.");
696
727
  printPermissionModelHelp(namespace);
697
728
  return;
698
729
  }
699
- // "present" (namespace already defined) or "unknown" (couldn't read the
700
- // model) fall through and attempt the grant best-effort.
730
+ // "present" (namespace already defined) falls through and grants directly.
701
731
  const subjectPatch = "subjectSet" in subject
702
732
  ? {
703
733
  subject_set: {
@@ -727,26 +757,13 @@ async function bootstrapPermissionsViaOry(runner, projectId, harness, subject, p
727
757
  printPermissionModelHelp(namespace);
728
758
  }
729
759
  /**
730
- * Inspect the project's permission model to decide how to make `<namespace>`
731
- * grantable:
732
- *
733
- * - `present` — the namespace is already defined; grant directly.
734
- * - `empty` — the project has no model at all. A freshly created Ory
735
- * Network project reports this as an empty list, a `null`, or
736
- * an absent `namespaces` key — all of which mean "nothing
737
- * defined yet", so provisioning a minimal model is safe.
738
- * - `missing` — a model exists and defines *other* namespaces but not this
739
- * one. `ory update opl` overwrites the whole OPL, so we must
740
- * not auto-provision (it would clobber the existing model).
741
- * - `unknown` — the config couldn't be read/parsed; make no assumptions and
742
- * attempt the grant best-effort.
743
- *
744
- * The earlier version only recognized the exact `namespaces: []` shape as
745
- * "empty", so a fresh project reporting `null`/absent namespaces (or one that
746
- * already had unrelated namespaces) silently skipped provisioning and the grant
747
- * then failed with `NotFound`.
760
+ * Inspect the project's permission model. Ory Network reports the config in one
761
+ * of two shapes: `{ namespaces: [] }` (or `null`) for a project with no model,
762
+ * and `{ namespaces: { location: "https://….txt" } }` for an OPL-configured
763
+ * onewhere the `.txt` is the full OPL *source*. We fetch that source so a
764
+ * missing namespace can be merged in rather than clobbering the whole model.
748
765
  */
749
- function inspectPermissionModel(runner, projectId, namespace) {
766
+ async function inspectPermissionModel(runner, projectId, namespace) {
750
767
  const r = runner.exec([
751
768
  "get",
752
769
  "permission-config",
@@ -756,47 +773,75 @@ function inspectPermissionModel(runner, projectId, namespace) {
756
773
  "json",
757
774
  ]);
758
775
  if (r.status !== 0)
759
- return "unknown";
776
+ return { kind: "unknown" };
760
777
  const cfg = safeJsonParse(r.stdout);
761
778
  if (!cfg || typeof cfg !== "object")
762
- return "unknown";
779
+ return { kind: "unknown" };
763
780
  const ns = cfg.namespaces;
764
- // A fresh project reports no model as null / an absent key / an empty list.
781
+ // A fresh project reports no model as null / an absent key.
765
782
  if (ns == null)
766
- return "empty";
767
- if (!Array.isArray(ns))
768
- return "unknown";
769
- if (ns.length === 0)
770
- return "empty";
771
- const names = ns
772
- .map((n) => n && typeof n === "object"
773
- ? n.name
774
- : undefined)
775
- .filter((n) => typeof n === "string");
776
- return names.includes(namespace) ? "present" : "missing";
783
+ return { kind: "empty" };
784
+ // OPL-configured form: { namespaces: { location: "https://….txt" } }.
785
+ // The .txt holds the full OPL source, so we can merge into it.
786
+ if (!Array.isArray(ns) && typeof ns === "object") {
787
+ const location = ns.location;
788
+ if (typeof location !== "string" || !location)
789
+ return { kind: "empty" };
790
+ const opl = await fetchOplSource(location);
791
+ // A model exists but its source is unreadable — can't merge, mustn't clobber.
792
+ if (opl == null)
793
+ return { kind: "blocked" };
794
+ return oplDefinesClass(opl, namespace)
795
+ ? { kind: "present" }
796
+ : { kind: "merge", opl };
797
+ }
798
+ // Inline compiled form: { namespaces: [{ name, id }, …] } (legacy / self-hosted).
799
+ // We only have names, not source, so a missing namespace can't be merged.
800
+ if (Array.isArray(ns)) {
801
+ if (ns.length === 0)
802
+ return { kind: "empty" };
803
+ const names = ns
804
+ .map((n) => n && typeof n === "object"
805
+ ? n.name
806
+ : undefined)
807
+ .filter((n) => typeof n === "string");
808
+ return names.includes(namespace) ? { kind: "present" } : { kind: "blocked" };
809
+ }
810
+ return { kind: "unknown" };
811
+ }
812
+ /** Fetch the OPL source served at a permission-config `location` URL. */
813
+ async function fetchOplSource(url) {
814
+ try {
815
+ const res = await fetch(url);
816
+ if (!res.ok)
817
+ return null;
818
+ return await res.text();
819
+ }
820
+ catch {
821
+ return null;
822
+ }
823
+ }
824
+ /** Whether an OPL source defines `class <name>`. */
825
+ function oplDefinesClass(opl, name) {
826
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
827
+ return new RegExp(`\\bclass\\s+${escaped}\\b`).test(opl);
777
828
  }
778
829
  /**
779
- * Provision a minimal permission model that defines `<namespace>` with a
780
- * `use` relation, via `ory update opl`. The namespace becomes an OPL class, so
781
- * it must be a valid identifier; if not, we bail (caller guides instead).
782
- * Writes the OPL to a temp file because `ory update opl` reads `--file` only
783
- * (no stdin). Returns true on success.
830
+ * Provision a permission model that defines `<namespace>` with a `use`
831
+ * relation, via `ory update opl`. The namespace becomes an OPL class, so it
832
+ * must be a valid identifier; if not, we bail (caller guides instead).
833
+ *
834
+ * When `existingOpl` is given, the namespace is appended to that source
835
+ * (preserving the model's other namespaces); otherwise a fresh minimal model
836
+ * is generated. Writes the OPL to a temp file because `ory update opl` reads
837
+ * `--file` only (no stdin). Returns true on success.
784
838
  */
785
- function provisionPermissionModel(runner, projectId, namespace) {
839
+ function provisionPermissionModel(runner, projectId, namespace, existingOpl) {
786
840
  if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(namespace))
787
841
  return false;
788
- const opl = [
789
- 'import { Namespace } from "@ory/permission-namespace-types"',
790
- "",
791
- "class User implements Namespace {}",
792
- "",
793
- `class ${namespace} implements Namespace {`,
794
- " related: {",
795
- " use: User[]",
796
- " }",
797
- "}",
798
- "",
799
- ].join("\n");
842
+ const opl = existingOpl !== undefined
843
+ ? mergeNamespaceIntoOpl(existingOpl, namespace)
844
+ : freshOpl(namespace);
800
845
  let dir;
801
846
  try {
802
847
  dir = fs.mkdtempSync(path.join(os.tmpdir(), "ory-opl-"));
@@ -832,6 +877,39 @@ function provisionPermissionModel(runner, projectId, namespace) {
832
877
  }
833
878
  }
834
879
  }
880
+ /** A minimal standalone OPL defining `User` and `<namespace>` with `use`. */
881
+ function freshOpl(namespace) {
882
+ return [
883
+ 'import { Namespace } from "@ory/permission-namespace-types"',
884
+ "",
885
+ "class User implements Namespace {}",
886
+ "",
887
+ `class ${namespace} implements Namespace {`,
888
+ " related: {",
889
+ " use: User[]",
890
+ " }",
891
+ "}",
892
+ "",
893
+ ].join("\n");
894
+ }
895
+ /**
896
+ * Append a `<namespace>` class (with a `use: User[]` relation) to an existing
897
+ * OPL source, leaving every other namespace untouched. Adds a `User` class too
898
+ * when the source doesn't already define one, since `use` is typed `User[]`.
899
+ */
900
+ function mergeNamespaceIntoOpl(existingOpl, namespace) {
901
+ let opl = existingOpl.replace(/\s*$/, "\n");
902
+ if (!oplDefinesClass(opl, "User")) {
903
+ opl += "\nclass User implements Namespace {}\n";
904
+ }
905
+ opl +=
906
+ `\nclass ${namespace} implements Namespace {\n` +
907
+ " related: {\n" +
908
+ " use: User[]\n" +
909
+ " }\n" +
910
+ "}\n";
911
+ return opl;
912
+ }
835
913
  function printPermissionModelHelp(namespace) {
836
914
  // When the namespace is a valid OPL class name, show the exact snippet to
837
915
  // paste into the model — that's the concrete "how" behind "define it". The
@@ -1013,13 +1091,39 @@ async function createProjectInteractive(runner, prompt, workspace, harness) {
1013
1091
  }
1014
1092
  /** JSON-Patch path (relative to the oauth2 service config) that toggles DCR. */
1015
1093
  const DCR_ENABLED_PATH = "/oidc/dynamic_client_registration/enabled";
1094
+ /**
1095
+ * Read the project's current DCR state via `ory get oauth2-config`.
1096
+ * Returns `true`/`false` when the value is known, or `null` when it can't be
1097
+ * determined (CLI error, unparseable output) so the caller falls open and
1098
+ * prompts anyway rather than assuming a state.
1099
+ */
1100
+ function dcrEnabled(runner, projectId) {
1101
+ const r = runner.exec([
1102
+ "get",
1103
+ "oauth2-config",
1104
+ "--project",
1105
+ projectId,
1106
+ "--format",
1107
+ "json",
1108
+ ]);
1109
+ if (r.status !== 0)
1110
+ return null;
1111
+ const config = parseObject(r.stdout);
1112
+ if (!config)
1113
+ return null;
1114
+ const enabled = config.oidc?.dynamic_client_registration?.enabled;
1115
+ return typeof enabled === "boolean" ? enabled : null;
1116
+ }
1016
1117
  /**
1017
1118
  * Enable OAuth2 Dynamic Client Registration on the project so the agent
1018
- * identity can self-register (RFC 7591) at runtime. Prompts first (default
1019
- * yes) since it changes project-wide config; best-effort and fail-open a
1020
- * decline or failure just prints the manual command and continues.
1119
+ * identity can self-register (RFC 7591) at runtime. First probes the project's
1120
+ * current DCR state and skips silently when it's already enabled. Otherwise
1121
+ * prompts (default yes) since it changes project-wide config; best-effort and
1122
+ * fail-open — a decline or failure just prints the manual command and continues.
1021
1123
  */
1022
1124
  async function maybeEnableDcr(runner, prompt, projectId) {
1125
+ if (dcrEnabled(runner, projectId) === true)
1126
+ return;
1023
1127
  ui.heading("Dynamic Client Registration");
1024
1128
  ui.info("The agent identity registers itself via OAuth2 Dynamic Client Registration");
1025
1129
  ui.hint("(DCR), which Ory Network projects disable by default.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ory/argus",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
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",