@ory/argus 0.13.9 → 1.0.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.
Files changed (133) hide show
  1. package/README.md +31 -45
  2. package/assets/commands/temporal-up.md +1 -1
  3. package/assets/skills/auth-setup/SKILL.md +1 -1
  4. package/assets/skills/local-dev/SKILL.md +17 -7
  5. package/assets/skills/ory-build-agent/SKILL.md +43 -97
  6. package/assets/skills/ory-e2b-sandbox/SKILL.md +18 -18
  7. package/assets/skills/ory-temporal-worker/SKILL.md +38 -42
  8. package/assets/skills/permissions-onboarding/SKILL.md +131 -104
  9. package/dist/adapters.d.ts +93 -30
  10. package/dist/adapters.js +464 -136
  11. package/dist/agent-auth.d.ts +258 -68
  12. package/dist/agent-auth.js +998 -202
  13. package/dist/auth-store.d.ts +37 -2
  14. package/dist/auth-store.js +37 -3
  15. package/dist/auth.d.ts +40 -4
  16. package/dist/auth.js +247 -19
  17. package/dist/bash-parser.d.ts +98 -0
  18. package/dist/bash-parser.js +396 -0
  19. package/dist/branding.d.ts +128 -0
  20. package/dist/branding.js +151 -0
  21. package/dist/build-info.json +4 -4
  22. package/dist/cli-invocation.d.ts +1 -1
  23. package/dist/cli-invocation.js +2 -1
  24. package/dist/cli.d.ts +20 -29
  25. package/dist/cli.js +271 -278
  26. package/dist/client.d.ts +175 -138
  27. package/dist/client.js +672 -391
  28. package/dist/config.d.ts +249 -57
  29. package/dist/config.js +486 -62
  30. package/dist/context.d.ts +10 -0
  31. package/dist/context.js +21 -0
  32. package/dist/contract-suite.d.ts +8 -8
  33. package/dist/contract-suite.js +88 -69
  34. package/dist/denial.d.ts +36 -3
  35. package/dist/denial.js +79 -10
  36. package/dist/event-reporter.d.ts +77 -0
  37. package/dist/event-reporter.js +776 -0
  38. package/dist/external-registrations-main.d.ts +10 -0
  39. package/dist/external-registrations-main.js +38 -0
  40. package/dist/external-registrations.d.ts +79 -0
  41. package/dist/external-registrations.js +188 -0
  42. package/dist/help-cli.d.ts +39 -0
  43. package/dist/help-cli.js +55 -0
  44. package/dist/hook-timeout.d.ts +64 -0
  45. package/dist/hook-timeout.js +88 -0
  46. package/dist/index.d.ts +31 -19
  47. package/dist/index.js +182 -31
  48. package/dist/lifecycle.d.ts +3 -3
  49. package/dist/lifecycle.js +38 -6
  50. package/dist/local/cli.js +11 -6
  51. package/dist/local/configs.d.ts +74 -18
  52. package/dist/local/configs.js +291 -84
  53. package/dist/local/health.d.ts +14 -0
  54. package/dist/local/health.js +50 -4
  55. package/dist/local/index.d.ts +2 -2
  56. package/dist/local/index.js +24 -10
  57. package/dist/local/manager.d.ts +20 -1
  58. package/dist/local/manager.js +160 -39
  59. package/dist/local/ports.d.ts +158 -0
  60. package/dist/local/ports.js +443 -0
  61. package/dist/local/seed.d.ts +22 -25
  62. package/dist/local/seed.js +88 -56
  63. package/dist/logger.d.ts +54 -25
  64. package/dist/logger.js +329 -63
  65. package/dist/mcp.d.ts +2 -2
  66. package/dist/mcp.js +10 -5
  67. package/dist/mirror-bootstrap.d.ts +48 -0
  68. package/dist/mirror-bootstrap.js +254 -0
  69. package/dist/opl.d.ts +289 -0
  70. package/dist/opl.js +446 -0
  71. package/dist/permission-mode.d.ts +87 -0
  72. package/dist/permission-mode.js +307 -0
  73. package/dist/permissions-cli.d.ts +13 -49
  74. package/dist/permissions-cli.js +154 -348
  75. package/dist/permissions.d.ts +148 -38
  76. package/dist/permissions.js +591 -45
  77. package/dist/post-install.d.ts +33 -0
  78. package/dist/post-install.js +127 -0
  79. package/dist/read-credential.d.ts +65 -0
  80. package/dist/read-credential.js +86 -0
  81. package/dist/registry/cli.js +5 -2
  82. package/dist/registry/config.d.ts +0 -17
  83. package/dist/registry/config.js +0 -23
  84. package/dist/registry/index.d.ts +1 -1
  85. package/dist/registry/index.js +2 -2
  86. package/dist/registry/manager.d.ts +4 -21
  87. package/dist/registry/manager.js +83 -55
  88. package/dist/runtime-credential.d.ts +140 -0
  89. package/dist/runtime-credential.js +572 -0
  90. package/dist/runtime.d.ts +408 -0
  91. package/dist/runtime.js +748 -0
  92. package/dist/setup.d.ts +23 -28
  93. package/dist/setup.js +57 -84
  94. package/dist/status-cli.d.ts +29 -13
  95. package/dist/status-cli.js +124 -144
  96. package/dist/status-data.d.ts +195 -0
  97. package/dist/status-data.js +333 -0
  98. package/dist/status-system.d.ts +24 -0
  99. package/dist/status-system.js +56 -0
  100. package/dist/subject.d.ts +126 -20
  101. package/dist/subject.js +215 -30
  102. package/dist/testing.d.ts +74 -38
  103. package/dist/testing.js +185 -68
  104. package/dist/tool-catalog.d.ts +53 -11
  105. package/dist/tool-catalog.js +164 -13
  106. package/dist/tool-metadata.d.ts +7 -6
  107. package/dist/tool-metadata.js +6 -5
  108. package/dist/types.d.ts +11 -1
  109. package/dist/uninstall.d.ts +74 -19
  110. package/dist/uninstall.js +224 -49
  111. package/dist/user-login.d.ts +22 -16
  112. package/dist/user-login.js +67 -96
  113. package/dist/watch-cli.d.ts +6 -0
  114. package/dist/watch-cli.js +217 -0
  115. package/package.json +3 -11
  116. package/dist/dev.d.ts +0 -103
  117. package/dist/dev.js +0 -584
  118. package/dist/interactive-setup.d.ts +0 -165
  119. package/dist/interactive-setup.js +0 -1546
  120. package/dist/local/jaeger-main.d.ts +0 -13
  121. package/dist/local/jaeger-main.js +0 -85
  122. package/dist/local/jaeger.d.ts +0 -50
  123. package/dist/local/jaeger.js +0 -162
  124. package/dist/otel/exporter.d.ts +0 -17
  125. package/dist/otel/exporter.js +0 -12
  126. package/dist/otel/index.d.ts +0 -2
  127. package/dist/otel/index.js +0 -8
  128. package/dist/otel/otlp.d.ts +0 -103
  129. package/dist/otel/otlp.js +0 -385
  130. package/dist/tracer.d.ts +0 -190
  131. package/dist/tracer.js +0 -481
  132. package/dist/watch-sandbox.d.ts +0 -9
  133. package/dist/watch-sandbox.js +0 -81
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * 1. Runs the check.
11
11
  * 2. On a Keto deny (`result.allowed === false`), applies the mode:
12
- * - `observe` — record a `permission.observe_deny` audit span,
12
+ * - `observe` — record a `permission.observe_deny` audit event,
13
13
  * return `{ kind: "observe", … }` so the caller allows the
14
14
  * tool through.
15
15
  * - `enforce` — return `{ kind: "deny", … }` so the caller blocks.
@@ -29,11 +29,29 @@
29
29
  * mode logic.
30
30
  */
31
31
  Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.resolveCheckRelation = resolveCheckRelation;
32
33
  exports.applyPermissionMode = applyPermissionMode;
33
34
  exports.gateToolCall = gateToolCall;
35
+ exports.decomposeAndCheck = decomposeAndCheck;
34
36
  exports.checkAndDecide = checkAndDecide;
37
+ const bash_parser_js_1 = require("./bash-parser.js");
35
38
  const config_js_1 = require("./config.js");
39
+ const permission_mode_js_1 = require("./permission-mode.js");
40
+ const read_credential_js_1 = require("./read-credential.js");
41
+ const opl_js_1 = require("./opl.js");
42
+ const subject_js_1 = require("./subject.js");
36
43
  const tool_catalog_js_1 = require("./tool-catalog.js");
44
+ /**
45
+ * Map the logical grant relation a caller passes (`use`) onto the relation or
46
+ * permit Argus actually checks. A `use` check always routes through the
47
+ * block-aware `access` permit (`use && !blocked`), so an explicit block is
48
+ * always respected — the permission model must therefore be applied to the
49
+ * project (done in the Ory Console, Agent Security) for checks to resolve. Any
50
+ * other relation passes through unchanged.
51
+ */
52
+ function resolveCheckRelation(relation) {
53
+ return relation === opl_js_1.RELATION_USERS ? opl_js_1.PERMIT_USE : relation;
54
+ }
37
55
  /**
38
56
  * Error codes meaning the check call itself was rejected for
39
57
  * authentication/authorization reasons — an expired or misconfigured
@@ -42,66 +60,109 @@ const tool_catalog_js_1 = require("./tool-catalog.js");
42
60
  * failing open. Infrastructure errors (`network_error`, `rate_limited`,
43
61
  * `not_found`, `unknown`) keep the fail-open posture.
44
62
  */
45
- const CHECK_AUTH_REJECTED_CODES = new Set([
63
+ const CHECK_UNTRUSTED_CODES = new Set([
64
+ // The check reached Ory and was refused — an expired or misconfigured agent
65
+ // credential, an inactive session, a missing MFA step.
46
66
  "forbidden",
47
67
  "session_inactive",
48
68
  "session_aal2_required",
69
+ // TLS/certificate verification failed, so we cannot establish that we spoke to
70
+ // Ory at all. Strictly less trustworthy than the three above — with those we at
71
+ // least know who refused us. Treating it as a transport blip (fail open) meant a
72
+ // cert problem silently downgraded an `enforce` project to `observe`, and
73
+ // breaking TLS was sufficient to disable all tool gating (#218).
74
+ "tls_error",
49
75
  ]);
76
+ /**
77
+ * Max distinct command words checked per shell command (issue #76). A command
78
+ * with more than this is pathological (no legitimate command runs 100+ distinct
79
+ * binaries). Past this we mark the command too-complex (fail-closed in enforce)
80
+ * rather than issuing an unbounded number of batch round-trips.
81
+ */
82
+ const MAX_SHELL_WORDS = 100;
83
+ /**
84
+ * Words per Keto `batchCheckPermission` request. Keto caps a batch at
85
+ * `limit.max_batch_check_size` (default **10**); a batch over that limit is
86
+ * rejected with a 400 that would classify as `unknown` and fail the gate OPEN —
87
+ * silently defeating decomposition, and trivially exploitable by padding a
88
+ * command with >10 harmless distinct words. So we chunk the word list into
89
+ * requests of this size and aggregate. Default 10 (Keto's floor); raise via
90
+ * `ORY_SHELL_BATCH_SIZE` when the project configures a larger Keto limit.
91
+ */
92
+ const DEFAULT_SHELL_BATCH_SIZE = 10;
93
+ function resolveShellBatchSize() {
94
+ const raw = process.env.ORY_SHELL_BATCH_SIZE;
95
+ if (!raw)
96
+ return DEFAULT_SHELL_BATCH_SIZE;
97
+ const n = Number.parseInt(raw, 10);
98
+ if (!Number.isFinite(n) || n < 1)
99
+ return DEFAULT_SHELL_BATCH_SIZE;
100
+ return n;
101
+ }
50
102
  function formatSubjectSet(set) {
51
103
  if (!set)
52
104
  return undefined;
53
105
  return `${set.namespace}:${set.object}#${set.relation}`;
54
106
  }
55
- function buildDecisionAttributes(mode, subjectId, subjectSet) {
107
+ function buildDecisionAttributes(mode, subjectId, subjectSet, blockReason) {
56
108
  const set = formatSubjectSet(subjectSet);
57
- // SubjectSet supersedes subjectId in the span (the printable label is
109
+ const reason = blockReason ? { blockReason } : {};
110
+ // SubjectSet supersedes subjectId in activity (the printable label is
58
111
  // derivable from it); avoid emitting both so consumers don't need to
59
112
  // dedupe.
60
113
  if (set)
61
- return { permissionMode: mode, subjectSet: set };
114
+ return { permissionMode: mode, subjectSet: set, ...reason };
62
115
  if (subjectId)
63
- return { permissionMode: mode, subjectId };
64
- return { permissionMode: mode };
116
+ return { permissionMode: mode, subjectId, ...reason };
117
+ return { permissionMode: mode, ...reason };
65
118
  }
66
119
  /**
67
120
  * Map an `allowed` boolean from any permission check (plain or MCP)
68
121
  * onto a {@link ModeDecision}. When `allowed === false` and the mode
69
- * is `observe`, emits the `permission.observe_deny` audit span and
122
+ * is `observe`, emits the `permission.observe_deny` audit event and
70
123
  * returns `{ kind: "observe" }` so the caller knows to let the action
71
124
  * proceed despite the deny.
72
125
  */
73
126
  function applyPermissionMode(client, allowed, context = {}) {
74
127
  const mode = context.modeOverride ?? (0, config_js_1.resolveConfig)().permissionMode;
75
- const spanAttributes = buildDecisionAttributes(mode, context.subjectId, context.subjectSet);
128
+ const activityAttributes = buildDecisionAttributes(mode, context.subjectId, context.subjectSet, context.blockReason);
76
129
  if (allowed)
77
- return { kind: "allow", mode, spanAttributes };
130
+ return { kind: "allow", mode, activityAttributes };
131
+ const isBlock = context.blockReason === "explicit_block";
78
132
  if (mode === "observe") {
79
- client.logger.warn("permission.observe_deny", {
133
+ // An explicit block is still respected *as an observe-mode deny* — it is
134
+ // logged as a distinct `permission.block_observed` event (so an admin can
135
+ // see the block would have fired) but, per the observe posture, the tool
136
+ // still proceeds. Promote to enforce to make blocks actually stop tools.
137
+ const event = isBlock ? "permission.block_observed" : "permission.observe_deny";
138
+ client.logger.warn(event, {
80
139
  namespace: context.namespace,
81
140
  object: context.object,
82
141
  relation: context.relation,
83
142
  subjectId: context.subjectId,
84
- subjectSet: spanAttributes.subjectSet,
85
- note: "denied by Ory; allowed by plugin in observe mode",
143
+ subjectSet: activityAttributes.subjectSet,
144
+ note: isBlock
145
+ ? "explicitly blocked by Ory; allowed by plugin in observe mode"
146
+ : "denied by Ory; allowed by plugin in observe mode",
86
147
  });
87
- client.tracer.record("permission.observe_deny", "denied", {
148
+ client.logger.activity(event, "denied", {
88
149
  attributes: {
89
150
  namespace: context.namespace,
90
151
  object: context.object,
91
152
  relation: context.relation,
92
- ...spanAttributes,
93
- ...context.spanAttributes,
153
+ ...activityAttributes,
154
+ ...context.activityAttributes,
94
155
  },
95
156
  });
96
- return { kind: "observe", mode: "observe", spanAttributes };
157
+ return { kind: "observe", mode: "observe", activityAttributes };
97
158
  }
98
- return { kind: "deny", mode: "enforce", spanAttributes };
159
+ return { kind: "deny", mode: "enforce", activityAttributes };
99
160
  }
100
161
  /**
101
- * Single entry point for the pre-tool-use gate. Applies the
102
- * `config.auditOnly` kill switch first — when set, Ory is disabled
103
- * entirely and the caller gets `{ kind: "audit_only" }` back without any
104
- * permission check (record the invocation as an audit span and proceed).
162
+ * Single entry point for the pre-tool-use gate. Checks whether Agent Security
163
+ * is connected first — with no project URL there is
164
+ * nothing to check against, so the caller gets `{ kind: "not_connected" }`
165
+ * back and should record the invocation as an audit event and proceed.
105
166
  * Otherwise splits the harness's incoming "tool" into two semantic
106
167
  * categories:
107
168
  *
@@ -111,7 +172,7 @@ function applyPermissionMode(client, allowed, context = {}) {
111
172
  * through Ory: the user is the decision-maker, and blocking them in
112
173
  * enforce mode (or logging a misleading observe-deny) hides the very
113
174
  * prompt the user needs to see. We record one `user.interaction`
114
- * audit span and return — no permission check, no `tool.invoke`,
175
+ * audit event and return — no permission check, no `tool.invoke`,
115
176
  * no `tool.block`.
116
177
  *
117
178
  * - **Execution** — every other tool. Delegates to
@@ -123,15 +184,17 @@ function applyPermissionMode(client, allowed, context = {}) {
123
184
  * semantics; the new `interactive` kind means "do nothing else."
124
185
  */
125
186
  async function gateToolCall(client, args) {
126
- if ((0, config_js_1.resolveConfig)().auditOnly) {
127
- client.logger.debug("tool.audit_only", {
187
+ const security = (0, config_js_1.resolveConfig)().security;
188
+ if (!security.connected) {
189
+ client.logger.debug("tool.security_not_connected", {
128
190
  harness: args.harness,
129
191
  toolName: args.toolName,
130
- note: "audit-only mode — skipping permission check",
192
+ missing: security.missing,
193
+ note: "Agent Security is not connected — skipping permission check",
131
194
  });
132
195
  return {
133
- kind: "audit_only",
134
- spanAttributes: { auditOnly: true, toolName: args.toolName },
196
+ kind: "not_connected",
197
+ activityAttributes: { securityConnected: false, toolName: args.toolName },
135
198
  };
136
199
  }
137
200
  if ((0, tool_catalog_js_1.isInteractiveTool)(args.harness, args.toolName)) {
@@ -140,43 +203,406 @@ async function gateToolCall(client, args) {
140
203
  toolName: args.toolName,
141
204
  note: "user-interaction primitive — skipping permission check",
142
205
  });
143
- client.tracer.record("user.interaction", "ok", {
206
+ client.logger.activity("user.interaction", "ok", {
144
207
  attributes: {
145
208
  harness: args.harness,
146
209
  toolName: args.toolName,
147
210
  kind: "tool",
148
- ...args.spanAttributes,
211
+ ...args.activityAttributes,
149
212
  },
150
213
  });
151
214
  return {
152
215
  kind: "interactive",
153
- spanAttributes: { interactive: true, toolName: args.toolName },
216
+ activityAttributes: { interactive: true, toolName: args.toolName },
217
+ };
218
+ }
219
+ // Every read below is authenticated by Ory's Oathkeeper `/user` rule, so the
220
+ // client needs an access token before the first one goes out. On a subprocess
221
+ // harness this event's process has never run the agent gate, so without this
222
+ // the only credential is the user's persisted token — and once its hour is up
223
+ // every read 401s: `session_inactive` denies every tool under `enforce`, and
224
+ // under `observe` stops checking anything at all (#242).
225
+ await (0, read_credential_js_1.ensureReadCredential)(client, { harness: args.harness });
226
+ // Resolve the mode ONCE for this gate — a per-principal Keto permission read
227
+ // (server → cache → observe), cached with a short TTL. Threaded downstream as
228
+ // `modeOverride` so the whole gate (top-level check + every decomposed shell
229
+ // word) decides against one consistent mode. An explicit `modeOverride`
230
+ // (tests) preempts the server read.
231
+ const mode = args.modeOverride ??
232
+ (await (0, permission_mode_js_1.warmPermissionModeCache)(client, {
233
+ subjectId: args.check.subjectId,
234
+ subjectSet: args.check.subjectSet,
235
+ }, {
236
+ additionalSubjects: [
237
+ { scope: "agent", subject: (0, subject_js_1.resolveAgentSubject)(client) },
238
+ { scope: "subagent", subject: (0, subject_js_1.resolveSubAgentSubject)(args.principals?.subAgentClientId) },
239
+ ].filter((item) => item.subject !== undefined),
240
+ })).mode;
241
+ // An explicit block on the acting agent / session / sub-agent / spawn denies
242
+ // regardless of the user's grants. Run it alongside the user decision rather
243
+ // than before it, so the extra read costs no serial latency, and let a block
244
+ // override an allow afterwards.
245
+ const [decision, blocked] = await Promise.all([
246
+ args.shellCommand && (0, tool_catalog_js_1.isShellTool)(args.harness, args.toolName)
247
+ ? // Shell tools (issue #76): decompose the command into per-word sub-checks
248
+ // in addition to the top-level tool check. Only when the harness marks
249
+ // this a shell tool AND the raw command was threaded through — otherwise
250
+ // fall back to the plain single check (never breaks).
251
+ decomposeAndCheck(client, { ...args, modeOverride: mode })
252
+ : checkAndDecide(client, args.check, {
253
+ activityAttributes: args.activityAttributes,
254
+ modeOverride: mode,
255
+ }),
256
+ findBlockedPrincipal(client, {
257
+ namespace: args.check.namespace,
258
+ object: args.check.object,
259
+ principals: args.principals,
260
+ }),
261
+ ]);
262
+ if (!blocked)
263
+ return decision;
264
+ // Already denied on the user's own check — nothing to add to the outcome, but
265
+ // the block is worth recording as the stronger reason it would not have run.
266
+ if (decision.kind === "deny") {
267
+ return {
268
+ ...decision,
269
+ activityAttributes: {
270
+ ...decision.activityAttributes,
271
+ blockReason: "explicit_block",
272
+ blockedPrincipal: blocked.subject,
273
+ blockedPrincipalLevel: blocked.level,
274
+ },
154
275
  };
155
276
  }
156
- return checkAndDecide(client, args.check, {
157
- spanAttributes: args.spanAttributes,
277
+ return applyPrincipalBlock(client, { args, mode, decision, blocked });
278
+ }
279
+ /**
280
+ * Turn a machine-principal block into the gate's outcome, respecting
281
+ * `permissionMode` exactly as any other deny does: `enforce` blocks, `observe`
282
+ * records `permission.block_observed` and passes the tool through.
283
+ *
284
+ * The synthesized `PermissionResult` reports the *blocked principal* as the
285
+ * subject, not the user — the user may well be fully granted, and an audit trail
286
+ * that named them would send an admin looking at the wrong relation.
287
+ */
288
+ function applyPrincipalBlock(client, ctx) {
289
+ const { args, mode, blocked } = ctx;
290
+ const inner = applyPermissionMode(client, false, {
291
+ namespace: args.check.namespace,
292
+ object: args.check.object,
293
+ relation: opl_js_1.RELATION_BLOCKED_SUBJECTS,
294
+ subjectId: blocked.subject,
295
+ blockReason: "explicit_block",
296
+ modeOverride: mode,
297
+ activityAttributes: {
298
+ ...args.activityAttributes,
299
+ blockedPrincipal: blocked.subject,
300
+ blockedPrincipalLevel: blocked.level,
301
+ },
302
+ });
303
+ const result = {
304
+ allowed: false,
305
+ checkedAt: new Date().toISOString(),
306
+ check: {
307
+ namespace: args.check.namespace,
308
+ object: args.check.object,
309
+ relation: opl_js_1.RELATION_BLOCKED_SUBJECTS,
310
+ subjectId: blocked.subject,
311
+ },
312
+ };
313
+ const activityAttributes = {
314
+ ...inner.activityAttributes,
315
+ blockedPrincipal: blocked.subject,
316
+ blockedPrincipalLevel: blocked.level,
317
+ };
318
+ return inner.kind === "observe"
319
+ ? { kind: "observe", result, mode: "observe", activityAttributes }
320
+ : { kind: "deny", result, mode: "enforce", activityAttributes };
321
+ }
322
+ /**
323
+ * Gate a shell tool by decomposing its command into the program/builtin words
324
+ * that will run and checking each as `ShellTool:<word>#use`, on top of the
325
+ * existing top-level tool check.
326
+ *
327
+ * Aggregation: **any deny → deny** (naming the offending word); else any observe
328
+ * → observe (pass through, per-word `permission.observe_deny` events recorded);
329
+ * else allow. A too-complex / unparseable command is **fail-closed** in enforce
330
+ * (deny + alert) and logged-then-passed in observe. A parser-unavailable or
331
+ * infrastructure error is **fail-open** (top-level decision only) — availability
332
+ * must not hinge on the parser.
333
+ */
334
+ async function decomposeAndCheck(client, args) {
335
+ const top = await checkAndDecide(client, args.check, {
336
+ activityAttributes: args.activityAttributes,
158
337
  modeOverride: args.modeOverride,
159
338
  });
339
+ // Top-level Bash denied (enforce) or unreachable → no point decomposing.
340
+ if (top.kind === "deny" || top.kind === "fail_open")
341
+ return top;
342
+ const mode = top.mode;
343
+ // The parser is written to be total, but this is a security boundary: if it
344
+ // ever throws, treat it as too-complex (fail-closed in enforce) rather than
345
+ // letting the exception bubble to the harness catch, which fails open.
346
+ let parsed;
347
+ try {
348
+ parsed = await (0, bash_parser_js_1.extractBashCommandWords)(args.shellCommand ?? "");
349
+ }
350
+ catch {
351
+ parsed = { words: [], tooComplex: true };
352
+ }
353
+ if (parsed.parserUnavailable) {
354
+ client.logger.warn("bash.parser_unavailable", {
355
+ harness: args.harness,
356
+ toolName: args.toolName,
357
+ note: "shell parser unavailable; falling back to top-level tool check",
358
+ });
359
+ client.logger.activity("bash.parser_unavailable", "skipped", {
360
+ attributes: { harness: args.harness, toolName: args.toolName },
361
+ });
362
+ return top;
363
+ }
364
+ // Too many distinct words to check safely in one batch — fail closed rather
365
+ // than send an oversized request that could error into a fail-open fallback.
366
+ if (parsed.words.length > MAX_SHELL_WORDS) {
367
+ parsed = { words: [], tooComplex: true };
368
+ }
369
+ const subject = { subjectId: args.check.subjectId, subjectSet: args.check.subjectSet };
370
+ const baseAttrs = buildDecisionAttributes(mode, subject.subjectId, subject.subjectSet);
371
+ // Decomposition attributes attached to activity so the shell breakdown is
372
+ // observable in real time — binary/builtin *names* only. The RAW command is
373
+ // NEVER put in activity; it is written only to the local debug log below,
374
+ // gated by
375
+ // `ORY_AGENT_DEBUG`.
376
+ const decompAttrs = {
377
+ shellWords: parsed.words,
378
+ shellCommandCount: parsed.words.length,
379
+ };
380
+ // Raw command → local debug log only (DebugLogger writes to stderr + the
381
+ // optional local log file, and only when ORY_AGENT_DEBUG=true). This keeps
382
+ // the raw command entirely out of always-filed activity.
383
+ client.logger.debug("bash.decomposition", {
384
+ harness: args.harness,
385
+ toolName: args.toolName,
386
+ command: args.shellCommand,
387
+ words: parsed.words,
388
+ tooComplex: parsed.tooComplex,
389
+ });
390
+ // Too-complex → fail-closed in enforce, logged-and-passed in observe.
391
+ if (parsed.tooComplex) {
392
+ if (mode === "enforce") {
393
+ client.logger.warn("bash.too_complex", {
394
+ harness: args.harness,
395
+ toolName: args.toolName,
396
+ note: "shell command could not be safely parsed; enforce mode denies",
397
+ });
398
+ return {
399
+ kind: "deny",
400
+ result: { allowed: false, checkedAt: new Date().toISOString(), check: args.check },
401
+ mode: "enforce",
402
+ activityAttributes: { ...baseAttrs, ...decompAttrs, shellTooComplex: true },
403
+ };
404
+ }
405
+ // observe: record the deny signal, then continue (a partial word set may
406
+ // still be present and worth logging below).
407
+ applyPermissionMode(client, false, {
408
+ namespace: args.check.namespace,
409
+ object: args.toolName,
410
+ relation: args.check.relation,
411
+ subjectId: subject.subjectId,
412
+ subjectSet: subject.subjectSet,
413
+ activityAttributes: { ...args.activityAttributes, ...decompAttrs, shellTooComplex: true },
414
+ modeOverride: args.modeOverride,
415
+ });
416
+ }
417
+ // Check each distinct command word. Chunk into batches no larger than Keto's
418
+ // `max_batch_check_size` (see DEFAULT_SHELL_BATCH_SIZE) so a word-heavy command
419
+ // never trips the server's batch limit into a fail-open fallback.
420
+ const namespace = (0, tool_catalog_js_1.resolveShellCommandNamespace)();
421
+ const words = parsed.words;
422
+ const deniedWords = [];
423
+ const affectedWords = [];
424
+ let sawObserve = false;
425
+ const matchedBlocks = new Map();
426
+ if (words.length > 0) {
427
+ const batchSize = resolveShellBatchSize();
428
+ const results = [];
429
+ for (let start = 0; start < words.length; start += batchSize) {
430
+ const chunk = words.slice(start, start + batchSize);
431
+ // Route each word through the block-aware `access` permit (like the
432
+ // top-level check), so an explicit `ShellTool:<word>#blocked` overrides
433
+ // a `use` grant. With no block present, `access` == bare `use`.
434
+ const wordRelation = resolveCheckRelation("users");
435
+ const checks = chunk.map((word) => ({
436
+ namespace,
437
+ object: word,
438
+ relation: wordRelation,
439
+ ...subject,
440
+ }));
441
+ try {
442
+ const [batch, blocked] = await Promise.all([
443
+ client.batchCheckPermissions(checks, {
444
+ activityAttributes: { ...args.activityAttributes, ...decompAttrs, shellDecomposition: true },
445
+ }),
446
+ findBlockedPrincipals(client, {
447
+ namespace,
448
+ objects: chunk,
449
+ principals: args.principals,
450
+ }),
451
+ ]);
452
+ results.push(...batch.results);
453
+ for (const [word, principal] of blocked) {
454
+ if (!matchedBlocks.has(word))
455
+ matchedBlocks.set(word, principal);
456
+ }
457
+ }
458
+ catch (err) {
459
+ const oryErr = err;
460
+ if (mode === "enforce" && oryErr && CHECK_UNTRUSTED_CODES.has(oryErr.code)) {
461
+ return {
462
+ kind: "deny",
463
+ result: { allowed: false, checkedAt: new Date().toISOString(), check: args.check },
464
+ mode: "enforce",
465
+ activityAttributes: { ...baseAttrs, checkRejected: oryErr.code },
466
+ };
467
+ }
468
+ // Infrastructure error — fail open to the top-level decision.
469
+ client.logger.warn("bash.batch_check_failed", {
470
+ harness: args.harness,
471
+ toolName: args.toolName,
472
+ code: oryErr?.code,
473
+ note: "shell sub-tool batch check failed; falling back to top-level decision",
474
+ });
475
+ return top;
476
+ }
477
+ }
478
+ for (let i = 0; i < words.length; i++) {
479
+ const word = words[i];
480
+ const allowed = results[i]?.allowed ?? false;
481
+ const blocked = matchedBlocks.get(word);
482
+ const decision = applyPermissionMode(client, blocked ? false : allowed, {
483
+ namespace,
484
+ object: word,
485
+ relation: blocked ? opl_js_1.RELATION_BLOCKED_SUBJECTS : opl_js_1.RELATION_USERS,
486
+ subjectId: blocked?.subject ?? subject.subjectId,
487
+ subjectSet: blocked ? undefined : subject.subjectSet,
488
+ activityAttributes: {
489
+ ...args.activityAttributes,
490
+ shellWord: word,
491
+ ...(blocked ? {
492
+ blockedPrincipal: blocked.subject,
493
+ blockedPrincipalLevel: blocked.level,
494
+ } : {}),
495
+ },
496
+ modeOverride: args.modeOverride,
497
+ blockReason: blocked ? "explicit_block" : undefined,
498
+ });
499
+ if (decision.kind === "deny") {
500
+ deniedWords.push(word);
501
+ affectedWords.push(word);
502
+ }
503
+ else if (decision.kind === "observe") {
504
+ affectedWords.push(word);
505
+ sawObserve = true;
506
+ }
507
+ }
508
+ }
509
+ const primaryBlockedWord = words.find((word) => matchedBlocks.has(word));
510
+ const primaryBlock = primaryBlockedWord ? matchedBlocks.get(primaryBlockedWord) : undefined;
511
+ // Aggregate. Enforce: any denied word blocks. Observe: pass through.
512
+ if (deniedWords.length > 0) {
513
+ // Disambiguate an explicit block from a plain missing grant on the first
514
+ // denied word, so the deny event and denial message say "explicitly blocked".
515
+ const primaryWord = primaryBlockedWord ?? deniedWords[0];
516
+ const blockReason = primaryBlock
517
+ ? "explicit_block"
518
+ : await probeBlockReason(client, {
519
+ namespace,
520
+ object: primaryWord,
521
+ relation: opl_js_1.RELATION_USERS,
522
+ ...subject,
523
+ });
524
+ return {
525
+ kind: "deny",
526
+ result: {
527
+ allowed: false,
528
+ checkedAt: new Date().toISOString(),
529
+ check: primaryBlock
530
+ ? {
531
+ namespace,
532
+ object: primaryWord,
533
+ relation: opl_js_1.RELATION_BLOCKED_SUBJECTS,
534
+ subjectId: primaryBlock.subject,
535
+ }
536
+ : { namespace, object: primaryWord, relation: opl_js_1.RELATION_USERS, ...subject },
537
+ },
538
+ mode: "enforce",
539
+ activityAttributes: {
540
+ ...baseAttrs,
541
+ ...decompAttrs,
542
+ shellWord: primaryWord,
543
+ shellDeniedWords: deniedWords,
544
+ blockReason,
545
+ ...(primaryBlock ? {
546
+ blockedPrincipal: primaryBlock.subject,
547
+ blockedPrincipalLevel: primaryBlock.level,
548
+ } : {}),
549
+ },
550
+ };
551
+ }
552
+ if (top.kind === "observe" || sawObserve || parsed.tooComplex) {
553
+ return {
554
+ kind: "observe",
555
+ result: top.result,
556
+ mode: "observe",
557
+ activityAttributes: {
558
+ ...baseAttrs,
559
+ ...decompAttrs,
560
+ ...(parsed.tooComplex ? { shellTooComplex: true } : {}),
561
+ ...(affectedWords.length > 0 ? { shellDeniedWords: affectedWords } : {}),
562
+ ...(primaryBlock && primaryBlockedWord ? {
563
+ shellWord: primaryBlockedWord,
564
+ blockReason: "explicit_block",
565
+ blockedPrincipal: primaryBlock.subject,
566
+ blockedPrincipalLevel: primaryBlock.level,
567
+ } : {}),
568
+ },
569
+ };
570
+ }
571
+ // Allow: still surface the decomposition so an allowed command's breakdown is
572
+ // observable too (not just denials).
573
+ return {
574
+ kind: "allow",
575
+ result: top.result,
576
+ mode: top.mode,
577
+ activityAttributes: { ...top.activityAttributes, ...decompAttrs },
578
+ };
160
579
  }
161
580
  /**
162
581
  * Run a permission check and resolve the configured mode against the
163
582
  * result. Never throws — infrastructure errors surface as a typed
164
583
  * `{ kind: "fail_open" }` decision, and auth-rejected check calls in
165
584
  * enforce mode as `{ kind: "deny" }` (see
166
- * {@link CHECK_AUTH_REJECTED_CODES}).
585
+ * {@link CHECK_UNTRUSTED_CODES}).
167
586
  */
168
587
  async function checkAndDecide(client, check, opts = {}) {
169
- const mode = opts.modeOverride ?? (0, config_js_1.resolveConfig)().permissionMode;
170
- const spanAttributes = buildDecisionAttributes(mode, check.subjectId, check.subjectSet);
588
+ const resolved = (0, config_js_1.resolveConfig)();
589
+ const mode = opts.modeOverride ?? resolved.permissionMode;
590
+ const activityAttributes = buildDecisionAttributes(mode, check.subjectId, check.subjectSet);
591
+ // Route a `use` grant check through the block-aware `access` permit so
592
+ // explicit blocks are always respected.
593
+ const effectiveCheck = {
594
+ ...check,
595
+ relation: resolveCheckRelation(check.relation),
596
+ };
171
597
  let result;
172
598
  try {
173
- result = await client.checkPermission(check, {
174
- spanAttributes: opts.spanAttributes,
599
+ result = await client.checkPermission(effectiveCheck, {
600
+ activityAttributes: opts.activityAttributes,
175
601
  });
176
602
  }
177
603
  catch (err) {
178
604
  const oryErr = err;
179
- if (mode === "enforce" && oryErr && CHECK_AUTH_REJECTED_CODES.has(oryErr.code)) {
605
+ if (mode === "enforce" && oryErr && CHECK_UNTRUSTED_CODES.has(oryErr.code)) {
180
606
  // The check call itself was rejected for auth reasons — the
181
607
  // result cannot be trusted, so enforce mode must not fail open.
182
608
  client.logger.warn("permission.check_rejected", {
@@ -190,23 +616,143 @@ async function checkAndDecide(client, check, opts = {}) {
190
616
  kind: "deny",
191
617
  result: { allowed: false, checkedAt: new Date().toISOString(), check },
192
618
  mode: "enforce",
193
- spanAttributes: { ...spanAttributes, checkRejected: oryErr.code },
619
+ activityAttributes: { ...activityAttributes, checkRejected: oryErr.code },
194
620
  };
195
621
  }
196
- return { kind: "fail_open", error: oryErr, mode, spanAttributes };
622
+ return { kind: "fail_open", error: oryErr, mode, activityAttributes };
197
623
  }
624
+ // Report the decision against the caller's original check (relation `use`),
625
+ // not the internal permit rewrite, so callers see the coordinates they passed.
626
+ result = { ...result, check };
627
+ // On a deny, disambiguate "explicitly blocked" from "never granted" so the
628
+ // audit trail and denial messaging can tell them apart. Best-effort.
629
+ const blockReason = result.allowed
630
+ ? undefined
631
+ : await probeBlockReason(client, check);
198
632
  const inner = applyPermissionMode(client, result.allowed, {
199
633
  namespace: check.namespace,
200
634
  object: check.object,
201
635
  relation: check.relation,
202
636
  subjectId: check.subjectId,
203
637
  subjectSet: check.subjectSet,
204
- spanAttributes: opts.spanAttributes,
638
+ activityAttributes: opts.activityAttributes,
205
639
  modeOverride: opts.modeOverride,
640
+ blockReason,
206
641
  });
207
642
  if (inner.kind === "allow")
208
- return { kind: "allow", result, mode: inner.mode, spanAttributes: inner.spanAttributes };
643
+ return { kind: "allow", result, mode: inner.mode, activityAttributes: inner.activityAttributes };
209
644
  if (inner.kind === "observe")
210
- return { kind: "observe", result, mode: "observe", spanAttributes: inner.spanAttributes };
211
- return { kind: "deny", result, mode: "enforce", spanAttributes: inner.spanAttributes };
645
+ return { kind: "observe", result, mode: "observe", activityAttributes: inner.activityAttributes };
646
+ return { kind: "deny", result, mode: "enforce", activityAttributes: inner.activityAttributes };
647
+ }
648
+ const PROJECT_BLOCK_SUBJECT = {
649
+ subjectSet: {
650
+ namespace: opl_js_1.PERMISSION_MODE_NAMESPACE,
651
+ object: opl_js_1.PROJECT_POSTURE_OBJECT,
652
+ relation: opl_js_1.RELATION_ENFORCED_SUBJECTS,
653
+ },
654
+ };
655
+ /**
656
+ * Check whether the project or any acting machine principal is explicitly blocked from this
657
+ * tool, and name the one that is.
658
+ *
659
+ * Project checks are sent separately from the credential-, session-, and
660
+ * spawn-level forms from `subject.ts`. Older brokers do not understand project
661
+ * tool blocks; isolating that read keeps their rejection from suppressing the
662
+ * established machine-principal controls during an asynchronous rollout.
663
+ *
664
+ * This can only ever *remove* access: the user's `access` permit remains the
665
+ * allow decision, so a project that has written no principal blocks behaves
666
+ * exactly as it did before this pass existed.
667
+ *
668
+ * Each read fails open independently. The merged result is empty only when no
669
+ * successful scope reports a block; a project-read failure cannot erase a
670
+ * machine hit, and a machine-read failure cannot erase a project hit.
671
+ */
672
+ async function findBlockedPrincipal(client, args) {
673
+ return (await findBlockedPrincipals(client, {
674
+ namespace: args.namespace,
675
+ objects: [args.object],
676
+ principals: args.principals,
677
+ })).get(args.object);
678
+ }
679
+ async function findBlockedPrincipals(client, args) {
680
+ const p = args.principals ?? {};
681
+ const machineCandidates = [
682
+ { level: "agent", ref: (0, subject_js_1.resolveAgentSubject)(client) },
683
+ { level: "agent_session", ref: (0, subject_js_1.resolveAgentSessionSubject)(client, p.sessionId) },
684
+ { level: "subagent", ref: (0, subject_js_1.resolveSubAgentSubject)(p.subAgentClientId) },
685
+ {
686
+ level: "subagent_spawn",
687
+ ref: p.subAgentType
688
+ ? (0, subject_js_1.resolveSubAgentSpawnSubject)(client, {
689
+ subAgentClientId: p.subAgentClientId,
690
+ subAgentType: p.subAgentType,
691
+ perSpawnId: p.perSpawnId,
692
+ sessionId: p.sessionId,
693
+ })
694
+ : undefined,
695
+ },
696
+ ];
697
+ const presentMachines = machineCandidates.filter((c) => c.ref !== undefined);
698
+ const read = async (scope, candidates) => {
699
+ const found = new Map();
700
+ if (candidates.length === 0)
701
+ return found;
702
+ try {
703
+ const batch = await client.batchCheckPermissions(args.objects.flatMap((object) => candidates.map(({ ref }) => ({
704
+ namespace: args.namespace,
705
+ object,
706
+ relation: opl_js_1.RELATION_BLOCKED_SUBJECTS,
707
+ ...ref,
708
+ }))), { activityAttributes: { source: "principal_block", scope } });
709
+ for (const [i, result] of batch.results.entries()) {
710
+ const object = args.objects[Math.floor(i / candidates.length)];
711
+ const candidate = candidates[i % candidates.length];
712
+ if (object && result.allowed && !result.error && candidate && !found.has(object)) {
713
+ found.set(object, {
714
+ subject: candidate.level === "project" ? "project" : (0, subject_js_1.subjectLabel)(candidate.ref),
715
+ level: candidate.level,
716
+ });
717
+ }
718
+ }
719
+ }
720
+ catch (err) {
721
+ client.logger.debug("permission.principal_block.failed", {
722
+ scope,
723
+ note: `${scope} principal block read failed; falling open for that scope`,
724
+ message: err instanceof Error ? err.message : String(err),
725
+ });
726
+ }
727
+ return found;
728
+ };
729
+ const [projectBlocks, machineBlocks] = await Promise.all([
730
+ read("project", [{ level: "project", ref: PROJECT_BLOCK_SUBJECT }]),
731
+ read("machine", presentMachines),
732
+ ]);
733
+ const blocked = new Map(machineBlocks);
734
+ for (const object of args.objects) {
735
+ const project = projectBlocks.get(object);
736
+ if (project)
737
+ blocked.set(object, project);
738
+ }
739
+ return blocked;
740
+ }
741
+ /**
742
+ * After a denied tool-access check, determine whether the deny was an explicit
743
+ * block or a plain missing grant by probing the `blocked` relation for the same
744
+ * subject/object. Uses `checkPermission` (not a literal tuple read) so a block
745
+ * inherited through a subject-set / group is caught. Best-effort: any error
746
+ * yields `not_granted` and never changes the decision.
747
+ */
748
+ async function probeBlockReason(client, check) {
749
+ try {
750
+ const probe = await client.checkPermission({ ...check, relation: opl_js_1.RELATION_BLOCKED_SUBJECTS }, { activityAttributes: { source: "block_probe" } });
751
+ return probe.allowed ? "explicit_block" : "not_granted";
752
+ }
753
+ catch {
754
+ // The probe is diagnostic only; a failure must not upgrade or downgrade
755
+ // the deny that already stands.
756
+ return "not_granted";
757
+ }
212
758
  }