@ory/argus 0.14.0 → 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 (150) hide show
  1. package/README.md +31 -46
  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 +39 -91
  6. package/assets/skills/ory-e2b-sandbox/SKILL.md +18 -17
  7. package/assets/skills/ory-temporal-worker/SKILL.md +31 -33
  8. package/assets/skills/permissions-onboarding/SKILL.md +131 -104
  9. package/dist/adapters.d.ts +88 -32
  10. package/dist/adapters.js +443 -164
  11. package/dist/agent-auth.d.ts +226 -67
  12. package/dist/agent-auth.js +951 -205
  13. package/dist/auth-store.d.ts +37 -2
  14. package/dist/auth-store.js +37 -3
  15. package/dist/auth.d.ts +33 -4
  16. package/dist/auth.js +163 -20
  17. package/dist/bash-parser.d.ts +98 -0
  18. package/dist/bash-parser.js +396 -0
  19. package/dist/branding.d.ts +77 -16
  20. package/dist/branding.js +93 -23
  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 -239
  26. package/dist/client.d.ts +175 -138
  27. package/dist/client.js +672 -391
  28. package/dist/config.d.ts +217 -54
  29. package/dist/config.js +461 -42
  30. package/dist/context.d.ts +10 -0
  31. package/dist/context.js +21 -0
  32. package/dist/contract-suite.d.ts +3 -5
  33. package/dist/contract-suite.js +75 -47
  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 +28 -23
  47. package/dist/index.js +173 -58
  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.js +7 -7
  54. package/dist/local/index.d.ts +2 -2
  55. package/dist/local/index.js +24 -10
  56. package/dist/local/manager.d.ts +20 -1
  57. package/dist/local/manager.js +159 -36
  58. package/dist/local/ports.d.ts +158 -0
  59. package/dist/local/ports.js +443 -0
  60. package/dist/local/seed.d.ts +13 -5
  61. package/dist/local/seed.js +62 -38
  62. package/dist/logger.d.ts +54 -25
  63. package/dist/logger.js +329 -63
  64. package/dist/mcp.d.ts +2 -2
  65. package/dist/mcp.js +10 -5
  66. package/dist/mirror-bootstrap.d.ts +48 -0
  67. package/dist/mirror-bootstrap.js +254 -0
  68. package/dist/opl.d.ts +289 -0
  69. package/dist/opl.js +446 -0
  70. package/dist/permission-mode.d.ts +87 -0
  71. package/dist/permission-mode.js +307 -0
  72. package/dist/permissions-cli.d.ts +13 -49
  73. package/dist/permissions-cli.js +154 -348
  74. package/dist/permissions.d.ts +148 -38
  75. package/dist/permissions.js +591 -45
  76. package/dist/post-install.d.ts +33 -0
  77. package/dist/post-install.js +127 -0
  78. package/dist/read-credential.d.ts +65 -0
  79. package/dist/read-credential.js +86 -0
  80. package/dist/registry/cli.js +5 -2
  81. package/dist/registry/config.d.ts +0 -17
  82. package/dist/registry/config.js +0 -23
  83. package/dist/registry/index.d.ts +1 -1
  84. package/dist/registry/index.js +2 -2
  85. package/dist/registry/manager.d.ts +4 -21
  86. package/dist/registry/manager.js +21 -37
  87. package/dist/runtime-credential.d.ts +140 -0
  88. package/dist/runtime-credential.js +572 -0
  89. package/dist/runtime.d.ts +408 -0
  90. package/dist/runtime.js +748 -0
  91. package/dist/setup.d.ts +23 -47
  92. package/dist/setup.js +59 -116
  93. package/dist/skills.js +0 -7
  94. package/dist/status-cli.d.ts +27 -11
  95. package/dist/status-cli.js +121 -115
  96. package/dist/status-data.d.ts +116 -17
  97. package/dist/status-data.js +121 -38
  98. package/dist/subject.d.ts +126 -20
  99. package/dist/subject.js +215 -30
  100. package/dist/testing.d.ts +74 -38
  101. package/dist/testing.js +185 -68
  102. package/dist/tool-catalog.d.ts +53 -11
  103. package/dist/tool-catalog.js +164 -13
  104. package/dist/tool-metadata.d.ts +7 -6
  105. package/dist/tool-metadata.js +6 -5
  106. package/dist/types.d.ts +11 -1
  107. package/dist/uninstall.d.ts +71 -34
  108. package/dist/uninstall.js +217 -85
  109. package/dist/user-login.d.ts +9 -10
  110. package/dist/user-login.js +56 -75
  111. package/dist/watch-cli.d.ts +6 -0
  112. package/dist/watch-cli.js +217 -0
  113. package/package.json +4 -27
  114. package/assets/commands/dashboard.md +0 -34
  115. package/dist/dashboard-cli.d.ts +0 -8
  116. package/dist/dashboard-cli.js +0 -70
  117. package/dist/dev.d.ts +0 -103
  118. package/dist/dev.js +0 -583
  119. package/dist/interactive-setup.d.ts +0 -286
  120. package/dist/interactive-setup.js +0 -1734
  121. package/dist/local/jaeger-main.d.ts +0 -13
  122. package/dist/local/jaeger-main.js +0 -85
  123. package/dist/local/jaeger.d.ts +0 -50
  124. package/dist/local/jaeger.js +0 -162
  125. package/dist/otel/exporter.d.ts +0 -17
  126. package/dist/otel/exporter.js +0 -12
  127. package/dist/otel/index.d.ts +0 -2
  128. package/dist/otel/index.js +0 -8
  129. package/dist/otel/otlp.d.ts +0 -103
  130. package/dist/otel/otlp.js +0 -385
  131. package/dist/project-api-key.d.ts +0 -69
  132. package/dist/project-api-key.js +0 -147
  133. package/dist/setup-actions.d.ts +0 -232
  134. package/dist/setup-actions.js +0 -507
  135. package/dist/tracer.d.ts +0 -190
  136. package/dist/tracer.js +0 -481
  137. package/dist/watch-sandbox.d.ts +0 -9
  138. package/dist/watch-sandbox.js +0 -81
  139. package/dist/web/api.d.ts +0 -33
  140. package/dist/web/api.js +0 -294
  141. package/dist/web/launch.d.ts +0 -11
  142. package/dist/web/launch.js +0 -96
  143. package/dist/web/server.d.ts +0 -20
  144. package/dist/web/server.js +0 -233
  145. package/dist/web/types.d.ts +0 -65
  146. package/dist/web/types.js +0 -2
  147. package/dist/webapp/assets/index-Wucl4SZs.css +0 -1
  148. package/dist/webapp/assets/index-m-GtEdq0.js +0 -49
  149. package/dist/webapp/favicon.ico +0 -0
  150. package/dist/webapp/index.html +0 -15
@@ -2,13 +2,13 @@
2
2
  * Shared contract suite for harness plugin tests.
3
3
  *
4
4
  * Every harness plugin translates the same core decisions (session gates,
5
- * `gateToolCall`, audit-only) onto its own native signals (exit codes,
5
+ * `gateToolCall`, the not-connected short-circuit) onto its own native signals (exit codes,
6
6
  * `{ decision: "block" }`, `{ block: true }`, thrown errors, …). The
7
7
  * *decision semantics* are core-owned and tested once in `packages/core`;
8
8
  * what varies per harness is only the translation. This suite re-runs the
9
9
  * canonical scenario tables through a harness's real entry point via a
10
10
  * small adapter, so a plugin cannot ship with a divergent understanding of
11
- * observe / enforce / fail-open / audit-only — without each plugin
11
+ * observe / enforce / fail-open / not-connected — without each plugin
12
12
  * re-transcribing the tables.
13
13
  *
14
14
  * A harness test file calls:
@@ -28,7 +28,7 @@
28
28
  *
29
29
  * and then adds only its genuinely harness-specific tests: export shape,
30
30
  * event-name dispatch, block-signal details, input parsing (MCP names,
31
- * `subagent_type`), unique span enrichment, and the install CLI.
31
+ * `subagent_type`), unique activity enrichment, and the install CLI.
32
32
  */
33
33
  import { OryAgentClient } from "./client.js";
34
34
  import type { ensureUserAuthenticated } from "./user-login.js";
@@ -69,8 +69,6 @@ export interface HarnessContractAdapter {
69
69
  sessionCanBlock?: boolean;
70
70
  /** Whether the tool gate can hard-block. Defaults to true. */
71
71
  toolCanBlock?: boolean;
72
- /** Set false when the plugin has no legacy OAuth2-token session path. */
73
- legacyOAuth2?: boolean;
74
72
  /** Drive the session-start phase through the plugin's real entry point. */
75
73
  sessionStart(ctx: ContractContext): Promise<ContractOutcome>;
76
74
  /** Drive the pre-tool gate for `tool` through the plugin's real entry point. */
@@ -3,13 +3,13 @@
3
3
  * Shared contract suite for harness plugin tests.
4
4
  *
5
5
  * Every harness plugin translates the same core decisions (session gates,
6
- * `gateToolCall`, audit-only) onto its own native signals (exit codes,
6
+ * `gateToolCall`, the not-connected short-circuit) onto its own native signals (exit codes,
7
7
  * `{ decision: "block" }`, `{ block: true }`, thrown errors, …). The
8
8
  * *decision semantics* are core-owned and tested once in `packages/core`;
9
9
  * what varies per harness is only the translation. This suite re-runs the
10
10
  * canonical scenario tables through a harness's real entry point via a
11
11
  * small adapter, so a plugin cannot ship with a divergent understanding of
12
- * observe / enforce / fail-open / audit-only — without each plugin
12
+ * observe / enforce / fail-open / not-connected — without each plugin
13
13
  * re-transcribing the tables.
14
14
  *
15
15
  * A harness test file calls:
@@ -29,12 +29,11 @@
29
29
  *
30
30
  * and then adds only its genuinely harness-specific tests: export shape,
31
31
  * event-name dispatch, block-signal details, input parsing (MCP names,
32
- * `subagent_type`), unique span enrichment, and the install CLI.
32
+ * `subagent_type`), unique activity enrichment, and the install CLI.
33
33
  */
34
34
  Object.defineProperty(exports, "__esModule", { value: true });
35
35
  exports.runHarnessContractSuite = runHarnessContractSuite;
36
36
  const vitest_1 = require("vitest");
37
- const config_js_1 = require("./config.js");
38
37
  const testing_js_1 = require("./testing.js");
39
38
  const DEFAULT_DECISION = {
40
39
  proceed: true,
@@ -59,16 +58,13 @@ function makeGates(userDecision = DEFAULT_DECISION) {
59
58
  }
60
59
  const ENV_KEYS = [
61
60
  "ORY_PROJECT_URL",
62
- "ORY_API_KEY",
63
- "ORY_SESSION_TOKEN",
64
- "ORY_OAUTH2_TOKEN",
65
- "ORY_USER_SESSION_TOKEN",
61
+ "ORY_SDK_URL",
62
+ "ORY_OAUTH2_CLIENT_ID",
66
63
  "ORY_USER_OAUTH2_TOKEN",
67
- "ORY_USER_LOGIN",
68
64
  "ORY_USER_SUBJECT_ID",
69
65
  "ORY_USER_SUBJECT_NAMESPACE",
70
66
  "ORY_AGENT_SUBJECT_ID",
71
- "ORY_PERMISSION_MODE",
67
+ "ORY_PERMISSION_MODE_TTL_MS",
72
68
  "ORY_PERMISSION_NAMESPACE",
73
69
  "ORY_INTERACTIVE_TOOLS",
74
70
  ];
@@ -88,7 +84,16 @@ function runHarnessContractSuite(adapter) {
88
84
  delete process.env[key];
89
85
  }
90
86
  restoreConfigDir = (0, testing_js_1.useTempConfigDir)();
87
+ // Agent Security runs exactly when both values are present. The contract
88
+ // matrix exercises the *connected* posture, so set both; the dedicated
89
+ // not-connected test clears them again.
91
90
  process.env.ORY_PROJECT_URL = "https://test.projects.oryapis.com";
91
+ process.env.ORY_OAUTH2_CLIENT_ID = "test-login-client";
92
+ // Disable the mode cache TTL so each row re-reads the stubbed mode.
93
+ process.env.ORY_PERMISSION_MODE_TTL_MS = "0";
94
+ // Default the stubbed server-read mode to enforce (the matrix rows set it
95
+ // per-case via setTestPermissionMode).
96
+ (0, testing_js_1.setTestPermissionMode)("enforce");
92
97
  client = (0, testing_js_1.createMockClient)({ harness: adapter.harness });
93
98
  });
94
99
  (0, vitest_1.afterEach)(() => {
@@ -103,21 +108,23 @@ function runHarnessContractSuite(adapter) {
103
108
  const ctx = (gates = makeGates()) => ({ client, gates });
104
109
  // ── Session start never blocks on auth/infra failures ─────────
105
110
  (0, vitest_1.describe)("session start is fail-open", () => {
111
+ // The stubs below back APIs the session-start path no longer calls
112
+ // (`verifySession` / `introspectToken`). They stay because the invariant
113
+ // under test is that session start proceeds *regardless* of what those
114
+ // would have said — a plugin that reintroduced a verification
115
+ // fall-through would start failing these rows.
106
116
  vitest_1.it.each([
107
- { name: "valid session token", env: "session", stub: testing_js_1.stubSessionSuccess },
108
- { name: "inactive session", env: "session", stub: testing_js_1.stubSessionInactive },
109
- { name: "session network error", env: "session", stub: testing_js_1.stubSessionNetworkError },
110
- { name: "MFA required", env: "session", stub: testing_js_1.stubSessionMfaRequired },
111
- { name: "valid OAuth2 token", env: "oauth2", stub: testing_js_1.stubOAuth2Success },
112
- { name: "inactive OAuth2 token", env: "oauth2", stub: testing_js_1.stubOAuth2Inactive },
117
+ { name: "a pre-supplied token", env: "token", stub: testing_js_1.stubSessionSuccess },
118
+ { name: "an inactive session upstream", env: "token", stub: testing_js_1.stubSessionInactive },
119
+ { name: "a session network error", env: "token", stub: testing_js_1.stubSessionNetworkError },
120
+ { name: "MFA required upstream", env: "token", stub: testing_js_1.stubSessionMfaRequired },
121
+ { name: "an active OAuth2 introspection", env: "token", stub: testing_js_1.stubOAuth2Success },
122
+ { name: "an inactive OAuth2 introspection", env: "token", stub: testing_js_1.stubOAuth2Inactive },
113
123
  { name: "unconfigured (no project URL)", env: "none", stub: undefined },
114
- ])("proceeds with $name", async ({ env, stub, name }) => {
115
- if (name.includes("OAuth2") && adapter.legacyOAuth2 === false)
116
- return;
117
- if (env === "session")
118
- process.env.ORY_SESSION_TOKEN = "token-under-test";
119
- if (env === "oauth2")
120
- process.env.ORY_OAUTH2_TOKEN = "token-under-test";
124
+ ])("proceeds with $name", async ({ env, stub }) => {
125
+ // `ORY_USER_OAUTH2_TOKEN` is the one accepted env credential.
126
+ if (env === "token")
127
+ process.env.ORY_USER_OAUTH2_TOKEN = "token-under-test";
121
128
  if (env === "none")
122
129
  delete process.env.ORY_PROJECT_URL;
123
130
  stub?.(client);
@@ -138,6 +145,7 @@ function runHarnessContractSuite(adapter) {
138
145
  const gates = makeGates(declined);
139
146
  const outcome = await adapter.sessionStart(ctx(gates));
140
147
  (0, vitest_1.expect)(outcome.blocked).toBe(false);
148
+ (0, vitest_1.expect)((0, testing_js_1.getActivityEvents)(client, "session.start")).toHaveLength(1);
141
149
  });
142
150
  (0, vitest_1.it)("runs the agent gate even when the user gate declines", async () => {
143
151
  const gates = makeGates(declined);
@@ -147,7 +155,7 @@ function runHarnessContractSuite(adapter) {
147
155
  (0, vitest_1.it)("never runs a legacy session-token verification path", async () => {
148
156
  // The user gate owns authentication; there is no separate legacy
149
157
  // verify/introspect fall-through any longer.
150
- process.env.ORY_SESSION_TOKEN = "should-not-be-verified";
158
+ process.env.ORY_USER_OAUTH2_TOKEN = "should-not-be-verified";
151
159
  const verify = (0, testing_js_1.stubSessionSuccess)(client);
152
160
  const outcome = await adapter.sessionStart(ctx());
153
161
  (0, vitest_1.expect)(outcome.blocked).toBe(false);
@@ -160,8 +168,8 @@ function runHarnessContractSuite(adapter) {
160
168
  { check: "allowed", mode: "enforce", blocked: false, invoke: true, block: false },
161
169
  { check: "denied", mode: "enforce", blocked: toolCanBlock, invoke: false, block: true },
162
170
  { check: "denied", mode: "observe", blocked: false, invoke: true, block: true },
163
- { check: "network_error", mode: "enforce", blocked: false, invoke: false, block: false },
164
- { check: "rate_limited", mode: "enforce", blocked: false, invoke: false, block: false },
171
+ { check: "network_error", mode: "enforce", blocked: false, invoke: true, block: false },
172
+ { check: "rate_limited", mode: "enforce", blocked: false, invoke: true, block: false },
165
173
  ])("check=$check × $mode → blocked=$blocked", async ({ check, mode, blocked, invoke, block }) => {
166
174
  const stubs = {
167
175
  allowed: testing_js_1.stubPermissionAllowed,
@@ -170,54 +178,74 @@ function runHarnessContractSuite(adapter) {
170
178
  rate_limited: testing_js_1.stubPermissionRateLimited,
171
179
  };
172
180
  stubs[check](client);
173
- process.env.ORY_PERMISSION_MODE = mode;
181
+ (0, testing_js_1.setTestPermissionMode)(mode);
174
182
  const outcome = await adapter.toolBefore(ctx(), adapter.tool);
175
183
  (0, vitest_1.expect)(outcome.blocked).toBe(blocked);
176
184
  if (blocked)
177
185
  (0, vitest_1.expect)(outcome.reason).toBeTruthy();
178
- const invokeSpans = (0, testing_js_1.getTraceSpans)(client, "tool.invoke");
179
- const blockSpans = (0, testing_js_1.getTraceSpans)(client, "tool.block");
186
+ const invokeEvents = (0, testing_js_1.getActivityEvents)(client, "tool.invoke");
187
+ const blockEvents = (0, testing_js_1.getActivityEvents)(client, "tool.block");
180
188
  if (invoke) {
181
- (0, vitest_1.expect)(invokeSpans.length).toBeGreaterThanOrEqual(1);
182
- (0, vitest_1.expect)(invokeSpans[0].attributes?.toolName).toBe(adapter.tool);
189
+ (0, vitest_1.expect)(invokeEvents.length).toBeGreaterThanOrEqual(1);
190
+ (0, vitest_1.expect)(invokeEvents[0].attributes?.toolName).toBe(adapter.tool);
183
191
  }
184
192
  else {
185
- (0, vitest_1.expect)(invokeSpans).toHaveLength(0);
193
+ (0, vitest_1.expect)(invokeEvents).toHaveLength(0);
186
194
  }
187
195
  if (block) {
188
- (0, vitest_1.expect)(blockSpans.length).toBeGreaterThanOrEqual(1);
189
- (0, vitest_1.expect)(blockSpans[0].attributes?.blocked).toBe(mode === "enforce" && toolCanBlock);
196
+ (0, vitest_1.expect)(blockEvents.length).toBeGreaterThanOrEqual(1);
197
+ (0, vitest_1.expect)(blockEvents[0].attributes?.blocked).toBe(mode === "enforce" && toolCanBlock);
198
+ (0, vitest_1.expect)(blockEvents[0].attributes?.observed).toBe(mode === "observe" ? true : undefined);
190
199
  }
191
200
  else {
192
- (0, vitest_1.expect)(blockSpans).toHaveLength(0);
201
+ (0, vitest_1.expect)(blockEvents).toHaveLength(0);
193
202
  }
194
- // Observe-mode denies also emit the audit span; enforce must not.
195
- const observeSpans = (0, testing_js_1.getTraceSpans)(client, "permission.observe_deny");
196
- (0, vitest_1.expect)(observeSpans).toHaveLength(check === "denied" && mode === "observe" ? 1 : 0);
203
+ // Observe-mode denies also emit the audit event; enforce must not.
204
+ const observeEvents = (0, testing_js_1.getActivityEvents)(client, "permission.observe_deny");
205
+ (0, vitest_1.expect)(observeEvents).toHaveLength(check === "denied" && mode === "observe" ? 1 : 0);
197
206
  });
198
- (0, vitest_1.it)("skips the permission check entirely in audit-only mode", async () => {
199
- (0, config_js_1.saveConfig)({ auditOnly: true });
207
+ (0, vitest_1.it)("records tool.invoke when an interactive tool proceeds", async () => {
208
+ process.env.ORY_INTERACTIVE_TOOLS = adapter.tool;
209
+ const outcome = await adapter.toolBefore(ctx(), adapter.tool);
210
+ (0, vitest_1.expect)(outcome.blocked).toBe(false);
211
+ (0, vitest_1.expect)((0, testing_js_1.getActivityEvents)(client, "tool.invoke")).toHaveLength(1);
212
+ });
213
+ // A project URL is the required address. The public login client has a
214
+ // reserved default, so omitting only its override remains connected.
215
+ vitest_1.it.each([
216
+ ["no project URL and no client id", { url: false, clientId: false }],
217
+ ["a client id but no project URL", { url: false, clientId: true }],
218
+ ])("skips the permission check entirely with %s", async (_label, { url, clientId }) => {
219
+ if (!url)
220
+ delete process.env.ORY_PROJECT_URL;
221
+ if (!clientId)
222
+ delete process.env.ORY_OAUTH2_CLIENT_ID;
200
223
  const checkSpy = vitest_1.vi.spyOn(client, "checkPermission");
201
- process.env.ORY_PERMISSION_MODE = "enforce";
224
+ (0, testing_js_1.setTestPermissionMode)("enforce");
202
225
  const outcome = await adapter.toolBefore(ctx(), adapter.tool);
226
+ // Never blocks: with nothing to check against, the tool passes through.
203
227
  (0, vitest_1.expect)(outcome.blocked).toBe(false);
204
228
  (0, vitest_1.expect)(checkSpy).not.toHaveBeenCalled();
205
229
  });
206
- (0, vitest_1.it)("addresses the check to ORY_USER_SUBJECT_ID when set", async () => {
230
+ (0, vitest_1.it)("addresses the check to ORY_USER_SUBJECT_ID when set (as a User SubjectSet)", async () => {
207
231
  process.env.ORY_USER_SUBJECT_ID = "user:custom-override";
208
232
  (0, testing_js_1.stubPermissionAllowed)(client);
209
233
  await adapter.toolBefore(ctx(), adapter.tool);
210
- const [span] = (0, testing_js_1.getTraceSpans)(client, "tool.invoke");
211
- (0, vitest_1.expect)(span.attributes?.subjectId).toBe("user:custom-override");
234
+ const [activity] = (0, testing_js_1.getActivityEvents)(client, "tool.invoke");
235
+ // Principals are addressed as SubjectSets under the default `User`
236
+ // namespace; activity carries the SubjectSet, not a direct subjectId.
237
+ (0, vitest_1.expect)(activity.attributes?.subjectSet).toBeUndefined();
238
+ (0, vitest_1.expect)(activity.attributes?.subjectSetHash).toMatch(/^[a-f0-9]{16}$/);
239
+ (0, vitest_1.expect)(activity.attributes?.subjectId).toBeUndefined();
212
240
  });
213
241
  });
214
242
  // ── Post-tool + passthrough ────────────────────────────────────
215
243
  if (adapter.toolAfter) {
216
244
  (0, vitest_1.it)("records tool.complete after execution", async () => {
217
245
  await adapter.toolAfter(ctx(), adapter.tool);
218
- const spans = (0, testing_js_1.getTraceSpans)(client, "tool.complete");
219
- (0, vitest_1.expect)(spans.length).toBeGreaterThanOrEqual(1);
220
- (0, vitest_1.expect)(spans[0].attributes?.toolName).toBe(adapter.tool);
246
+ const events = (0, testing_js_1.getActivityEvents)(client, "tool.complete");
247
+ (0, vitest_1.expect)(events.length).toBeGreaterThanOrEqual(1);
248
+ (0, vitest_1.expect)(events[0].attributes?.toolName).toBe(adapter.tool);
221
249
  });
222
250
  }
223
251
  if (adapter.unknownEvent) {
package/dist/denial.d.ts CHANGED
@@ -15,11 +15,35 @@ export interface DenialContext {
15
15
  subjectId: string;
16
16
  namespace?: string;
17
17
  mcp?: McpToolIdentifier;
18
+ /**
19
+ * Shell decomposition (issue #76): the denied command word (`curl`) inside a
20
+ * `Bash` call. When set, the message names the word and the
21
+ * `ShellTool:<word>#use` permission instead of the tool.
22
+ */
23
+ shellWord?: string;
24
+ /**
25
+ * Every denied command word when more than one was blocked in the same shell
26
+ * call. The message lists them all so the user can grant them in one pass;
27
+ * {@link shellWord} remains the primary (first) word for back-compat.
28
+ */
29
+ shellDeniedWords?: string[];
30
+ /** Set when a shell command was blocked because it could not be safely parsed. */
31
+ shellTooComplex?: boolean;
32
+ /** Namespace for shell-command sub-tools (default `ShellTool`). */
33
+ shellNamespace?: string;
34
+ /**
35
+ * True when the deny was an *explicit block* (a `blocked` relation matched
36
+ * the subject) rather than a missing grant. Changes the wording from "not
37
+ * authorized" to "explicitly blocked" and points the user at removing the
38
+ * block rather than granting a permission. Sourced from a decision's
39
+ * `activityAttributes.blockReason === "explicit_block"`.
40
+ */
41
+ blocked?: boolean;
18
42
  }
19
43
  /**
20
- * Span attributes that mark a denial as a security alert.
44
+ * Activity attributes that mark a denial as a security alert.
21
45
  *
22
- * Spread these onto the existing `tool.block` span attributes whenever an
46
+ * Spread these onto the existing `tool.block` activity attributes whenever an
23
47
  * actual deny comes back from Ory. The shape is stable so downstream
24
48
  * alerting rules can hook on `attributes.alert === true` and filter by
25
49
  * `attributes.severity` or `attributes.alertKind` without string matching.
@@ -40,7 +64,7 @@ export declare function alertAttributes(blocked: boolean): AlertAttributes;
40
64
  /**
41
65
  * Human-facing denial reason for an alert that the harness cannot enforce
42
66
  * (advisory mode). Prepends an explicit "ORY SECURITY ALERT TRIGGERED"
43
- * banner and notes that the trace span is the alert signal, so the agent
67
+ * banner and notes that the activity event is the alert signal, so the agent
44
68
  * — and any human reading transcripts — can't miss that a security event
45
69
  * was emitted even though the tool was allowed to proceed.
46
70
  */
@@ -50,6 +74,15 @@ export declare function formatAlertMessage(ctx: DenialContext): string;
50
74
  * systemMessage, etc.) when the denial cannot be enforced.
51
75
  */
52
76
  export declare function formatAlertSummary(ctx: DenialContext): string;
77
+ /**
78
+ * Extract the shell-decomposition denial fields (issue #76) from a permission
79
+ * decision's activity attributes, for spreading into a {@link DenialContext}. Reads
80
+ * defensively (the outcome union includes non-shell members) so harness
81
+ * handlers can write `...shellDenialFields(decision)` without narrowing.
82
+ */
83
+ export declare function shellDenialFields(decision: {
84
+ activityAttributes?: unknown;
85
+ }): Pick<DenialContext, "shellWord" | "shellDeniedWords" | "shellTooComplex" | "shellNamespace" | "blocked">;
53
86
  /**
54
87
  * Human-facing denial reason. Shown in agent UIs (Claude Code's `decision`
55
88
  * reason, Gemini CLI's systemMessage, OpenClaw's blockReason, OpenCode's
package/dist/denial.js CHANGED
@@ -15,9 +15,21 @@ exports.OryDenialError = void 0;
15
15
  exports.alertAttributes = alertAttributes;
16
16
  exports.formatAlertMessage = formatAlertMessage;
17
17
  exports.formatAlertSummary = formatAlertSummary;
18
+ exports.shellDenialFields = shellDenialFields;
18
19
  exports.formatDenialMessage = formatDenialMessage;
19
20
  exports.formatDenialSummary = formatDenialSummary;
20
- const ADMIN_HINT = "Contact your Ory Keto administrator to grant the required permission.";
21
+ const tool_catalog_js_1 = require("./tool-catalog.js");
22
+ /**
23
+ * Where a user is sent when a tool is denied.
24
+ *
25
+ * Names the **Ory Console** rather than "your Ory Keto administrator": since
26
+ * #180 the plugin is a read-only Keto client and grants, blocks, and the
27
+ * permission model are all provisioned in the Console by someone with project
28
+ * access. There is no "Keto administrator" role to contact, and "Keto" is
29
+ * internal vocabulary that should never reach an end user (#222).
30
+ */
31
+ const ADMIN_HINT = "Ask someone with access to your Ory project to grant this permission in the Ory Console (Agent Security).";
32
+ const BLOCK_HINT = "This subject is explicitly blocked from this tool. Ask someone with access to your Ory project to remove the block in the Ory Console (Agent Security).";
21
33
  function alertAttributes(blocked) {
22
34
  return {
23
35
  alert: true,
@@ -30,12 +42,12 @@ const ALERT_PREFIX = "ORY SECURITY ALERT TRIGGERED";
30
42
  /**
31
43
  * Human-facing denial reason for an alert that the harness cannot enforce
32
44
  * (advisory mode). Prepends an explicit "ORY SECURITY ALERT TRIGGERED"
33
- * banner and notes that the trace span is the alert signal, so the agent
45
+ * banner and notes that the activity event is the alert signal, so the agent
34
46
  * — and any human reading transcripts — can't miss that a security event
35
47
  * was emitted even though the tool was allowed to proceed.
36
48
  */
37
49
  function formatAlertMessage(ctx) {
38
- return `[${ALERT_PREFIX}] ${formatDenialMessage(ctx)} A security alert trace span was recorded for this denied tool call.`;
50
+ return `[${ALERT_PREFIX}] ${formatDenialMessage(ctx)} A security activity event was recorded for this denied tool call.`;
39
51
  }
40
52
  /**
41
53
  * Short alert summary suitable for compact UI fields (Gemini's
@@ -44,38 +56,95 @@ function formatAlertMessage(ctx) {
44
56
  function formatAlertSummary(ctx) {
45
57
  return `[${ALERT_PREFIX}] ${formatDenialSummary(ctx)}`;
46
58
  }
59
+ /**
60
+ * Extract the shell-decomposition denial fields (issue #76) from a permission
61
+ * decision's activity attributes, for spreading into a {@link DenialContext}. Reads
62
+ * defensively (the outcome union includes non-shell members) so harness
63
+ * handlers can write `...shellDenialFields(decision)` without narrowing.
64
+ */
65
+ function shellDenialFields(decision) {
66
+ const sa = (decision.activityAttributes ?? {});
67
+ return {
68
+ shellWord: sa.shellWord,
69
+ shellDeniedWords: sa.shellDeniedWords,
70
+ shellTooComplex: sa.shellTooComplex,
71
+ shellNamespace: (0, tool_catalog_js_1.resolveShellCommandNamespace)(),
72
+ blocked: sa.blockReason === "explicit_block",
73
+ };
74
+ }
47
75
  /**
48
76
  * Human-facing denial reason. Shown in agent UIs (Claude Code's `decision`
49
77
  * reason, Gemini CLI's systemMessage, OpenClaw's blockReason, OpenCode's
50
78
  * thrown error message).
51
79
  */
52
80
  function formatDenialMessage(ctx) {
81
+ if (ctx.shellTooComplex) {
82
+ return (`Ory: shell command blocked — it could not be safely parsed and is ` +
83
+ `denied in enforce mode. Subject "${ctx.subjectId}". ` +
84
+ `Simplify the command or split it into individually authorized steps.`);
85
+ }
86
+ if (ctx.shellWord) {
87
+ const ns = ctx.shellNamespace ?? "ShellTool";
88
+ const shellHint = ctx.blocked ? BLOCK_HINT : ADMIN_HINT;
89
+ const shellVerb = ctx.blocked
90
+ ? "is explicitly blocked from running"
91
+ : "is not authorized to run";
92
+ const denied = ctx.shellDeniedWords && ctx.shellDeniedWords.length > 0
93
+ ? ctx.shellDeniedWords
94
+ : [ctx.shellWord];
95
+ if (denied.length > 1) {
96
+ const list = denied.map((w) => `"${w}"`).join(", ");
97
+ const perms = denied.map((w) => `"${ns}:${w}#use"`).join(", ");
98
+ return (`Ory: permission denied for shell commands ${list}. ` +
99
+ `Subject "${ctx.subjectId}" ${shellVerb} them ` +
100
+ `(permissions ${perms}). ` +
101
+ shellHint);
102
+ }
103
+ return (`Ory: permission denied for shell command "${ctx.shellWord}". ` +
104
+ `Subject "${ctx.subjectId}" ${shellVerb} "${ctx.shellWord}" ` +
105
+ `(permission "${ns}:${ctx.shellWord}#use"). ` +
106
+ shellHint);
107
+ }
108
+ const hint = ctx.blocked ? BLOCK_HINT : ADMIN_HINT;
53
109
  if (ctx.mcp) {
54
110
  const tool = ctx.mcp.toolName
55
111
  ? `${ctx.mcp.serverName}/${ctx.mcp.toolName}`
56
112
  : ctx.mcp.serverName;
113
+ const verb = ctx.blocked ? "is explicitly blocked from" : "is not authorized to use";
57
114
  return (`Ory: permission denied for MCP server "${ctx.mcp.serverName}". ` +
58
- `Subject "${ctx.subjectId}" is not authorized to use "${tool}". ` +
59
- ADMIN_HINT);
115
+ `Subject "${ctx.subjectId}" ${verb} "${tool}". ` +
116
+ hint);
60
117
  }
61
- const ns = ctx.namespace ?? "AgentTools";
118
+ const ns = ctx.namespace ?? "AgentTool";
119
+ const verb = ctx.blocked ? "is explicitly blocked from invoking" : "is not authorized to invoke";
62
120
  return (`Ory: permission denied for "${ctx.tool}". ` +
63
- `Subject "${ctx.subjectId}" is not authorized to invoke "${ctx.tool}" ` +
121
+ `Subject "${ctx.subjectId}" ${verb} "${ctx.tool}" ` +
64
122
  `(namespace "${ns}"). ` +
65
- ADMIN_HINT);
123
+ hint);
66
124
  }
67
125
  /**
68
126
  * Short denial summary suitable for compact UI fields (e.g. Gemini's
69
127
  * systemMessage which appears inline in the agent's TUI).
70
128
  */
71
129
  function formatDenialSummary(ctx) {
130
+ if (ctx.shellTooComplex) {
131
+ return `Ory: shell command blocked (could not be safely parsed). ${ADMIN_HINT}`;
132
+ }
133
+ if (ctx.shellWord) {
134
+ const denied = ctx.shellDeniedWords && ctx.shellDeniedWords.length > 1
135
+ ? ctx.shellDeniedWords.map((w) => `"${w}"`).join(", ")
136
+ : `"${ctx.shellWord}"`;
137
+ const noun = ctx.shellDeniedWords && ctx.shellDeniedWords.length > 1 ? "commands" : "command";
138
+ return `Ory: permission denied for shell ${noun} ${denied}. ${ctx.blocked ? BLOCK_HINT : ADMIN_HINT}`;
139
+ }
140
+ const hint = ctx.blocked ? BLOCK_HINT : ADMIN_HINT;
72
141
  if (ctx.mcp) {
73
142
  const tool = ctx.mcp.toolName
74
143
  ? `${ctx.mcp.serverName}/${ctx.mcp.toolName}`
75
144
  : ctx.mcp.serverName;
76
- return `Ory: permission denied for MCP tool "${tool}". ${ADMIN_HINT}`;
145
+ return `Ory: permission denied for MCP tool "${tool}". ${hint}`;
77
146
  }
78
- return `Ory: permission denied for "${ctx.tool}". ${ADMIN_HINT}`;
147
+ return `Ory: permission denied for "${ctx.tool}". ${hint}`;
79
148
  }
80
149
  /**
81
150
  * Thrown by plugins whose hook contract has no native block signal and
@@ -0,0 +1,77 @@
1
+ import type { ActivityEntry, ActivityStatus, DebugLogger } from "./logger.js";
2
+ export declare const SERVER_ACTIVITY_EVENTS: Set<string>;
3
+ export interface ServerActivityEvent {
4
+ event_id: string;
5
+ name: string;
6
+ status: ActivityStatus;
7
+ occurred_at: string;
8
+ session_id?: string;
9
+ attributes?: Record<string, string | number | boolean | string[]>;
10
+ }
11
+ export interface EventReporterOptions {
12
+ projectUrl: string;
13
+ agentSecurityUrl: string;
14
+ token?: (sessionId?: string, principalKey?: string) => string | undefined;
15
+ request?: (url: string, init: RequestInit, sessionId?: string, principalKey?: string) => Promise<Response> | undefined;
16
+ onUnauthorized?: (sessionId: string | undefined, principalKey: string | undefined, rejectedToken: string) => void;
17
+ logger?: DebugLogger;
18
+ fetch?: typeof fetch;
19
+ maxQueueSize?: number;
20
+ requestTimeoutMs?: number;
21
+ /** Durable per-event storage. Omitted reporters remain memory-only. */
22
+ outboxDir?: string;
23
+ now?: () => number;
24
+ eventId?: () => string;
25
+ retryBaseMs?: number;
26
+ retryMaxMs?: number;
27
+ random?: () => number;
28
+ }
29
+ export interface PendingEventScope {
30
+ sessionId?: string;
31
+ principalKey?: string;
32
+ }
33
+ /** Best-effort delivery of privacy-safe client activity to Agent Security. */
34
+ export declare class AgentSecurityEventReporter {
35
+ private readonly options;
36
+ private readonly queue;
37
+ private readonly fetcher;
38
+ private readonly maxQueueSize;
39
+ private readonly requestTimeoutMs;
40
+ private readonly now;
41
+ private readonly eventId;
42
+ private delivery;
43
+ private scheduled;
44
+ private activeRequest;
45
+ private flushDeadline;
46
+ private suppressRetry;
47
+ private retryTimer;
48
+ private retryAttempt;
49
+ private readonly retryBaseMs;
50
+ private readonly retryMaxMs;
51
+ private readonly random;
52
+ constructor(options: EventReporterOptions);
53
+ get pending(): number;
54
+ get pendingScopes(): ReadonlyArray<PendingEventScope>;
55
+ enqueue(entry: ActivityEntry, principalKey?: string): void;
56
+ /** Attempt all currently deliverable batches, bounded by the caller's deadline. */
57
+ flush(timeoutMs?: number): Promise<void>;
58
+ private schedule;
59
+ private startDelivery;
60
+ private scheduleRetry;
61
+ private drain;
62
+ private deliver;
63
+ private request;
64
+ private persist;
65
+ private syncOutbox;
66
+ private acquireOutboxLock;
67
+ private cleanRenamedLocks;
68
+ private enforceBounds;
69
+ private removeFile;
70
+ private prune;
71
+ private diagnostic;
72
+ }
73
+ export declare function projectActivityEntry(entry: ActivityEntry, createEventId?: () => string): ServerActivityEvent | undefined;
74
+ /** Stable, non-identifying directory name that prevents cross-project replay. */
75
+ export declare function eventOutboxProjectScope(projectUrl: string): string;
76
+ /** Stable local selector for a bearer without persisting its identifying subject. */
77
+ export declare function eventPrincipalKey(subject: string): string;