@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
package/dist/client.js CHANGED
@@ -33,25 +33,56 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DEFAULT_TRACE_FILENAME = exports.DEFAULT_DEBUG_LOG_FILENAME = exports.OryAgentClient = void 0;
36
+ exports.DEFAULT_DEBUG_LOG_FILENAME = exports.OryAgentClient = void 0;
37
37
  exports.resolveDebugLogPath = resolveDebugLogPath;
38
- exports.resolveTraceFilePath = resolveTraceFilePath;
39
38
  exports.extractOryRequestId = extractOryRequestId;
40
39
  const client_1 = require("@ory/client");
41
40
  const path = __importStar(require("node:path"));
42
41
  const logger_js_1 = require("./logger.js");
43
- const tracer_js_1 = require("./tracer.js");
42
+ const event_reporter_js_1 = require("./event-reporter.js");
43
+ const agent_auth_js_1 = require("./agent-auth.js");
44
+ const context_js_1 = require("./context.js");
44
45
  const config_js_1 = require("./config.js");
45
46
  const auth_store_js_1 = require("./auth-store.js");
46
- const otlp_js_1 = require("./otel/otlp.js");
47
+ const runtime_credential_js_1 = require("./runtime-credential.js");
48
+ const BROKER_PERMISSION_BATCH_SIZE = 10;
49
+ function permissionCheckFromRequest(request) {
50
+ return {
51
+ namespace: request.namespace,
52
+ object: request.object,
53
+ relation: request.relation,
54
+ subjectId: request.subjectId,
55
+ subjectSet: request.subjectSetNamespace && request.subjectSetObject !== undefined
56
+ ? {
57
+ namespace: request.subjectSetNamespace,
58
+ object: request.subjectSetObject,
59
+ relation: request.subjectSetRelation ?? "",
60
+ }
61
+ : undefined,
62
+ };
63
+ }
64
+ function permissionCheckFromTuple(tuple) {
65
+ return {
66
+ namespace: tuple.namespace,
67
+ object: tuple.object,
68
+ relation: tuple.relation,
69
+ subjectId: tuple.subject_id,
70
+ subjectSet: tuple.subject_set,
71
+ };
72
+ }
73
+ function httpError(status, message) {
74
+ return Object.assign(new Error(message), { status });
75
+ }
47
76
  class OryAgentClient {
48
77
  frontend;
49
78
  oauth2;
50
79
  permission;
51
- relationship;
52
80
  config;
53
81
  logger;
54
- tracer;
82
+ eventReporter;
83
+ _runtimeCredential;
84
+ _runtimeCredentialsBySession = new Map();
85
+ _runtimeCredentialsByPrincipalKey = new Map();
55
86
  sessionCache = new Map();
56
87
  sessionCacheTtlMs;
57
88
  /**
@@ -59,94 +90,383 @@ class OryAgentClient {
59
90
  * Populated by `ensureUserAuthenticated` on session start.
60
91
  */
61
92
  _userPrincipal = {};
93
+ _userPrincipalsBySession = new Map();
62
94
  /**
63
95
  * The AI agent process — the principal that authenticates outgoing
64
96
  * Ory API calls. Populated by `ensureAgentIdentity`.
65
97
  */
66
98
  _agentPrincipal = {};
67
- /**
68
- * Admin credential — an Ory Network **project API key** (`ory_pat_…`)
69
- * used to authenticate the Keto Permission and Relationship APIs.
70
- *
71
- * This is deliberately distinct from the agent principal's token. Ory
72
- * Network's permission APIs authenticate with a project API key and
73
- * **reject project-issued OAuth2 access tokens** (a DCR client-credentials
74
- * token is answered with `401 "Access token is not active"`). So when the
75
- * agent identity resolves to a DCR OAuth2 client — the default path — its
76
- * token cannot authenticate permission checks. Holding the admin key here
77
- * lets {@link buildApis} route Keto through it while the agent OAuth2 token
78
- * still authenticates the OAuth2 / Frontend APIs and carries attribution.
79
- *
80
- * When unset, Keto calls fall back to the agent token (legacy
81
- * single-credential behavior, unchanged for static-key deployments).
82
- */
83
- _adminApiKey;
99
+ _agentPrincipalsBySession = new Map();
100
+ _eventPrincipalsByKey = new Map();
84
101
  constructor(config) {
85
102
  this.config = config;
86
103
  this.sessionCacheTtlMs = config.sessionCacheTtlMs ?? 60_000;
87
104
  this.logger = new logger_js_1.DebugLogger(config.harness, {
88
105
  logFile: config.logFile,
89
106
  });
90
- this.tracer = new tracer_js_1.Tracer({
91
- harness: config.harness,
92
- traceFile: config.traceFile,
93
- ...(config.exporter ? { exporter: config.exporter } : {}),
107
+ this.logger.setActivityEnricher(() => this.principalActivityAttributes());
108
+ this._runtimeCredential = (0, runtime_credential_js_1.resolveRuntimeCredential)({
109
+ requestAuthenticator: config.runtimeRequestAuthenticator,
110
+ subject: config.runtimeSubject ?? process.env.ORY_AGENT_SUBJECT_ID?.trim(),
94
111
  });
95
- // Every span — including direct `tracer.record(...)` calls from
96
- // plugin handlers gets userSubject / agentSubject merged in, so
97
- // tool.invoke / tool.block / permission.observe_deny spans carry
98
- // the same principal attribution as the API-call spans.
99
- this.tracer.setAttributeEnricher(() => this.principalSpanAttributes());
100
- // Seed agent principal from the constructor `apiKey` so existing
101
- // call sites keep working until they migrate to ensureAgentIdentity.
102
- if (config.apiKey)
103
- this._agentPrincipal.token = config.apiKey;
104
- // The `apiKey` is also the admin credential for the Keto Permission /
105
- // Relationship APIs. Seed it separately so that when `ensureAgentIdentity`
106
- // later replaces the agent token with a DCR OAuth2 token, permission
107
- // checks keep authenticating with the project API key rather than the
108
- // OAuth2 token Ory Network rejects. See {@link _adminApiKey}.
109
- if (config.apiKey)
110
- this._adminApiKey = config.apiKey;
111
- const { frontend, oauth2, permission, relationship } = this.buildApis();
112
+ if (this._runtimeCredential?.subject) {
113
+ this._agentPrincipal = { subject: this._runtimeCredential.subject };
114
+ }
115
+ if (config.eventReporter) {
116
+ const reporterOptions = config.eventReporter === true ? {} : config.eventReporter;
117
+ this.eventReporter = new event_reporter_js_1.AgentSecurityEventReporter({
118
+ ...reporterOptions,
119
+ projectUrl: config.projectUrl,
120
+ agentSecurityUrl: this.agentSecurityUrl,
121
+ token: (sessionId, principalKey) => this.eventPrincipal(sessionId, principalKey).token,
122
+ request: (url, init, sessionId, principalKey) => {
123
+ const credential = (principalKey ? this._runtimeCredentialsByPrincipalKey.get(principalKey) : undefined) ??
124
+ (sessionId ? this._runtimeCredentialsBySession.get(sessionId) : undefined) ??
125
+ this._runtimeCredential;
126
+ if (!credential)
127
+ return undefined;
128
+ return (0, runtime_credential_js_1.authenticatedRuntimeRequest)(credential, {
129
+ url,
130
+ init,
131
+ authorizationHeader: "authorization",
132
+ });
133
+ },
134
+ onUnauthorized: (sessionId, principalKey, rejectedToken) => {
135
+ const principal = this.eventPrincipal(sessionId, principalKey);
136
+ if (principal.token === rejectedToken) {
137
+ if (principalKey) {
138
+ this._eventPrincipalsByKey.set(principalKey, { subject: principal.subject });
139
+ if (this.agentPrincipalForSession(sessionId).subject
140
+ && (0, event_reporter_js_1.eventPrincipalKey)(this.agentPrincipalForSession(sessionId).subject) === principalKey) {
141
+ this.setAgentPrincipal({ subject: principal.subject }, sessionId);
142
+ }
143
+ }
144
+ else {
145
+ this.setAgentPrincipal({ subject: principal.subject }, sessionId);
146
+ }
147
+ (0, agent_auth_js_1.invalidateAgentAccessToken)(rejectedToken);
148
+ }
149
+ },
150
+ logger: this.logger,
151
+ });
152
+ this.logger.setActivitySink((entry) => {
153
+ const enrichedSubject = entry.attributes?.agentSubject;
154
+ const principal = typeof enrichedSubject === "string"
155
+ ? { subject: enrichedSubject }
156
+ : this.agentPrincipalForSession(entry.sessionId);
157
+ this.eventReporter?.enqueue(entry, principal.subject ? (0, event_reporter_js_1.eventPrincipalKey)(principal.subject) : undefined);
158
+ });
159
+ }
160
+ // User OAuth APIs remain independent from the runtime credential used by
161
+ // Agent Security broker requests.
162
+ const { frontend, oauth2, permission } = this.buildApis();
112
163
  this.frontend = frontend;
113
164
  this.oauth2 = oauth2;
114
165
  this.permission = permission;
115
- this.relationship = relationship;
116
166
  }
117
167
  /** Build a fresh set of Ory API instances using the current agent token. */
118
168
  buildApis() {
119
- const accessToken = this._agentPrincipal.token;
169
+ const accessToken = this.agentPrincipal.token;
170
+ const auth = accessToken ? { accessToken } : {};
120
171
  const apiConfig = new client_1.Configuration({
121
172
  basePath: this.config.projectUrl,
122
- ...(accessToken ? { accessToken } : {}),
173
+ ...auth,
123
174
  });
124
- // Keto (Permission + Relationship) authenticates with the admin project
125
- // API key when one is set; otherwise it reuses the agent token (legacy
126
- // single-credential behavior). When the two credentials are identical we
127
- // reuse the same Configuration object so a single-credential deployment —
128
- // and tests that stub one Configuration — see exactly one.
129
- const ketoToken = this._adminApiKey ?? accessToken;
130
- const ketoConfig = ketoToken === accessToken
131
- ? apiConfig
132
- : new client_1.Configuration({
133
- basePath: this.config.projectUrl,
134
- ...(ketoToken ? { accessToken: ketoToken } : {}),
135
- });
136
175
  return {
137
176
  frontend: new client_1.FrontendApi(apiConfig),
138
177
  oauth2: new client_1.OAuth2Api(apiConfig),
139
- permission: new client_1.PermissionApi(ketoConfig),
140
- relationship: new client_1.RelationshipApi(ketoConfig),
178
+ permission: this.buildBrokerPermissionTransport(),
141
179
  };
142
180
  }
181
+ /** Canonical broker origin, with the shipped project URL fallback applied. */
182
+ get agentSecurityUrl() {
183
+ return this.config.agentSecurityUrl ?? this.config.projectUrl;
184
+ }
185
+ buildBrokerPermissionTransport() {
186
+ return {
187
+ checkPermission: async (request) => {
188
+ const response = await this.postPermissionChecks([permissionCheckFromRequest(request)]);
189
+ return { data: response.data.results[0] ?? { allowed: false }, headers: response.headers };
190
+ },
191
+ batchCheckPermission: async ({ batchCheckPermissionBody }) => {
192
+ const checks = batchCheckPermissionBody.tuples.map(permissionCheckFromTuple);
193
+ return this.postPermissionChecks(checks);
194
+ },
195
+ };
196
+ }
197
+ async postPermissionChecks(checks) {
198
+ const credential = this.runtimeCredential;
199
+ if (!credential)
200
+ throw httpError(403, "Runtime credential is required for permission checks");
201
+ const results = [];
202
+ let headers;
203
+ for (let start = 0; start < checks.length; start += BROKER_PERMISSION_BATCH_SIZE) {
204
+ const chunk = checks.slice(start, start + BROKER_PERMISSION_BATCH_SIZE);
205
+ const body = {
206
+ project_url: this.config.projectUrl,
207
+ checks: chunk.map((check) => ({
208
+ namespace: check.namespace,
209
+ object: check.object,
210
+ relation: check.relation,
211
+ principal: this.permissionPrincipal(check),
212
+ })),
213
+ };
214
+ const response = await (0, runtime_credential_js_1.authenticatedRuntimeRequest)(credential, {
215
+ url: `${this.agentSecurityUrl.replace(/\/+$/, "")}/agent-security/v1/permissions:check`,
216
+ init: {
217
+ method: "POST",
218
+ headers: { "content-type": "application/json" },
219
+ body: JSON.stringify(body),
220
+ signal: AbortSignal.timeout(30_000),
221
+ },
222
+ authorizationHeader: "authorization",
223
+ });
224
+ if (!response.ok)
225
+ throw httpError(response.status, `HTTP ${response.status}`);
226
+ const data = await response.json();
227
+ if (!Array.isArray(data.results)
228
+ || data.results.length !== chunk.length
229
+ || data.results.some((result) => typeof result !== "object"
230
+ || result === null
231
+ || typeof result.allowed !== "boolean"
232
+ || (result.error !== undefined && typeof result.error !== "string"))) {
233
+ throw httpError(403, "Agent Security returned an invalid permission result");
234
+ }
235
+ results.push(...data.results);
236
+ headers = response.headers;
237
+ }
238
+ return { data: { results }, headers };
239
+ }
240
+ permissionPrincipal(check) {
241
+ if (check.subjectId) {
242
+ throw httpError(403, "Permission subject IDs are not accepted by Agent Security");
243
+ }
244
+ const subject = check.subjectSet;
245
+ if (!subject)
246
+ throw httpError(403, "Permission check has no representable principal");
247
+ const projectPrincipal = subject.object === "project" &&
248
+ subject.relation === "enforcedSubjects" &&
249
+ subject.namespace === "PermissionMode";
250
+ if (projectPrincipal &&
251
+ (check.relation === "blockedSubjects" || (check.namespace === "PermissionMode" &&
252
+ check.object === "mode" &&
253
+ check.relation === "enforcedSubjects"))) {
254
+ return { kind: "project" };
255
+ }
256
+ if (subject.relation) {
257
+ throw httpError(403, "Permission subject-set expansion is not accepted by Agent Security");
258
+ }
259
+ const ownerNamespace = process.env.ORY_USER_SUBJECT_NAMESPACE?.trim()
260
+ || this.config.userSubjectNamespace
261
+ || "User";
262
+ if (subject.namespace === ownerNamespace) {
263
+ if (subject.object === this.userPrincipal.subject) {
264
+ return { kind: "owner", namespace: subject.namespace };
265
+ }
266
+ throw httpError(403, "Permission owner does not match the authenticated user");
267
+ }
268
+ const parts = subject.object.split("|");
269
+ if (parts.some((part) => !part.trim())) {
270
+ throw httpError(403, "Permission principal contains an invalid subject separator");
271
+ }
272
+ if (subject.namespace === "Agent") {
273
+ if (parts[0] !== this.agentPrincipal.subject) {
274
+ throw httpError(403, "Permission agent does not match the authenticated agent");
275
+ }
276
+ if (parts.length === 1)
277
+ return { kind: "agent" };
278
+ if (parts.length === 2)
279
+ return { kind: "agent_session", session_id: parts[1] };
280
+ }
281
+ else if (subject.namespace === "SubAgent") {
282
+ if (parts.length === 1) {
283
+ const typeName = this.subAgentTypeForClient(parts[0]);
284
+ if (typeName)
285
+ return { kind: "subagent", type_name: typeName };
286
+ }
287
+ if ((parts.length === 3 || parts.length === 4) && this.subAgentTypeForClient(parts[0]) === parts[2]) {
288
+ return {
289
+ kind: "subagent_spawn",
290
+ session_id: parts[1],
291
+ type_name: parts[2],
292
+ ...(parts[3] ? { per_spawn_id: parts[3] } : {}),
293
+ };
294
+ }
295
+ }
296
+ throw httpError(403, "Permission check principal cannot be represented safely");
297
+ }
298
+ subAgentTypeForClient(clientId) {
299
+ const bySession = (0, config_js_1.loadConfig)().agent?.subAgents?.[this.harness];
300
+ for (const byType of Object.values(bySession ?? {})) {
301
+ const match = Object.entries(byType).find(([, credentials]) => credentials.clientId === clientId);
302
+ if (match)
303
+ return match[0];
304
+ }
305
+ return undefined;
306
+ }
143
307
  /** Snapshot of the current user principal. */
144
308
  get userPrincipal() {
145
- return { ...this._userPrincipal };
309
+ return this.userPrincipalForSession(this.sessionId);
310
+ }
311
+ /** Snapshot of the user principal bound to one session. */
312
+ userPrincipalForSession(sessionId) {
313
+ const session = sessionId?.trim();
314
+ const principal = session ? this._userPrincipalsBySession.get(session) : undefined;
315
+ return { ...(principal ?? this._userPrincipal) };
146
316
  }
147
317
  /** Snapshot of the current agent principal. */
148
318
  get agentPrincipal() {
149
- return { ...this._agentPrincipal };
319
+ const runtimeSubject = (0, context_js_1.currentSessionContext)().runtimeCredential?.subject;
320
+ if (runtimeSubject)
321
+ return { subject: runtimeSubject };
322
+ return this.agentPrincipalForSession(this.sessionId);
323
+ }
324
+ /** Active noninteractive broker credential. It is process memory only. */
325
+ get runtimeCredential() {
326
+ const context = (0, context_js_1.currentSessionContext)();
327
+ if (context.runtimeCredential)
328
+ return context.runtimeCredential;
329
+ if (context.sessionId) {
330
+ const credential = this._runtimeCredentialsBySession.get(context.sessionId);
331
+ if (credential)
332
+ return credential;
333
+ }
334
+ if (this._runtimeCredential)
335
+ return this._runtimeCredential;
336
+ const principal = this.agentPrincipal;
337
+ return principal.token
338
+ ? (0, runtime_credential_js_1.talosRuntimeCredential)(principal.token, { subject: principal.subject })
339
+ : undefined;
340
+ }
341
+ get hasRuntimeCredential() {
342
+ return this.runtimeCredential !== undefined;
343
+ }
344
+ setRuntimeCredential(credential) {
345
+ const sessionId = this.sessionId?.trim();
346
+ if (sessionId) {
347
+ if (credential)
348
+ this._runtimeCredentialsBySession.set(sessionId, credential);
349
+ else
350
+ this._runtimeCredentialsBySession.delete(sessionId);
351
+ }
352
+ else {
353
+ this._runtimeCredential = credential;
354
+ }
355
+ if (credential)
356
+ this.rememberRuntimeCredential(credential);
357
+ if (credential?.subject)
358
+ this.setAgentPrincipal({ subject: credential.subject }, sessionId);
359
+ }
360
+ /** Retain an actor-scoped credential for deferred event delivery without activating it globally. */
361
+ rememberRuntimeCredential(credential) {
362
+ if (credential.subject) {
363
+ this._runtimeCredentialsByPrincipalKey.set((0, event_reporter_js_1.eventPrincipalKey)(credential.subject), credential);
364
+ }
365
+ }
366
+ /** Run one hook with session/credential state isolated from concurrent hooks. */
367
+ withRuntimeContext(context, work) {
368
+ if (context.runtimeCredential)
369
+ this.rememberRuntimeCredential(context.runtimeCredential);
370
+ return (0, context_js_1.withSessionContext)(context, work);
371
+ }
372
+ /** Snapshot of the agent principal bound to one session. */
373
+ agentPrincipalForSession(sessionId) {
374
+ const session = sessionId?.trim();
375
+ const principal = session ? this._agentPrincipalsBySession.get(session) : undefined;
376
+ return { ...(principal ?? this._agentPrincipal) };
377
+ }
378
+ eventPrincipal(sessionId, principalKey) {
379
+ if (principalKey) {
380
+ const principal = this._eventPrincipalsByKey.get(principalKey);
381
+ if (principal)
382
+ return { ...principal };
383
+ const sessionPrincipal = this.agentPrincipalForSession(sessionId);
384
+ if (sessionPrincipal.subject && (0, event_reporter_js_1.eventPrincipalKey)(sessionPrincipal.subject) === principalKey) {
385
+ return sessionPrincipal;
386
+ }
387
+ return {};
388
+ }
389
+ return this.agentPrincipalForSession(sessionId);
390
+ }
391
+ /**
392
+ * The harness this client runs in (`"claude-code"`, `"codex"`, …). Exposed
393
+ * because persisted agent / sub-agent credentials and delegation anchors are
394
+ * keyed first by harness and then by session.
395
+ */
396
+ get harness() {
397
+ return this.config.harness;
398
+ }
399
+ /**
400
+ * The harness session currently in scope, or `undefined` outside one.
401
+ *
402
+ * Read from standalone ambient session context, which every harness sets from its
403
+ * own session identifier at the top of a lifecycle invocation — so identity
404
+ * and delegation code can select the session's credential and node without
405
+ * each plugin threading the id through its own call chain.
406
+ */
407
+ get sessionId() {
408
+ return (0, context_js_1.currentSessionContext)().sessionId;
409
+ }
410
+ /** Best-effort bounded flush for short-lived hook subprocesses and tests. */
411
+ async flushEvents(timeoutMs = 1_000) {
412
+ if (!this.eventReporter || timeoutMs <= 0)
413
+ return;
414
+ const startedAt = Date.now();
415
+ const remaining = () => Math.max(0, timeoutMs - (Date.now() - startedAt));
416
+ // Deliver sessions that already have a bearer before historical credential
417
+ // recovery can consume the short-lived hook process's entire deadline.
418
+ await this.eventReporter.flush(remaining());
419
+ if (remaining() <= 0)
420
+ return;
421
+ const controller = new AbortController();
422
+ const timer = setTimeout(() => controller.abort(), remaining());
423
+ timer.unref?.();
424
+ try {
425
+ for (const scope of this.eventReporter.pendingScopes) {
426
+ if (controller.signal.aborted)
427
+ break;
428
+ if (this.eventPrincipal(scope.sessionId, scope.principalKey).token)
429
+ continue;
430
+ try {
431
+ const { loadAllSubAgentDynamicCredentials, ensureSubAgentIdentity } = await import("./agent-auth.js");
432
+ const subAgent = scope.principalKey
433
+ ? Object.entries(loadAllSubAgentDynamicCredentials(this.harness, scope.sessionId?.trim())).find(([, credentials]) => (0, event_reporter_js_1.eventPrincipalKey)(credentials.clientId) === scope.principalKey)
434
+ : undefined;
435
+ if (subAgent) {
436
+ const [subAgentType] = subAgent;
437
+ const identity = await (0, context_js_1.withSessionContext)({ sessionId: scope.sessionId }, () => ensureSubAgentIdentity(this, {
438
+ subAgentType,
439
+ harness: this.harness,
440
+ sessionId: scope.sessionId,
441
+ emitActivity: false,
442
+ signal: controller.signal,
443
+ }));
444
+ if (identity.subject && identity.token) {
445
+ this._eventPrincipalsByKey.set((0, event_reporter_js_1.eventPrincipalKey)(identity.subject), {
446
+ subject: identity.subject,
447
+ token: identity.token,
448
+ });
449
+ }
450
+ }
451
+ else {
452
+ const { ensureReadCredential } = await import("./read-credential.js");
453
+ await (0, context_js_1.withSessionContext)({ sessionId: scope.sessionId }, () => ensureReadCredential(this, {
454
+ signal: controller.signal,
455
+ allowRegistration: false,
456
+ }));
457
+ }
458
+ }
459
+ catch (error) {
460
+ this.logger.debug("events.credential_resolution_failed", {
461
+ message: error instanceof Error ? error.message : String(error),
462
+ });
463
+ }
464
+ }
465
+ }
466
+ finally {
467
+ clearTimeout(timer);
468
+ }
469
+ await this.eventReporter.flush(remaining());
150
470
  }
151
471
  /**
152
472
  * Configured user-subject namespace (from config, resolved at construction).
@@ -159,53 +479,44 @@ class OryAgentClient {
159
479
  }
160
480
  /**
161
481
  * Set or update the human user principal. The user is the subject of
162
- * permission checks but does not authenticate outgoing API calls.
163
- * Pass `{}` (or fields set to undefined) to clear.
164
- */
165
- setUserPrincipal(principal) {
166
- this._userPrincipal = { ...principal };
167
- }
168
- /** Whether an admin API key is set for the Keto APIs (never exposes it). */
169
- get hasAdminApiKey() {
170
- return !!this._adminApiKey;
171
- }
172
- /**
173
- * Set or update the admin API key (an Ory Network project API key) used to
174
- * authenticate the Keto Permission / Relationship APIs. Rebuilds those API
175
- * instances when the key changes; a no-op update leaves them alone so test
176
- * stubs survive. See {@link _adminApiKey} for why Keto needs a credential
177
- * distinct from the agent's OAuth2 token.
482
+ * permission checks. Its token does not authenticate agent-attributed calls
483
+ * (OAuth2 / Frontend / Relationship), but it *is* the fallback credential for
484
+ * Keto permission reads when no agent token is present (see `buildApis`) — so
485
+ * a change to the user token rebuilds the API instances, otherwise the
486
+ * rehydrated user token would never reach the Permission API in a fresh
487
+ * subprocess. Pass `{}` (or fields set to undefined) to clear.
178
488
  */
179
- setAdminApiKey(apiKey) {
180
- if (this._adminApiKey === apiKey)
181
- return;
182
- this._adminApiKey = apiKey;
183
- const { frontend, oauth2, permission, relationship } = this.buildApis();
184
- this.frontend = frontend;
185
- this.oauth2 = oauth2;
186
- this.permission = permission;
187
- this.relationship = relationship;
489
+ setUserPrincipal(principal, sessionId = this.sessionId) {
490
+ const session = sessionId?.trim();
491
+ if (session)
492
+ this._userPrincipalsBySession.set(session, { ...principal });
493
+ else
494
+ this._userPrincipal = { ...principal };
188
495
  }
189
496
  /**
190
497
  * Set or update the AI agent principal. The agent's token (when present)
191
- * authenticates outgoing calls to the OAuth2 / Frontend APIs and carries
192
- * audit attribution ("agent X acting on behalf of user Y"). Note the Keto
193
- * Permission / Relationship APIs authenticate with {@link _adminApiKey}
194
- * instead when one is set Ory Network rejects OAuth2 tokens there.
195
- * Rebuilds the underlying Ory API instances when the token actually changes
196
- * so subsequent calls pick it up; otherwise leaves the API instances alone
197
- * (so test stubs survive a no-op update).
498
+ * authenticates *every* outgoing Ory call OAuth2 / Frontend and Keto
499
+ * (Permission / Relationship) alike and carries audit attribution ("agent X
500
+ * acting on behalf of user Y"). Rebuilds the underlying Ory API instances when
501
+ * the token actually changes so subsequent calls pick it up; otherwise leaves
502
+ * the API instances alone (so test stubs survive a no-op update).
198
503
  */
199
- setAgentPrincipal(principal) {
200
- const tokenChanged = this._agentPrincipal.token !== principal.token;
201
- this._agentPrincipal = { ...principal };
504
+ setAgentPrincipal(principal, sessionId = this.sessionId) {
505
+ const tokenChanged = this.agentPrincipalForSession(sessionId).token !== principal.token;
506
+ const session = sessionId?.trim();
507
+ if (session)
508
+ this._agentPrincipalsBySession.set(session, { ...principal });
509
+ else
510
+ this._agentPrincipal = { ...principal };
511
+ if (principal.subject) {
512
+ this._eventPrincipalsByKey.set((0, event_reporter_js_1.eventPrincipalKey)(principal.subject), { ...principal });
513
+ }
202
514
  if (!tokenChanged)
203
515
  return;
204
- const { frontend, oauth2, permission, relationship } = this.buildApis();
516
+ const { frontend, oauth2, permission } = this.buildApis();
205
517
  this.frontend = frontend;
206
518
  this.oauth2 = oauth2;
207
519
  this.permission = permission;
208
- this.relationship = relationship;
209
520
  }
210
521
  // ─── Session (Ory Identities / Kratos) ──────────────────────────
211
522
  /**
@@ -221,7 +532,7 @@ class OryAgentClient {
221
532
  });
222
533
  return cached.info;
223
534
  }
224
- const span = this.tracer.startSpan("session.verify");
535
+ const activity = this.logger.startActivity("session.verify");
225
536
  try {
226
537
  const response = await this.frontend.toSession({
227
538
  xSessionToken: sessionToken,
@@ -254,7 +565,7 @@ class OryAgentClient {
254
565
  active: info.active,
255
566
  aal: info.authenticatorAssuranceLevel,
256
567
  });
257
- span.end("ok", {
568
+ activity.end("ok", {
258
569
  sessionId: info.sessionId,
259
570
  identityId: info.identityId,
260
571
  active: info.active,
@@ -269,7 +580,7 @@ class OryAgentClient {
269
580
  status: oryErr.status,
270
581
  message: oryErr.message,
271
582
  });
272
- span.end("error", {
583
+ activity.end("error", {
273
584
  error: oryErr.code,
274
585
  ...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
275
586
  });
@@ -283,7 +594,7 @@ class OryAgentClient {
283
594
  */
284
595
  async introspectToken(token, scope) {
285
596
  this.logger.debug("oauth2.introspect.start", { hasToken: !!token });
286
- const span = this.tracer.startSpan("oauth2.introspect");
597
+ const activity = this.logger.startActivity("oauth2.introspect");
287
598
  try {
288
599
  const response = await this.oauth2.introspectOAuth2Token({
289
600
  token,
@@ -307,7 +618,7 @@ class OryAgentClient {
307
618
  clientId: info.clientId,
308
619
  subject: info.subject,
309
620
  });
310
- span.end("ok", {
621
+ activity.end("ok", {
311
622
  active: info.active,
312
623
  clientId: info.clientId,
313
624
  subject: info.subject,
@@ -322,7 +633,7 @@ class OryAgentClient {
322
633
  status: oryErr.status,
323
634
  message: oryErr.message,
324
635
  });
325
- span.end("error", {
636
+ activity.end("error", {
326
637
  error: oryErr.code,
327
638
  ...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
328
639
  });
@@ -333,19 +644,23 @@ class OryAgentClient {
333
644
  /**
334
645
  * Check if a subject has permission to perform an action.
335
646
  *
336
- * `options.spanAttributes` are merged into the `permission.check` trace
337
- * span so callers can attach harness-side context (toolName, mcpServer,
647
+ * `options.activityAttributes` are merged into the `permission.check` activity
648
+ * event so callers can attach harness-side context (toolName, mcpServer,
338
649
  * etc.) that the wire-level `PermissionCheck` shape doesn't carry.
650
+ *
651
+ * `options.informational` marks a check whose `false` result is *not* a
652
+ * denial — e.g. the permission-mode probe, where "enforce not granted" simply
653
+ * selects `observe`. Such an event ends `ok` (the read succeeded) rather than
654
+ * `denied`, so a reader watching activity doesn't mistake a routine mode read
655
+ * for a blocked tool. The boolean is still recorded as the `allowed`
656
+ * attribute either way.
339
657
  */
340
658
  async checkPermission(check, options) {
341
659
  this.logger.debug("permission.check.start", { ...check });
342
- const span = this.tracer.startSpan("permission.check", {
343
- attributes: {
344
- object: check.object,
345
- relation: check.relation,
346
- ...this.principalSpanAttributes(),
347
- ...options?.spanAttributes,
348
- },
660
+ const activity = this.logger.startActivity("permission.check", {
661
+ object: check.object,
662
+ relation: check.relation,
663
+ ...options?.activityAttributes,
349
664
  });
350
665
  try {
351
666
  const response = await this.permission.checkPermission({
@@ -372,7 +687,7 @@ class OryAgentClient {
372
687
  subjectId: check.subjectId,
373
688
  subjectSet: check.subjectSet,
374
689
  });
375
- span.end(result.allowed ? "ok" : "denied", {
690
+ activity.end(options?.informational || result.allowed ? "ok" : "denied", {
376
691
  allowed: result.allowed,
377
692
  ...(oryRequestId ? { oryRequestId } : {}),
378
693
  });
@@ -380,14 +695,13 @@ class OryAgentClient {
380
695
  }
381
696
  catch (err) {
382
697
  const oryErr = this.classifyError(err);
383
- this.warnIfKetoCredentialMismatch(oryErr);
384
698
  this.logger.error("permission.check.failed", {
385
699
  code: oryErr.code,
386
700
  status: oryErr.status,
387
701
  message: oryErr.message,
388
702
  ...check,
389
703
  });
390
- span.end("error", {
704
+ activity.end("error", {
391
705
  error: oryErr.code,
392
706
  ...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
393
707
  });
@@ -397,19 +711,16 @@ class OryAgentClient {
397
711
  /**
398
712
  * Check multiple permissions in a single request.
399
713
  *
400
- * `options.spanAttributes` are merged into the `permission.batch_check`
401
- * span same purpose as `checkPermission`'s spanAttributes.
714
+ * `options.activityAttributes` are merged into the `permission.batch_check`
715
+ * activity event, for the same purpose as `checkPermission`'s attributes.
402
716
  */
403
717
  async batchCheckPermissions(checks, options) {
404
718
  this.logger.debug("permission.batch_check.start", {
405
719
  count: checks.length,
406
720
  });
407
- const span = this.tracer.startSpan("permission.batch_check", {
408
- attributes: {
409
- count: checks.length,
410
- ...this.principalSpanAttributes(),
411
- ...options?.spanAttributes,
412
- },
721
+ const activity = this.logger.startActivity("permission.batch_check", {
722
+ count: checks.length,
723
+ ...options?.activityAttributes,
413
724
  });
414
725
  try {
415
726
  const response = await this.permission.batchCheckPermission({
@@ -446,7 +757,7 @@ class OryAgentClient {
446
757
  allowed,
447
758
  denied,
448
759
  });
449
- span.end("ok", {
760
+ activity.end("ok", {
450
761
  allowed,
451
762
  denied,
452
763
  ...(oryRequestId ? { oryRequestId } : {}),
@@ -455,261 +766,177 @@ class OryAgentClient {
455
766
  }
456
767
  catch (err) {
457
768
  const oryErr = this.classifyError(err);
458
- this.warnIfKetoCredentialMismatch(oryErr);
459
769
  this.logger.error("permission.batch_check.failed", {
460
770
  code: oryErr.code,
461
771
  status: oryErr.status,
462
772
  message: oryErr.message,
463
773
  });
464
- span.end("error", {
774
+ activity.end("error", {
465
775
  error: oryErr.code,
466
776
  ...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
467
777
  });
468
778
  throw oryErr;
469
779
  }
470
780
  }
471
- // ─── Relationships (Ory Permissions / Keto write API) ────────────
781
+ // ─── Delegation (agent-security broker) ──────────────────────────
472
782
  /**
473
- * Create a relation tuple in Keto. Idempotent: a 409 (tuple already
474
- * exists) is swallowed and reported as success. All other errors are
475
- * classified and re-thrown callers wrap in fail-open semantics if
476
- * the tuple is non-critical (e.g. audit-only delegation tracking).
783
+ * Record a delegation edge through the Ory Agent Security broker
784
+ * (`POST <agentSecurityUrl>/agent-security/v1/delegations:record`), authenticated
785
+ * with the process runtime credential. The broker constructs and writes the Keto tuple the plugin
786
+ * sends only the semantic inputs, so the join-key/tuple encoding stays
787
+ * server-side and cannot drift here. Returns the node id + chain the broker
788
+ * assigned. Throws a classified {@link OryError} on any non-2xx; callers wrap
789
+ * in fail-open semantics (delegation is audit-only).
477
790
  *
478
- * `options.spanAttributes` are merged into the `relationship.create`
479
- * trace span so callers can attach context like `delegation: "user→agent"`.
791
+ * The broker exists only on hosted Ory; against the local dev stack the call
792
+ * returns 404 and callers treat it as a best-effort no-op.
480
793
  */
481
- async createRelationship(check, options) {
482
- this.logger.debug("relationship.create.start", { ...check });
483
- const span = this.tracer.startSpan("relationship.create", {
484
- attributes: {
485
- namespace: check.namespace,
486
- object: check.object,
487
- relation: check.relation,
488
- ...this.principalSpanAttributes(),
489
- ...options?.spanAttributes,
794
+ async recordDelegation(input, options) {
795
+ const kind = input.kind === "agent" ? "PRINCIPAL_KIND_AGENT" : "PRINCIPAL_KIND_SUBAGENT";
796
+ const sessionId = input.sessionId ?? this.sessionId;
797
+ // `session_id` and `per_spawn_id` belong on `principal` — that is where the
798
+ // broker's proto declares them. They were previously sent at the top level
799
+ // of the body, where `RecordDelegationBody` has no such fields; the
800
+ // gateway's protojson runs with `DiscardUnknown: true`, so every value was
801
+ // silently dropped and the server never saw a session at all.
802
+ const body = {
803
+ project_url: this.config.projectUrl,
804
+ ...(input.identityId ? { identity_id: input.identityId } : {}),
805
+ harness: this.config.harness,
806
+ principal: {
807
+ kind,
808
+ ...(input.host ? { host: input.host } : {}),
809
+ ...(input.subAgentType ? { type_name: input.subAgentType } : {}),
810
+ ...(sessionId ? { session_id: sessionId } : {}),
811
+ ...(input.perSpawnId ? { per_spawn_id: input.perSpawnId } : {}),
490
812
  },
491
- });
813
+ ...(input.delegatedBy ? { delegated_by: input.delegatedBy } : {}),
814
+ };
815
+ const activity = this.logger.startActivity("delegation.record", { delegationKind: input.kind, ...options?.activityAttributes }, sessionId);
816
+ const url = `${this.agentSecurityUrl.replace(/\/+$/, "")}/agent-security/v1/delegations:record`;
817
+ // The user bearer authenticates who delegated; the agent or sub-agent bearer
818
+ // separately proves the principal being attached to the delegation edge.
819
+ const userToken = this.userPrincipalForSession(sessionId).token;
820
+ const runtimeCredential = options?.runtimeCredential ?? (options?.agentToken
821
+ ? (0, runtime_credential_js_1.talosRuntimeCredential)(options.agentToken)
822
+ : this.runtimeCredential);
492
823
  try {
493
- const response = await this.relationship.createRelationship({
494
- createRelationshipBody: {
495
- namespace: check.namespace,
496
- object: check.object,
497
- relation: check.relation,
498
- subject_id: check.subjectId,
499
- ...(check.subjectSet
500
- ? {
501
- subject_set: {
502
- namespace: check.subjectSet.namespace,
503
- object: check.subjectSet.object,
504
- relation: check.subjectSet.relation,
505
- },
506
- }
507
- : {}),
824
+ if (!userToken || !runtimeCredential) {
825
+ throw httpError(403, "User token and runtime credential are required");
826
+ }
827
+ const res = await (0, runtime_credential_js_1.authenticatedRuntimeRequest)(runtimeCredential, {
828
+ url,
829
+ init: {
830
+ method: "POST",
831
+ headers: {
832
+ "content-type": "application/json",
833
+ authorization: `Bearer ${userToken}`,
834
+ },
835
+ body: JSON.stringify(body),
836
+ signal: options?.signal ?? AbortSignal.timeout(30_000),
508
837
  },
838
+ authorizationHeader: "x-ory-agent-authorization",
839
+ fetch: options?.fetchImpl,
509
840
  });
510
- const oryRequestId = extractOryRequestId(response.headers);
511
- this.logger.info("relationship.create.result", {
512
- namespace: check.namespace,
513
- object: check.object,
514
- relation: check.relation,
515
- subjectId: check.subjectId,
516
- });
517
- span.end("ok", {
518
- created: true,
519
- ...(oryRequestId ? { oryRequestId } : {}),
520
- });
521
- return { created: true, alreadyExisted: false };
522
- }
523
- catch (err) {
524
- const oryErr = this.classifyError(err);
525
- // Keto returns 409 when the tuple already exists; treat as success
526
- // so callers can call this on every session start without dedup.
527
- if (oryErr.status === 409) {
528
- this.logger.debug("relationship.create.exists", {
529
- namespace: check.namespace,
530
- object: check.object,
531
- relation: check.relation,
532
- });
533
- span.end("ok", {
534
- created: false,
535
- alreadyExisted: true,
536
- ...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
537
- });
538
- return { created: false, alreadyExisted: true };
841
+ if (!res.ok) {
842
+ const err = new Error(`HTTP ${res.status}`);
843
+ err.status = res.status;
844
+ throw err;
539
845
  }
540
- this.warnIfKetoCredentialMismatch(oryErr);
541
- this.logger.error("relationship.create.failed", {
542
- code: oryErr.code,
543
- status: oryErr.status,
544
- message: oryErr.message,
545
- ...check,
546
- });
547
- span.end("error", {
548
- error: oryErr.code,
549
- ...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
550
- });
551
- throw oryErr;
552
- }
553
- }
554
- /**
555
- * True if the *exact* relation tuple already exists in Keto. Queries the
556
- * relation-tuple listing API filtered by the tuple's own coordinates
557
- * (namespace/object/relation + subject), so — unlike `checkPermission` —
558
- * it does not follow subject-set expansion: it answers "is this literal
559
- * tuple stored?", not "does this permission resolve?". Throws a classified
560
- * {@link OryError} if the query itself fails.
561
- */
562
- async relationshipExists(check) {
563
- try {
564
- const response = await this.relationship.getRelationships({
565
- namespace: check.namespace,
566
- object: check.object,
567
- relation: check.relation,
568
- ...(check.subjectId ? { subjectId: check.subjectId } : {}),
569
- ...(check.subjectSet
570
- ? {
571
- subjectSetNamespace: check.subjectSet.namespace,
572
- subjectSetObject: check.subjectSet.object,
573
- subjectSetRelation: check.subjectSet.relation,
574
- }
575
- : {}),
576
- pageSize: 1,
577
- });
578
- const tuples = response.data?.relation_tuples ?? [];
579
- return tuples.length > 0;
580
- }
581
- catch (err) {
582
- throw this.classifyError(err);
583
- }
584
- }
585
- /**
586
- * Idempotent relationship write. Keto's create API is **not** idempotent —
587
- * `PUT /admin/relation-tuples` inserts a fresh row on every call (each with
588
- * a new primary key) and never returns 409, so calling `createRelationship`
589
- * on repeated bootstraps / session starts accumulates duplicate tuples.
590
- * This reads the exact tuple first (via {@link relationshipExists}) and only
591
- * writes when it is absent — which also self-heals if the tuple was deleted
592
- * out of band.
593
- *
594
- * If the existence probe itself fails, the tuple is left untouched (we do
595
- * not write blind, to avoid re-introducing duplicates when the read path is
596
- * misbehaving) and the classified error is returned as `probeError`. Callers
597
- * that must guarantee the grant exists (e.g. install-time bootstrap) can
598
- * fall back to {@link createRelationship} when `probeError` is set.
599
- */
600
- async ensureRelationship(check, options) {
601
- let exists;
602
- try {
603
- exists = await this.relationshipExists(check);
604
- }
605
- catch (err) {
606
- return { created: false, alreadyExisted: false, probeError: err };
607
- }
608
- if (exists)
609
- return { created: false, alreadyExisted: true };
610
- return this.createRelationship(check, options);
611
- }
612
- /**
613
- * Delete a relation tuple in Keto. Missing tuples (404) are treated as
614
- * success so callers can call this idempotently when unwinding state.
615
- */
616
- async deleteRelationship(check, options) {
617
- this.logger.debug("relationship.delete.start", { ...check });
618
- const span = this.tracer.startSpan("relationship.delete", {
619
- attributes: {
620
- namespace: check.namespace,
621
- object: check.object,
622
- relation: check.relation,
623
- ...this.principalSpanAttributes(),
624
- ...options?.spanAttributes,
625
- },
626
- });
627
- try {
628
- const response = await this.relationship.deleteRelationships({
629
- namespace: check.namespace,
630
- object: check.object,
631
- relation: check.relation,
632
- subjectId: check.subjectId,
633
- ...(check.subjectSet
634
- ? {
635
- subjectSetNamespace: check.subjectSet.namespace,
636
- subjectSetObject: check.subjectSet.object,
637
- subjectSetRelation: check.subjectSet.relation,
638
- }
639
- : {}),
640
- });
641
- const oryRequestId = extractOryRequestId(response.headers);
642
- span.end("ok", {
643
- deleted: true,
644
- ...(oryRequestId ? { oryRequestId } : {}),
645
- });
646
- return { deleted: true, notFound: false };
846
+ const json = (await res.json());
847
+ // Accept both spellings. The broker's OpenAPI document carries the proto
848
+ // field names (`node_id`), but grpc-gateway's default marshaler emits
849
+ // lowerCamelCase — reading only one silently yields "" on the other,
850
+ // which reads as "no node" and stops every sub-agent edge from being
851
+ // recorded. Tolerating both also lets the two repos deploy in any order.
852
+ const raw = json.node_id ?? json.nodeId;
853
+ const nodeId = typeof raw === "string" ? raw : "";
854
+ const chain = json.delegation_chain ?? json.delegationChain;
855
+ const delegationChain = Array.isArray(chain)
856
+ ? chain.filter((c) => typeof c === "string")
857
+ : [];
858
+ activity.end("ok", { nodeId });
859
+ this.logger.debug("delegation.record.result", { kind: input.kind, nodeId });
860
+ return { nodeId, delegationChain };
647
861
  }
648
862
  catch (err) {
649
863
  const oryErr = this.classifyError(err);
650
- if (oryErr.code === "not_found") {
651
- span.end("ok", {
652
- deleted: false,
653
- notFound: true,
654
- ...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
655
- });
656
- return { deleted: false, notFound: true };
657
- }
658
- this.warnIfKetoCredentialMismatch(oryErr);
659
- this.logger.error("relationship.delete.failed", {
864
+ activity.end("error", { error: oryErr.code });
865
+ this.logger.debug("delegation.record.failed", {
866
+ kind: input.kind,
660
867
  code: oryErr.code,
661
868
  status: oryErr.status,
662
869
  message: oryErr.message,
663
- ...check,
664
- });
665
- span.end("error", {
666
- error: oryErr.code,
667
- ...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
668
870
  });
669
871
  throw oryErr;
670
872
  }
671
873
  }
672
874
  /**
673
- * Span attributes describing which principals were attached to the
674
- * client when the span was started. Useful for correlating audit logs
875
+ * Activity attributes describing which principals are attached to the
876
+ * client. Useful for correlating audit logs
675
877
  * across the user/agent split.
676
878
  */
677
- principalSpanAttributes() {
879
+ principalActivityAttributes() {
678
880
  const out = {};
679
- if (this._userPrincipal.subject)
680
- out.userSubject = this._userPrincipal.subject;
681
- if (this._agentPrincipal.subject)
682
- out.agentSubject = this._agentPrincipal.subject;
881
+ const user = this.userPrincipal;
882
+ const agent = this.agentPrincipal;
883
+ if (user.subject)
884
+ out.userSubject = user.subject;
885
+ if (agent.subject)
886
+ out.agentSubject = agent.subject;
683
887
  return out;
684
888
  }
685
- /**
686
- * When a Keto call is auth-rejected while it was authenticated by something
687
- * other than an Ory Network project API key (`ory_pat_…`) — e.g. a DCR
688
- * OAuth2 access token — the credential *type* is wrong, not merely expired.
689
- * Ory Network's Permission / Relationship APIs only accept a project API
690
- * key. Emit an actionable hint so the failure isn't misread as a stale
691
- * session (the generic `session_inactive` classification of the raw 401).
692
- */
693
- warnIfKetoCredentialMismatch(oryErr) {
694
- if (oryErr.code !== "session_inactive" && oryErr.code !== "forbidden")
695
- return;
696
- const ketoToken = this._adminApiKey ?? this._agentPrincipal.token;
697
- // A real project API key is already in use — treat as a genuine auth error.
698
- if (typeof ketoToken === "string" && ketoToken.startsWith("ory_pat_"))
699
- return;
700
- this.logger.warn("permission.credential_mismatch", {
701
- code: oryErr.code,
702
- message: "Ory Network's Permission/Relationship APIs require a project API key " +
703
- "(ory_pat_…); the agent's OAuth2 access token is not accepted. Create a " +
704
- "key in the Ory Console (Project settings → API keys) and set it with " +
705
- "`configure --api-key <key>` or the ORY_AGENT_API_KEY env var.",
706
- });
707
- }
708
889
  // ─── Error Classification ────────────────────────────────────────
709
890
  /**
710
891
  * Classify an error from any Ory API call into a structured OryError.
711
892
  */
712
893
  classifyError(err) {
894
+ // ── Transport failures, before anything status-shaped ──────────────
895
+ //
896
+ // Ordering matters and was the whole bug in #218. An axios error carries
897
+ // `isAxiosError` whether or not a response arrived, so the status table below
898
+ // claimed *every* transport failure and — with `status === undefined` — fell
899
+ // through to `unknown`, which fails **open** in both modes. That silently
900
+ // downgraded an `enforce` project to `observe` on any certificate problem, and
901
+ // made breaking TLS sufficient to disable all tool gating.
902
+ //
903
+ // It also made the socket-code list further down dead code for every
904
+ // axios-based call: a plain `ECONNREFUSED` classified as `unknown` too, so
905
+ // `network_error` was effectively unreachable from a permission check.
906
+ //
907
+ // A response-less error is by definition a transport failure, so it is
908
+ // classified here, by cause, and never by HTTP status.
909
+ const tlsCode = findTlsErrorCode(err);
910
+ if (tlsCode) {
911
+ return {
912
+ code: "tls_error",
913
+ message: `TLS/certificate verification failed: ${tlsCode} — ${err.message}. ` +
914
+ "The permission check could not be completed against a verified host. " +
915
+ "For a local stack with a private CA, set NODE_EXTRA_CA_CERTS to its root certificate.",
916
+ cause: err,
917
+ };
918
+ }
919
+ const socketCode = findSocketErrorCode(err);
920
+ if (socketCode) {
921
+ return {
922
+ code: "network_error",
923
+ message: `Network error: ${socketCode} — ${err.message}`,
924
+ cause: err,
925
+ };
926
+ }
927
+ // A plain Error carrying a top-level numeric `status`. The `fetch`-based
928
+ // calls (the agent-security broker) raise this shape rather than an axios
929
+ // error, and without this branch every one of them classified as `unknown`
930
+ // with no status at all — so a broker 403 was indistinguishable in the audit
931
+ // trail from a parse failure. Normalized into the axios shape so there is
932
+ // exactly one status→code table below.
933
+ if (!this.isAxiosError(err) && typeof err?.status === "number") {
934
+ return this.classifyError({
935
+ isAxiosError: true,
936
+ message: err.message,
937
+ response: { status: err.status },
938
+ });
939
+ }
713
940
  // Axios errors have a `response` property with status + data
714
941
  if (this.isAxiosError(err)) {
715
942
  const status = err.response?.status;
@@ -782,29 +1009,29 @@ class OryAgentClient {
782
1009
  * Create a client from environment variables, falling back to the
783
1010
  * shared config file at ~/.config/ory-agent-plugins/config.json.
784
1011
  *
785
- * Resolution order for projectUrl and apiKey:
786
- * 1. Environment variables (ORY_PROJECT_URL, ORY_API_KEY)
1012
+ * Resolution order for projectUrl:
1013
+ * 1. Environment variables (ORY_PROJECT_URL / ORY_SDK_URL)
787
1014
  * 2. Config file (~/.config/ory-agent-plugins/config.json)
788
1015
  * 3. Placeholder (fail-open pass-through mode)
789
1016
  */
790
- static fromEnv(harness) {
1017
+ static fromEnv(harness, runtime = {}) {
791
1018
  const resolved = (0, config_js_1.resolveConfig)();
792
1019
  const projectUrl = resolved.projectUrl;
793
1020
  const logFile = resolveDebugLogPath(harness);
794
- const traceFile = resolveTraceFilePath(harness);
795
1021
  if (!projectUrl) {
796
1022
  // Use a placeholder URL so the client can be constructed.
797
1023
  // API calls will fail with network_error which is handled as fail-open.
798
1024
  const client = new OryAgentClient({
799
1025
  projectUrl: "http://localhost:0",
800
- apiKey: resolved.apiKey,
1026
+ agentSecurityUrl: resolved.agentSecurityUrl ?? "http://localhost:0",
801
1027
  harness,
802
1028
  logFile,
803
- traceFile,
804
1029
  userSubjectNamespace: resolved.userSubjectNamespace,
805
1030
  sessionCacheTtlMs: process.env.ORY_SESSION_CACHE_TTL_MS
806
1031
  ? parseInt(process.env.ORY_SESSION_CACHE_TTL_MS, 10)
807
1032
  : undefined,
1033
+ eventReporter: undefined,
1034
+ ...runtime,
808
1035
  });
809
1036
  client.logger.warn("config.missing_project_url", {
810
1037
  message: "ORY_PROJECT_URL is not set and no config file found. " +
@@ -812,26 +1039,29 @@ class OryAgentClient {
812
1039
  "Set ORY_PROJECT_URL or run the plugin's configure command.",
813
1040
  });
814
1041
  rehydrateUserPrincipal(client);
815
- attachOtlpExporterFromEnv(client, harness);
816
1042
  return client;
817
1043
  }
818
1044
  const client = new OryAgentClient({
819
1045
  projectUrl,
820
- apiKey: resolved.apiKey,
1046
+ agentSecurityUrl: resolved.agentSecurityUrl,
821
1047
  harness,
822
1048
  logFile,
823
- traceFile,
824
1049
  userSubjectNamespace: resolved.userSubjectNamespace,
825
1050
  sessionCacheTtlMs: process.env.ORY_SESSION_CACHE_TTL_MS
826
1051
  ? parseInt(process.env.ORY_SESSION_CACHE_TTL_MS, 10)
827
1052
  : undefined,
1053
+ eventReporter: resolved.security.connected
1054
+ ? {
1055
+ outboxDir: path.join((0, config_js_1.getHarnessDataDir)(harness), "events-outbox", (0, event_reporter_js_1.eventOutboxProjectScope)(projectUrl)),
1056
+ }
1057
+ : undefined,
1058
+ ...runtime,
828
1059
  });
829
1060
  client.logger.debug("config.resolved", {
830
1061
  projectUrlSource: resolved.projectUrlSource,
831
- apiKeySource: resolved.apiKeySource,
1062
+ agentSecurityUrlSource: resolved.agentSecurityUrlSource,
832
1063
  });
833
1064
  rehydrateUserPrincipal(client);
834
- attachOtlpExporterFromEnv(client, harness);
835
1065
  return client;
836
1066
  }
837
1067
  }
@@ -855,13 +1085,39 @@ exports.OryAgentClient = OryAgentClient;
855
1085
  * with fresh values when it runs at `SessionStart`.
856
1086
  *
857
1087
  * The subject is attached whenever it is known — even for an expired token,
858
- * since the subject is what permission checks resolve against and Keto
859
- * checks authenticate with the admin API key, not this token. The access
860
- * token itself is only carried forward while still valid, so nothing
861
- * authenticates with a stale bearer.
1088
+ * since the subject is what permission checks resolve against. The access
1089
+ * token itself is only carried forward while still valid, so delegation
1090
+ * authentication never uses a stale bearer. Permission reads independently
1091
+ * resolve the required agent credential.
1092
+ *
1093
+ * Two sources feed it, in the same order the login gate uses: a credential
1094
+ * supplied through the environment (`ORY_USER_OAUTH2_TOKEN`) first, then the
1095
+ * persisted tokens. The env branch exists because the gate's `env_token` short-circuit
1096
+ * attaches that credential *in memory only* and runs only at `SessionStart`;
1097
+ * reading it here is what makes a pre-supplied token apply on every lifecycle
1098
+ * event rather than just the first one.
862
1099
  */
863
1100
  function rehydrateUserPrincipal(client) {
1101
+ // An env-supplied credential wins, matching the login gate's own ordering
1102
+ // (its `env_token` short-circuit is checked before persisted tokens). That
1103
+ // gate only runs on `SessionStart`, and it attaches the credential in memory
1104
+ // without persisting it — so without this branch `ORY_USER_OAUTH2_TOKEN`
1105
+ // authenticated the session and then silently stopped
1106
+ // applying on every subsequent event. On a subprocess harness that left the
1107
+ // tool-call client with no user credential for delegation or DCR bootstrap.
1108
+ const env = (0, auth_store_js_1.readEnvUserCredential)();
864
1109
  const tokens = (0, auth_store_js_1.loadTokens)();
1110
+ if (env.token) {
1111
+ client.setUserPrincipal({
1112
+ // The operator-pinned subject wins; fall back to the persisted one so an
1113
+ // env token supplied alongside a completed login keeps its identity.
1114
+ ...(env.subject ?? tokens?.subject
1115
+ ? { subject: env.subject ?? tokens?.subject }
1116
+ : {}),
1117
+ token: env.token,
1118
+ });
1119
+ return;
1120
+ }
865
1121
  if (!tokens?.subject)
866
1122
  return;
867
1123
  client.setUserPrincipal({
@@ -869,17 +1125,15 @@ function rehydrateUserPrincipal(client) {
869
1125
  token: (0, auth_store_js_1.isExpired)(tokens) ? undefined : tokens.accessToken,
870
1126
  });
871
1127
  }
872
- /** Default debug-log path under the shared per-harness data dir. */
1128
+ /** Default unified activity/debug log path under the shared per-harness data dir. */
873
1129
  exports.DEFAULT_DEBUG_LOG_FILENAME = "ory-agent-debug.log";
874
- /** Default trace (NDJSON) path under the shared per-harness data dir. */
875
- exports.DEFAULT_TRACE_FILENAME = "ory-agent-trace.ndjson";
876
1130
  /**
877
1131
  * Resolve a file path from an env var with a sensible default under the
878
1132
  * per-harness data dir. Semantics:
879
1133
  * - unset → the default path (so post-install runs are observable
880
1134
  * without hand-setting env vars),
881
1135
  * - set to a path → that path (operator / dev-launcher override),
882
- * - set but empty → `undefined` (explicit opt-out, e.g. `ORY_AGENT_TRACE_FILE=`).
1136
+ * - set but empty → `undefined` (explicit opt-out, e.g. `ORY_AGENT_LOG_FILE=`).
883
1137
  */
884
1138
  function resolveFilePath(envValue, defaultPath) {
885
1139
  if (envValue === undefined)
@@ -888,40 +1142,12 @@ function resolveFilePath(envValue, defaultPath) {
888
1142
  return trimmed.length > 0 ? trimmed : undefined;
889
1143
  }
890
1144
  /**
891
- * Debug log path. Only written when `ORY_AGENT_DEBUG=true` (the logger gates
892
- * on that); defaulting the path just means an enabled debug session lands in a
893
- * file automatically — important for subprocess-hook harnesses whose stderr
894
- * the harness may swallow.
1145
+ * Unified activity/debug log path. Activity is always appended; verbose
1146
+ * debug/info/warn/error entries are appended only when `ORY_AGENT_DEBUG=true`.
895
1147
  */
896
1148
  function resolveDebugLogPath(harness) {
897
1149
  return resolveFilePath(process.env.ORY_AGENT_LOG_FILE, path.join((0, config_js_1.getHarnessDataDir)(harness), exports.DEFAULT_DEBUG_LOG_FILENAME));
898
1150
  }
899
- /** Trace (NDJSON) path. Written on every recorded span when set. */
900
- function resolveTraceFilePath(harness) {
901
- return resolveFilePath(process.env.ORY_AGENT_TRACE_FILE, path.join((0, config_js_1.getHarnessDataDir)(harness), exports.DEFAULT_TRACE_FILENAME));
902
- }
903
- function attachOtlpExporterFromEnv(client, harness) {
904
- const meta = client.tracer.processMetadata;
905
- const exporter = (0, otlp_js_1.otlpExporterFromEnv)({
906
- harness,
907
- hostname: meta.hostname,
908
- user: meta.user,
909
- pluginVersion: meta.pluginVersion,
910
- cwd: meta.cwd,
911
- gitBranch: meta.gitBranch,
912
- gitCommit: meta.gitCommit,
913
- onError: (err) => client.logger.warn("otel.export.failed", {
914
- message: err instanceof Error ? err.message : String(err),
915
- }),
916
- });
917
- if (!exporter)
918
- return;
919
- client.tracer.setExporter(exporter);
920
- client.logger.info("otel.export.enabled", {
921
- endpoint: exporter.endpoint,
922
- serviceName: exporter.resource.attributes["service.name"],
923
- });
924
- }
925
1151
  /**
926
1152
  * Pull the Ory backend request ID from a response headers bag (axios returns
927
1153
  * lowercase header names). Returns undefined when not present.
@@ -940,3 +1166,58 @@ function extractOryRequestId(headers) {
940
1166
  return value[0];
941
1167
  return undefined;
942
1168
  }
1169
+ /**
1170
+ * The TLS/certificate error code on `err`, walking `cause` because HTTP clients
1171
+ * wrap the underlying socket error.
1172
+ *
1173
+ * Matched by *shape*, not an exhaustive list. OpenSSL codes are SCREAMING_SNAKE
1174
+ * and name the artifact that failed (`CERT_HAS_EXPIRED`,
1175
+ * `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `SELF_SIGNED_CERT_IN_CHAIN`,
1176
+ * `DEPTH_ZERO_SELF_SIGNED_CERT`, …), and Node adds `ERR_TLS_*`. A missed code
1177
+ * would fall through to `unknown`, which fails **open** — the bug this exists to
1178
+ * prevent — so the predicate is deliberately broad. `ERR_TLS_CERT_ALTNAME_INVALID`
1179
+ * (hostname mismatch) is included: it is a verification failure, not a transport
1180
+ * one.
1181
+ */
1182
+ function findTlsErrorCode(err, depth = 0) {
1183
+ if (!err || typeof err !== "object" || depth > 4)
1184
+ return undefined;
1185
+ const code = err.code;
1186
+ if (typeof code === "string") {
1187
+ const isOpenSslCertCode = /^[A-Z0-9_]+$/.test(code) && /(CERT|SSL|_TLS|SIGNATURE)/.test(code);
1188
+ const isNodeTlsCode = code.startsWith("ERR_TLS_") || code === "ERR_SSL_PROTOCOL_ERROR";
1189
+ // `ERR_TLS_*` and OpenSSL cert codes only; plain socket codes (ECONNRESET…)
1190
+ // deliberately do not match — those are transport failures and fail open.
1191
+ if (isNodeTlsCode || (isOpenSslCertCode && !code.startsWith("E")))
1192
+ return code;
1193
+ if (isOpenSslCertCode && /(CERT|SIGNATURE)/.test(code))
1194
+ return code;
1195
+ }
1196
+ return (findTlsErrorCode(err.cause, depth + 1) ??
1197
+ findTlsErrorCode(err.response?.cause, depth + 1));
1198
+ }
1199
+ /** Socket-level codes meaning the request never reached a server. */
1200
+ const SOCKET_ERROR_CODES = new Set([
1201
+ "ECONNREFUSED",
1202
+ "ECONNRESET",
1203
+ "ENOTFOUND",
1204
+ "ETIMEDOUT",
1205
+ "EAI_AGAIN",
1206
+ "EHOSTUNREACH",
1207
+ "ENETUNREACH",
1208
+ "EPIPE",
1209
+ "ECONNABORTED",
1210
+ ]);
1211
+ /**
1212
+ * The socket-level error code on `err`, walking `cause` the same way
1213
+ * {@link findTlsErrorCode} does — an HTTP client wraps the underlying socket
1214
+ * error, so the code is rarely on the outermost object.
1215
+ */
1216
+ function findSocketErrorCode(err, depth = 0) {
1217
+ if (!err || typeof err !== "object" || depth > 4)
1218
+ return undefined;
1219
+ const code = err.code;
1220
+ if (typeof code === "string" && SOCKET_ERROR_CODES.has(code))
1221
+ return code;
1222
+ return findSocketErrorCode(err.cause, depth + 1);
1223
+ }