@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
@@ -0,0 +1,10 @@
1
+ import type { RuntimeCredential } from "./runtime-credential.js";
2
+ export interface SessionContext {
3
+ sessionId?: string;
4
+ runtimeCredential?: RuntimeCredential;
5
+ }
6
+ export declare function currentSessionContext(): Readonly<SessionContext>;
7
+ export declare function withSessionContext<T>(context: SessionContext, work: () => T): T;
8
+ /** Test and low-level integration helper. Prefer `withHookContext` in adapters. */
9
+ export declare function setSessionContext(context: SessionContext): void;
10
+ export declare function clearSessionContext(): void;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.currentSessionContext = currentSessionContext;
4
+ exports.withSessionContext = withSessionContext;
5
+ exports.setSessionContext = setSessionContext;
6
+ exports.clearSessionContext = clearSessionContext;
7
+ const node_async_hooks_1 = require("node:async_hooks");
8
+ const sessionStorage = new node_async_hooks_1.AsyncLocalStorage();
9
+ function currentSessionContext() {
10
+ return sessionStorage.getStore() ?? {};
11
+ }
12
+ function withSessionContext(context, work) {
13
+ return sessionStorage.run({ ...currentSessionContext(), ...context }, work);
14
+ }
15
+ /** Test and low-level integration helper. Prefer `withHookContext` in adapters. */
16
+ function setSessionContext(context) {
17
+ sessionStorage.enterWith({ ...context });
18
+ }
19
+ function clearSessionContext() {
20
+ sessionStorage.enterWith({});
21
+ }
@@ -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";
@@ -61,14 +61,14 @@ export interface HarnessContractAdapter {
61
61
  /** A real, non-interactive tool from this harness's catalog. */
62
62
  tool: string;
63
63
  /**
64
- * Whether the harness's session-start primitive can carry a hard block
65
- * (subprocess exit-code harnesses: true; in-process advisory: false).
64
+ * @deprecated Ignored. Session start is now uniformly non-blocking on every
65
+ * harness (authentication never blocks; enforcement is governed by
66
+ * `permissionMode` at tool-call time). Kept optional so existing adapters
67
+ * that still pass it type-check.
66
68
  */
67
- sessionCanBlock: boolean;
69
+ sessionCanBlock?: boolean;
68
70
  /** Whether the tool gate can hard-block. Defaults to true. */
69
71
  toolCanBlock?: boolean;
70
- /** Set false when the plugin has no legacy OAuth2-token session path. */
71
- legacyOAuth2?: boolean;
72
72
  /** Drive the session-start phase through the plugin's real entry point. */
73
73
  sessionStart(ctx: ContractContext): Promise<ContractOutcome>;
74
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,19 +29,18 @@
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
- const DISABLED_DECISION = {
38
+ const DEFAULT_DECISION = {
40
39
  proceed: true,
41
- mode: "disabled",
42
- reason: "ORY_USER_LOGIN not enabled",
40
+ mode: "skipped",
41
+ reason: "no cached user identity in contract suite",
43
42
  };
44
- function makeGates(userDecision = DISABLED_DECISION) {
43
+ function makeGates(userDecision = DEFAULT_DECISION) {
45
44
  return {
46
45
  userLogin: vitest_1.vi.fn(async () => userDecision),
47
46
  agentGate: vitest_1.vi.fn(async () => ({
@@ -59,16 +58,13 @@ function makeGates(userDecision = DISABLED_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);
@@ -125,42 +132,34 @@ function runHarnessContractSuite(adapter) {
125
132
  (0, vitest_1.expect)(outcome.blocked).toBe(false);
126
133
  });
127
134
  });
128
- // ── User-login gate translation ────────────────────────────────
135
+ // ── User-login gate is advisory (never blocks the session) ─────
129
136
  (0, vitest_1.describe)("user-login gate", () => {
130
137
  const declined = {
131
138
  proceed: false,
132
139
  mode: "declined",
133
140
  reason: "User declined the OAuth2 consent screen",
134
141
  };
135
- (0, vitest_1.it)(adapter.sessionCanBlock
136
- ? "translates a declined login into the native session block"
137
- : "treats a declined login as advisory (no session block channel)", async () => {
142
+ (0, vitest_1.it)("never blocks the session even when the user login declines", async () => {
143
+ // Authentication is non-blocking on every harness; enforcement is
144
+ // governed by permissionMode at tool-call time, not here.
138
145
  const gates = makeGates(declined);
139
146
  const outcome = await adapter.sessionStart(ctx(gates));
140
- (0, vitest_1.expect)(outcome.blocked).toBe(adapter.sessionCanBlock);
141
- if (adapter.sessionCanBlock) {
142
- (0, vitest_1.expect)(outcome.reason).toBeTruthy();
143
- }
147
+ (0, vitest_1.expect)(outcome.blocked).toBe(false);
148
+ (0, vitest_1.expect)((0, testing_js_1.getActivityEvents)(client, "session.start")).toHaveLength(1);
144
149
  });
145
150
  (0, vitest_1.it)("runs the agent gate even when the user gate declines", async () => {
146
151
  const gates = makeGates(declined);
147
152
  await adapter.sessionStart(ctx(gates));
148
153
  (0, vitest_1.expect)(gates.agentGate).toHaveBeenCalledOnce();
149
154
  });
150
- (0, vitest_1.it)("proceeds without legacy verification when the gate handled auth", async () => {
151
- process.env.ORY_SESSION_TOKEN = "should-not-be-verified";
152
- const verify = (0, testing_js_1.stubSessionSuccess)(client);
153
- const gates = makeGates({ proceed: true, mode: "ok", reason: "logged in" });
154
- const outcome = await adapter.sessionStart(ctx(gates));
155
- (0, vitest_1.expect)(outcome.blocked).toBe(false);
156
- (0, vitest_1.expect)(verify).not.toHaveBeenCalled();
157
- });
158
- (0, vitest_1.it)("falls through to legacy verification when the gate is disabled", async () => {
159
- process.env.ORY_SESSION_TOKEN = "legacy-token";
155
+ (0, vitest_1.it)("never runs a legacy session-token verification path", async () => {
156
+ // The user gate owns authentication; there is no separate legacy
157
+ // verify/introspect fall-through any longer.
158
+ process.env.ORY_USER_OAUTH2_TOKEN = "should-not-be-verified";
160
159
  const verify = (0, testing_js_1.stubSessionSuccess)(client);
161
160
  const outcome = await adapter.sessionStart(ctx());
162
161
  (0, vitest_1.expect)(outcome.blocked).toBe(false);
163
- (0, vitest_1.expect)(verify).toHaveBeenCalled();
162
+ (0, vitest_1.expect)(verify).not.toHaveBeenCalled();
164
163
  });
165
164
  });
166
165
  // ── Tool gate translation ──────────────────────────────────────
@@ -169,8 +168,8 @@ function runHarnessContractSuite(adapter) {
169
168
  { check: "allowed", mode: "enforce", blocked: false, invoke: true, block: false },
170
169
  { check: "denied", mode: "enforce", blocked: toolCanBlock, invoke: false, block: true },
171
170
  { check: "denied", mode: "observe", blocked: false, invoke: true, block: true },
172
- { check: "network_error", mode: "enforce", blocked: false, invoke: false, block: false },
173
- { 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 },
174
173
  ])("check=$check × $mode → blocked=$blocked", async ({ check, mode, blocked, invoke, block }) => {
175
174
  const stubs = {
176
175
  allowed: testing_js_1.stubPermissionAllowed,
@@ -179,54 +178,74 @@ function runHarnessContractSuite(adapter) {
179
178
  rate_limited: testing_js_1.stubPermissionRateLimited,
180
179
  };
181
180
  stubs[check](client);
182
- process.env.ORY_PERMISSION_MODE = mode;
181
+ (0, testing_js_1.setTestPermissionMode)(mode);
183
182
  const outcome = await adapter.toolBefore(ctx(), adapter.tool);
184
183
  (0, vitest_1.expect)(outcome.blocked).toBe(blocked);
185
184
  if (blocked)
186
185
  (0, vitest_1.expect)(outcome.reason).toBeTruthy();
187
- const invokeSpans = (0, testing_js_1.getTraceSpans)(client, "tool.invoke");
188
- 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");
189
188
  if (invoke) {
190
- (0, vitest_1.expect)(invokeSpans.length).toBeGreaterThanOrEqual(1);
191
- (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);
192
191
  }
193
192
  else {
194
- (0, vitest_1.expect)(invokeSpans).toHaveLength(0);
193
+ (0, vitest_1.expect)(invokeEvents).toHaveLength(0);
195
194
  }
196
195
  if (block) {
197
- (0, vitest_1.expect)(blockSpans.length).toBeGreaterThanOrEqual(1);
198
- (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);
199
199
  }
200
200
  else {
201
- (0, vitest_1.expect)(blockSpans).toHaveLength(0);
201
+ (0, vitest_1.expect)(blockEvents).toHaveLength(0);
202
202
  }
203
- // Observe-mode denies also emit the audit span; enforce must not.
204
- const observeSpans = (0, testing_js_1.getTraceSpans)(client, "permission.observe_deny");
205
- (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);
206
206
  });
207
- (0, vitest_1.it)("skips the permission check entirely in audit-only mode", async () => {
208
- (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;
209
223
  const checkSpy = vitest_1.vi.spyOn(client, "checkPermission");
210
- process.env.ORY_PERMISSION_MODE = "enforce";
224
+ (0, testing_js_1.setTestPermissionMode)("enforce");
211
225
  const outcome = await adapter.toolBefore(ctx(), adapter.tool);
226
+ // Never blocks: with nothing to check against, the tool passes through.
212
227
  (0, vitest_1.expect)(outcome.blocked).toBe(false);
213
228
  (0, vitest_1.expect)(checkSpy).not.toHaveBeenCalled();
214
229
  });
215
- (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 () => {
216
231
  process.env.ORY_USER_SUBJECT_ID = "user:custom-override";
217
232
  (0, testing_js_1.stubPermissionAllowed)(client);
218
233
  await adapter.toolBefore(ctx(), adapter.tool);
219
- const [span] = (0, testing_js_1.getTraceSpans)(client, "tool.invoke");
220
- (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();
221
240
  });
222
241
  });
223
242
  // ── Post-tool + passthrough ────────────────────────────────────
224
243
  if (adapter.toolAfter) {
225
244
  (0, vitest_1.it)("records tool.complete after execution", async () => {
226
245
  await adapter.toolAfter(ctx(), adapter.tool);
227
- const spans = (0, testing_js_1.getTraceSpans)(client, "tool.complete");
228
- (0, vitest_1.expect)(spans.length).toBeGreaterThanOrEqual(1);
229
- (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);
230
249
  });
231
250
  }
232
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;