@atlasent/sdk 1.5.0 → 2.10.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.
package/dist/hono.js CHANGED
@@ -1,4 +1,25 @@
1
1
  // src/errors.ts
2
+ var StreamTimeoutError = class extends Error {
3
+ name = "StreamTimeoutError";
4
+ /** Timeout that was exceeded, in milliseconds. */
5
+ timeoutMs;
6
+ constructor(timeoutMs) {
7
+ super(`AtlaSent stream timed out after ${timeoutMs}ms with no event`);
8
+ this.timeoutMs = timeoutMs;
9
+ }
10
+ };
11
+ var StreamParseError = class extends Error {
12
+ name = "StreamParseError";
13
+ /** The raw data string that failed to parse. */
14
+ rawData;
15
+ constructor(rawData, cause) {
16
+ super(`AtlaSent stream received malformed JSON: ${rawData.slice(0, 200)}`);
17
+ this.rawData = rawData;
18
+ if (cause !== void 0) {
19
+ this.cause = cause;
20
+ }
21
+ }
22
+ };
2
23
  var AtlaSentError = class extends Error {
3
24
  // Subclasses override to their own literal (e.g. "AtlaSentDeniedError");
4
25
  // keep this assignable rather than pinned to a single literal.
@@ -22,6 +43,18 @@ var AtlaSentError = class extends Error {
22
43
  this.retryAfterMs = init.retryAfterMs;
23
44
  }
24
45
  };
46
+ var KNOWN_PERMIT_OUTCOMES = /* @__PURE__ */ new Set([
47
+ "permit_consumed",
48
+ "permit_expired",
49
+ "permit_revoked",
50
+ "permit_not_found"
51
+ ]);
52
+ function normalizePermitOutcome(raw) {
53
+ if (raw !== void 0 && KNOWN_PERMIT_OUTCOMES.has(raw)) {
54
+ return raw;
55
+ }
56
+ return void 0;
57
+ }
25
58
  var AtlaSentDeniedError = class extends AtlaSentError {
26
59
  name = "AtlaSentDeniedError";
27
60
  /** Policy decision — `"deny"` today; `"hold"` / `"escalate"` reserved. */
@@ -32,6 +65,12 @@ var AtlaSentDeniedError = class extends AtlaSentError {
32
65
  reason;
33
66
  /** Hash-chained audit-trail entry associated with the decision. */
34
67
  auditHash;
68
+ /**
69
+ * Discriminator for permit-side denial reasons. Populated only
70
+ * when the server reported `verified=false` from `/v1-verify-permit`;
71
+ * `undefined` for evaluate-time denials. See {@link PermitOutcome}.
72
+ */
73
+ outcome;
35
74
  constructor(init) {
36
75
  const msg = init.reason ? `AtlaSent ${init.decision}: ${init.reason}` : `AtlaSent ${init.decision}`;
37
76
  const errInit = { status: 200 };
@@ -41,92 +80,833 @@ var AtlaSentDeniedError = class extends AtlaSentError {
41
80
  this.evaluationId = init.evaluationId;
42
81
  this.reason = init.reason;
43
82
  this.auditHash = init.auditHash;
83
+ this.outcome = init.outcome;
84
+ }
85
+ // ── Outcome discriminators ───────────────────────────────────────
86
+ // Convenience predicates that mirror the operator runbook's matrix.
87
+ // Callers can compare `outcome` directly; these are sugar so the
88
+ // common cases are explicit at the call site.
89
+ /** `true` when the permit was explicitly revoked (D3 endpoint). */
90
+ get isRevoked() {
91
+ return this.outcome === "permit_revoked";
92
+ }
93
+ /** `true` when the permit's TTL passed before verification. */
94
+ get isExpired() {
95
+ return this.outcome === "permit_expired";
96
+ }
97
+ /**
98
+ * `true` when the permit was already consumed by a prior verify
99
+ * (v1 single-use replay protection).
100
+ */
101
+ get isConsumed() {
102
+ return this.outcome === "permit_consumed";
103
+ }
104
+ /**
105
+ * `true` when the permit id wasn't recognized server-side
106
+ * (typo, cross-tenant lookup, or pre-issuance race).
107
+ */
108
+ get isNotFound() {
109
+ return this.outcome === "permit_not_found";
44
110
  }
45
111
  };
46
112
 
113
+ // src/types.ts
114
+ var PRODUCTION_DEPLOY_ACTION = "production.deploy";
115
+ var DEPLOY_GATE_CODES = Object.freeze({
116
+ ALLOW: "ALLOW",
117
+ DENY_POLICY: "DENY_POLICY",
118
+ DENY_AUTHORITY: "DENY_AUTHORITY",
119
+ DENY_ENVIRONMENT: "DENY_ENVIRONMENT",
120
+ PERMIT_EXPIRED: "PERMIT_EXPIRED",
121
+ VERIFY_FAILED: "VERIFY_FAILED",
122
+ ESCALATE_REQUIRED: "ESCALATE_REQUIRED",
123
+ OVERRIDE_APPROVED: "OVERRIDE_APPROVED"
124
+ });
125
+
126
+ // src/compat.ts
127
+ function normalizeEvaluateRequest(input) {
128
+ if ("action" in input && !("action_type" in input)) {
129
+ console.warn(
130
+ "[atlasent] Deprecation: action/agent request shape is deprecated. Use action_type/actor_id instead. This compatibility shim will be removed in v3.0.0."
131
+ );
132
+ const legacy = input;
133
+ const normalized = {
134
+ action_type: legacy.action,
135
+ actor_id: legacy.agent
136
+ };
137
+ if (legacy.context !== void 0) normalized.context = legacy.context;
138
+ if (legacy.explain !== void 0) normalized.explain = legacy.explain;
139
+ return normalized;
140
+ }
141
+ return input;
142
+ }
143
+
144
+ // src/retry.ts
145
+ var DEFAULT_RETRY_POLICY = {
146
+ maxAttempts: 4,
147
+ baseDelayMs: 2e3,
148
+ maxDelayMs: 16e3
149
+ };
150
+ var RETRYABLE_CODES = /* @__PURE__ */ new Set([
151
+ "network",
152
+ "timeout",
153
+ "rate_limited",
154
+ "server_error",
155
+ "bad_response"
156
+ ]);
157
+ function isRetryable(err) {
158
+ if (!(err instanceof AtlaSentError)) return false;
159
+ if (err.code === void 0) return false;
160
+ return RETRYABLE_CODES.has(err.code);
161
+ }
162
+ function computeBackoffMs(attempt, policy = {}, err, random = Math.random) {
163
+ const merged = mergePolicy(policy);
164
+ const safeAttempt = Math.max(0, Math.floor(attempt));
165
+ const exp = Math.min(safeAttempt, 30);
166
+ const ceiling = Math.min(merged.maxDelayMs, merged.baseDelayMs * 2 ** exp);
167
+ const jittered = Math.floor(ceiling * clampUnit(random()));
168
+ const retryAfterMs = err instanceof AtlaSentError && typeof err.retryAfterMs === "number" ? Math.max(0, err.retryAfterMs) : 0;
169
+ return Math.max(retryAfterMs, jittered);
170
+ }
171
+ function hasAttemptsLeft(attempt, policy = {}) {
172
+ const merged = mergePolicy(policy);
173
+ return attempt + 1 < merged.maxAttempts;
174
+ }
175
+ function mergePolicy(policy) {
176
+ const maxAttempts = Math.max(
177
+ 1,
178
+ Math.floor(policy.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts)
179
+ );
180
+ const baseDelayMs = Math.max(
181
+ 0,
182
+ policy.baseDelayMs ?? DEFAULT_RETRY_POLICY.baseDelayMs
183
+ );
184
+ const maxDelayMs = Math.max(
185
+ baseDelayMs,
186
+ policy.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs
187
+ );
188
+ return { maxAttempts, baseDelayMs, maxDelayMs };
189
+ }
190
+ function clampUnit(n) {
191
+ if (!Number.isFinite(n)) return 0;
192
+ if (n < 0) return 0;
193
+ if (n >= 1) return 0.999999999;
194
+ return n;
195
+ }
196
+
47
197
  // src/client.ts
48
198
  var DEFAULT_BASE_URL = "https://api.atlasent.io";
49
199
  var DEFAULT_TIMEOUT_MS = 1e4;
50
- var SDK_VERSION = "0.1.0";
200
+ var SDK_VERSION = "2.2.0";
201
+ function _buildUserAgent() {
202
+ const isNode2 = typeof process !== "undefined" && typeof process?.versions?.node === "string";
203
+ return isNode2 ? `@atlasent/sdk/${SDK_VERSION} node/${process.version}` : `@atlasent/sdk/${SDK_VERSION} browser`;
204
+ }
205
+ var CONTEXT_PROPERTIES_SOFT_CAP = 64;
206
+ function _warnOversizeContext(context) {
207
+ if (context && Object.keys(context).length > CONTEXT_PROPERTIES_SOFT_CAP) {
208
+ console.warn(
209
+ `[atlasent] context has ${Object.keys(context).length} top-level keys (soft cap ${CONTEXT_PROPERTIES_SOFT_CAP}); the server may reject this. Pack richer payloads under a single top-level key.`
210
+ );
211
+ }
212
+ }
213
+ function _enforceTls(baseUrl) {
214
+ const nodeEnvValue = typeof process !== "undefined" && process.env ? process.env.ATLASENT_ALLOW_INSECURE_HTTP : void 0;
215
+ const allow = nodeEnvValue === "1" || globalThis.ATLASENT_ALLOW_INSECURE_HTTP === "1";
216
+ if (allow) return baseUrl;
217
+ let parsed;
218
+ try {
219
+ parsed = new URL(baseUrl);
220
+ } catch {
221
+ throw new AtlaSentError(`Invalid baseUrl: ${baseUrl}`, {
222
+ code: "bad_request"
223
+ });
224
+ }
225
+ if (parsed.protocol !== "https:") {
226
+ throw new AtlaSentError(
227
+ `AtlaSent baseUrl must use https:// (got ${parsed.protocol}). For local development, set ATLASENT_ALLOW_INSECURE_HTTP=1.`,
228
+ { code: "bad_request" }
229
+ );
230
+ }
231
+ return baseUrl;
232
+ }
233
+ var API_KEY_PATTERN = /^ask_(?:live|test)_[A-Za-z0-9_-]+$/;
234
+ function _validateApiKey(apiKey) {
235
+ if (typeof apiKey !== "string" || apiKey.length === 0) {
236
+ throw new AtlaSentError("apiKey is required", { code: "invalid_api_key" });
237
+ }
238
+ if (!API_KEY_PATTERN.test(apiKey)) {
239
+ const head = apiKey.slice(0, 8);
240
+ throw new AtlaSentError(
241
+ `AtlaSent apiKey does not match expected shape \`ask_(live|test)_<entropy>\` (got prefix=${JSON.stringify(head)}). Check for whitespace, quotes, or trailing characters.`,
242
+ { code: "invalid_api_key" }
243
+ );
244
+ }
245
+ return apiKey;
246
+ }
247
+ var isNode = typeof process !== "undefined" && typeof process.versions?.node === "string";
248
+ var NODE_VERSION = isNode ? process.version : null;
249
+ function deployGateEvidence(input) {
250
+ const evidence = {};
251
+ if (input.permitId) evidence.permitId = input.permitId;
252
+ if (input.permitHash) evidence.permitHash = input.permitHash;
253
+ if (input.auditHash) evidence.auditHash = input.auditHash;
254
+ if (input.verifiedAt) evidence.verifiedAt = input.verifiedAt;
255
+ return evidence;
256
+ }
51
257
  var AtlaSentClient = class {
52
258
  apiKey;
53
259
  baseUrl;
54
260
  timeoutMs;
55
261
  fetchImpl;
262
+ userAgent;
263
+ retryPolicy;
56
264
  constructor(options) {
57
265
  if (!options.apiKey || typeof options.apiKey !== "string") {
58
266
  throw new AtlaSentError("apiKey is required", {
59
267
  code: "invalid_api_key"
60
268
  });
61
269
  }
62
- this.apiKey = options.apiKey;
63
- this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
270
+ if (typeof AbortSignal.timeout !== "function") {
271
+ throw new AtlaSentError(
272
+ "@atlasent/sdk requires AbortSignal.timeout, which is not available in this runtime. Minimum supported browsers: Chrome 103+, Firefox 100+, Safari 16+. Upgrade your browser or add an AbortSignal.timeout polyfill.",
273
+ { code: "network" }
274
+ );
275
+ }
276
+ this.apiKey = _validateApiKey(options.apiKey);
277
+ this.baseUrl = _enforceTls(options.baseUrl ?? DEFAULT_BASE_URL).replace(
278
+ /\/+$/,
279
+ ""
280
+ );
64
281
  this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
65
282
  this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
283
+ this.userAgent = _buildUserAgent();
284
+ this.retryPolicy = mergePolicy(options.retryPolicy ?? {});
66
285
  }
67
286
  /**
68
287
  * Ask the policy engine whether an agent action is permitted.
69
288
  *
70
- * A "DENY" is **not** thrown it is returned in
289
+ * Accepts either the current v2.0 shape (`action_type` / `actor_id`)
290
+ * or the legacy v1.x shape (`action` / `agent`). Legacy callers
291
+ * receive a deprecation warning via `console.warn`; the shim is
292
+ * handled by {@link normalizeEvaluateRequest} and will be removed
293
+ * in v3.0.0.
294
+ *
295
+ * A "deny" is **not** thrown — it is returned in
71
296
  * `response.decision`. Network errors, invalid API key, rate
72
297
  * limits, timeouts, and malformed responses throw
73
298
  * {@link AtlaSentError}.
74
299
  */
75
300
  async evaluate(input) {
301
+ _warnOversizeContext(input.context);
302
+ const normalized = normalizeEvaluateRequest(
303
+ input
304
+ );
76
305
  const body = {
77
- action: input.action,
78
- agent: input.agent,
79
- context: input.context ?? {},
80
- api_key: this.apiKey
306
+ action_type: normalized.action_type,
307
+ actor_id: normalized.actor_id,
308
+ context: normalized.context ?? {}
81
309
  };
82
- const { body: wire, rateLimit } = await this.post("/v1-evaluate", body);
83
- if (typeof wire.permitted !== "boolean" || typeof wire.decision_id !== "string") {
310
+ if (normalized.explain !== void 0) body.explain = normalized.explain;
311
+ const { body: wire, rateLimit } = await this.post(
312
+ "/v1-evaluate",
313
+ body
314
+ );
315
+ let decision = typeof wire.decision === "string" ? wire.decision.toLowerCase() : wire.decision;
316
+ if (decision === void 0 && typeof wire.permitted === "boolean") {
317
+ decision = wire.permitted ? "allow" : "deny";
318
+ }
319
+ const permitToken = wire.permit_token ?? wire.decision_id;
320
+ if (decision !== "allow" && decision !== "deny" && decision !== "hold" && decision !== "escalate") {
321
+ throw new AtlaSentError(
322
+ "Malformed response from /v1-evaluate: missing `decision` (or legacy `permitted`)",
323
+ { code: "bad_response" }
324
+ );
325
+ }
326
+ if (decision === "allow" && (typeof permitToken !== "string" || permitToken.length === 0)) {
84
327
  throw new AtlaSentError(
85
- "Malformed response from /v1-evaluate: missing `permitted` or `decision_id`",
328
+ "Malformed response from /v1-evaluate: decision='allow' but no `permit_token` (or legacy `decision_id`)",
86
329
  { code: "bad_response" }
87
330
  );
88
331
  }
332
+ const reason = wire.denial?.reason ?? wire.reason ?? "";
333
+ const permitId = permitToken ?? "";
89
334
  return {
90
- decision: wire.permitted ? "ALLOW" : "DENY",
91
- permitId: wire.decision_id,
92
- reason: wire.reason ?? "",
335
+ decision,
336
+ decision_canonical: decision,
337
+ evaluationId: permitId,
338
+ permitId,
339
+ // /v1-evaluate does not return a control-plane-shaped Permit body;
340
+ // callers needing the full record fetch GET /v1/permits/:id.
341
+ permit: null,
342
+ permitToken: decision === "allow" ? permitToken ?? null : null,
343
+ reasons: reason ? [reason] : [],
344
+ reason,
93
345
  auditHash: wire.audit_hash ?? "",
94
346
  timestamp: wire.timestamp ?? "",
347
+ rateLimit,
348
+ ...wire.risk_envelope && {
349
+ riskEnvelope: {
350
+ weightedScore: wire.risk_envelope.weighted_score,
351
+ engineDecision: wire.risk_envelope.engine_decision,
352
+ envelopeDecision: wire.risk_envelope.envelope_decision,
353
+ promoted: wire.risk_envelope.promoted,
354
+ hardBlocks: wire.risk_envelope.hard_blocks ?? [],
355
+ ...wire.risk_envelope.factors && { factors: wire.risk_envelope.factors }
356
+ }
357
+ }
358
+ };
359
+ }
360
+ /**
361
+ * Batch evaluate — send up to 100 decisions in a single round-trip.
362
+ *
363
+ * Wraps `POST /v1-evaluate-batch`. The server evaluates each item
364
+ * against the active policy bundle and returns results in the same
365
+ * order as the input. One rate-limit token is consumed for the
366
+ * whole batch, and one audit-chain entry lists every included
367
+ * decision id.
368
+ *
369
+ * A per-item policy `deny` is **not** thrown — it appears as
370
+ * `item.decision === "deny"` in the returned items. A whole-batch
371
+ * network error, 4xx, or 5xx throws {@link AtlaSentError}.
372
+ *
373
+ * Requires the `v2_batch` tenant feature flag to be enabled on the
374
+ * org (returns 404 when off). Requires scope `evaluate:write`.
375
+ *
376
+ * @param requests - 1–100 evaluate items.
377
+ * @param batchId - Optional caller-supplied UUID for idempotency.
378
+ * A retried call with the same `batchId` and identical items
379
+ * returns the cached response within 24 h (`replayed: true`).
380
+ */
381
+ async evaluateBatch(requests, batchId) {
382
+ if (!Array.isArray(requests) || requests.length === 0) {
383
+ throw new AtlaSentError(
384
+ "evaluateBatch: requests must be a non-empty array",
385
+ { code: "bad_request" }
386
+ );
387
+ }
388
+ if (requests.length > 100) {
389
+ throw new AtlaSentError(
390
+ `evaluateBatch: requests.length ${requests.length} exceeds the 100-item cap`,
391
+ { code: "bad_request" }
392
+ );
393
+ }
394
+ const wireItems = requests.map((r) => ({
395
+ action_type: r.action,
396
+ actor_id: r.agent,
397
+ context: r.context ?? {}
398
+ }));
399
+ const wireBody = { items: wireItems };
400
+ if (batchId) wireBody.batch_id = batchId;
401
+ const { body: wire, rateLimit } = await this.post(
402
+ "/v1-evaluate-batch",
403
+ wireBody
404
+ );
405
+ const items = (wire.items ?? []).map(
406
+ (item) => {
407
+ const rawDecision = typeof item.decision === "string" ? item.decision.toLowerCase() : void 0;
408
+ const decision = rawDecision === "allow" || rawDecision === "deny" || rawDecision === "hold" || rawDecision === "escalate" ? rawDecision : void 0;
409
+ return {
410
+ index: item.index,
411
+ ...decision !== void 0 ? { decision } : {},
412
+ ...item.decision_id ? { decisionId: item.decision_id } : {},
413
+ ...item.permit_token != null ? { permitToken: item.permit_token } : {},
414
+ ...item.reason != null ? { reason: item.reason } : {},
415
+ ...item.audit_entry_hash ? { auditHash: item.audit_entry_hash } : {},
416
+ ...item.timestamp ? { timestamp: item.timestamp } : {},
417
+ ...item.error ? { error: item.error } : {},
418
+ ...item.message ? { message: item.message } : {}
419
+ };
420
+ }
421
+ );
422
+ return {
423
+ batchId: wire.batch_id,
424
+ items,
425
+ partial: wire.partial ?? false,
426
+ ...wire.replayed ? { replayed: wire.replayed } : {},
95
427
  rateLimit
96
428
  };
97
429
  }
430
+ /**
431
+ * Subscribe to a live stream of decisions for this org.
432
+ *
433
+ * Wraps `GET /v1-decisions-stream`. The server emits one SSE frame
434
+ * per audit event and sends a heartbeat every 15 s. The session
435
+ * auto-closes after `maxSeconds` (default 30 min); reconnect with
436
+ * the last received `event.id` to resume without replaying history.
437
+ *
438
+ * ```ts
439
+ * const controller = new AbortController();
440
+ * for await (const event of client.subscribeDecisions({ signal: controller.signal })) {
441
+ * if (event.type === "heartbeat") continue;
442
+ * console.log(event.type, event.decision, event.actorId);
443
+ * if (event.type === "session_end") break; // reconnect
444
+ * }
445
+ * ```
446
+ *
447
+ * Requires scope `audit:read`. Requires the `v2_decisions_stream`
448
+ * tenant feature flag (returns 404 when off).
449
+ */
450
+ async *subscribeDecisions(opts = {}) {
451
+ const url = new URL(`${this.baseUrl}/v1-decisions-stream`);
452
+ if (opts.types?.length) url.searchParams.set("types", opts.types.join(","));
453
+ if (opts.actorId) url.searchParams.set("actor_id", opts.actorId);
454
+ if (opts.maxSeconds !== void 0) url.searchParams.set("max_seconds", String(opts.maxSeconds));
455
+ const headers = {
456
+ Accept: "text/event-stream",
457
+ Authorization: `Bearer ${this.apiKey}`,
458
+ "User-Agent": this.userAgent,
459
+ // ADR-025: declare the wire-protocol version we were built
460
+ // against. Runtime serves this version's response shape; older
461
+ // versions outside the compatibility window get 426.
462
+ "X-AtlaSent-Protocol-Version": "1"
463
+ };
464
+ if (opts.lastEventId) headers["Last-Event-ID"] = opts.lastEventId;
465
+ let response;
466
+ try {
467
+ response = await this.fetchImpl(url.toString(), {
468
+ method: "GET",
469
+ headers,
470
+ ...opts.signal ? { signal: opts.signal } : {}
471
+ });
472
+ } catch (err) {
473
+ if (err instanceof Error && err.name === "AbortError") return;
474
+ throw new AtlaSentError(
475
+ `Failed to connect to decisions stream: ${err instanceof Error ? err.message : String(err)}`,
476
+ { code: "network" }
477
+ );
478
+ }
479
+ if (!response.ok) {
480
+ const code = response.status === 401 ? "invalid_api_key" : "server_error";
481
+ throw new AtlaSentError(
482
+ `Decisions stream returned ${response.status}`,
483
+ { code, status: response.status }
484
+ );
485
+ }
486
+ if (!response.body) {
487
+ throw new AtlaSentError("Decisions stream response has no body", { code: "bad_response" });
488
+ }
489
+ const reader = response.body.getReader();
490
+ const decoder = new TextDecoder("utf-8");
491
+ let buf = "";
492
+ try {
493
+ while (true) {
494
+ let chunk;
495
+ try {
496
+ chunk = await reader.read();
497
+ } catch (err) {
498
+ if (err instanceof Error && err.name === "AbortError") return;
499
+ throw new AtlaSentError(
500
+ `Decisions stream read error: ${err instanceof Error ? err.message : String(err)}`,
501
+ { code: "network" }
502
+ );
503
+ }
504
+ if (chunk.done) break;
505
+ buf += decoder.decode(chunk.value, { stream: true });
506
+ const rawBlocks = buf.split("\n\n");
507
+ buf = rawBlocks.pop() ?? "";
508
+ for (const block of rawBlocks) {
509
+ if (!block.trim()) continue;
510
+ if (block.trimStart().startsWith(":")) {
511
+ yield { type: "heartbeat" };
512
+ continue;
513
+ }
514
+ let id;
515
+ let eventType = "audit_event";
516
+ let dataLine = "";
517
+ for (const line of block.split("\n")) {
518
+ if (line.startsWith("id:")) id = line.slice(3).trim();
519
+ else if (line.startsWith("event:")) eventType = line.slice(6).trim();
520
+ else if (line.startsWith("data:")) dataLine = line.slice(5).trim();
521
+ }
522
+ if (!dataLine) continue;
523
+ let parsed;
524
+ try {
525
+ parsed = JSON.parse(dataLine);
526
+ } catch {
527
+ continue;
528
+ }
529
+ if (eventType === "session_end") {
530
+ yield { ...id !== void 0 ? { id } : {}, type: "session_end", payload: parsed };
531
+ return;
532
+ }
533
+ const decision = typeof parsed.decision === "string" ? parsed.decision.toLowerCase() : void 0;
534
+ yield {
535
+ ...id !== void 0 ? { id } : {},
536
+ type: eventType,
537
+ ...decision ? { decision } : {},
538
+ ...typeof parsed.actor_id === "string" ? { actorId: parsed.actor_id } : {},
539
+ ...typeof parsed.resource_type === "string" ? { resourceType: parsed.resource_type } : {},
540
+ ...typeof parsed.resource_id === "string" ? { resourceId: parsed.resource_id } : {},
541
+ ...parsed.payload && typeof parsed.payload === "object" ? { payload: parsed.payload } : {},
542
+ ...typeof parsed.hash === "string" ? { hash: parsed.hash } : {},
543
+ ...typeof parsed.previous_hash === "string" ? { previousHash: parsed.previous_hash } : {},
544
+ ...typeof parsed.occurred_at === "string" ? { occurredAt: parsed.occurred_at } : {}
545
+ };
546
+ }
547
+ }
548
+ } finally {
549
+ reader.releaseLock();
550
+ }
551
+ }
552
+ /**
553
+ * Pre-flight evaluation that always returns the constraint trace.
554
+ *
555
+ * Wraps `POST /v1-evaluate?include=constraint_trace`. Use this from
556
+ * a workflow's submission step to surface trivial defects (missing
557
+ * fields, wrong roles, mis-set context) BEFORE pushing the request
558
+ * onto an approval queue — only requests that would actually pass
559
+ * make it through to a human reviewer.
560
+ *
561
+ * Returns an {@link EvaluatePreflightResponse} carrying the regular
562
+ * {@link EvaluateResponse} plus the {@link ConstraintTrace}. Unlike
563
+ * {@link evaluate}, this method does NOT mark a non-allow as a
564
+ * thrown condition — the whole point is to inspect both the outcome
565
+ * AND the per-policy trace, so the caller branches on
566
+ * `result.evaluation.decision` and reads `result.constraintTrace`
567
+ * to render the failing stages.
568
+ *
569
+ * The constraint-trace shape mirrors `ConstraintTraceResponse` in
570
+ * atlasent-api (`packages/types/src/index.ts`). On older
571
+ * atlasent-api deployments that omit the trace, `constraintTrace`
572
+ * is `null` rather than throwing — forward-compatible degradation.
573
+ *
574
+ * Performance: one extra round-trip on submission. Latency is
575
+ * comparable to {@link evaluate}; the response body is fuller
576
+ * (includes the per-stage trace) so the wire payload is larger.
577
+ * If the caller does not need the trace, prefer {@link evaluate}.
578
+ */
579
+ async evaluatePreflight(input) {
580
+ _warnOversizeContext(input.context);
581
+ const body = {
582
+ action_type: input.action,
583
+ actor_id: input.agent,
584
+ context: input.context ?? {}
585
+ };
586
+ const query = new URLSearchParams({ include: "constraint_trace" });
587
+ const { body: wire, rateLimit } = await this.post(
588
+ "/v1-evaluate",
589
+ body,
590
+ query
591
+ );
592
+ let decision = typeof wire.decision === "string" ? wire.decision.toLowerCase() : wire.decision;
593
+ if (decision === void 0 && typeof wire.permitted === "boolean") {
594
+ decision = wire.permitted ? "allow" : "deny";
595
+ }
596
+ if (decision !== "allow" && decision !== "deny" && decision !== "hold" && decision !== "escalate") {
597
+ throw new AtlaSentError(
598
+ "Malformed response from /v1-evaluate: missing `decision` (or legacy `permitted`)",
599
+ { code: "bad_response" }
600
+ );
601
+ }
602
+ const permitToken = wire.permit_token ?? wire.decision_id;
603
+ const reason = wire.denial?.reason ?? wire.reason ?? "";
604
+ const permitId = permitToken ?? "";
605
+ const evaluation = {
606
+ decision,
607
+ decision_canonical: decision,
608
+ evaluationId: permitId,
609
+ permitId,
610
+ // /v1-evaluate does not return a control-plane-shaped Permit body;
611
+ // callers needing the full record fetch GET /v1/permits/:id.
612
+ permit: null,
613
+ permitToken: decision === "allow" ? permitToken ?? null : null,
614
+ reasons: reason ? [reason] : [],
615
+ reason,
616
+ auditHash: wire.audit_hash ?? "",
617
+ timestamp: wire.timestamp ?? "",
618
+ rateLimit,
619
+ ...wire.risk_envelope && {
620
+ riskEnvelope: {
621
+ weightedScore: wire.risk_envelope.weighted_score,
622
+ engineDecision: wire.risk_envelope.engine_decision,
623
+ envelopeDecision: wire.risk_envelope.envelope_decision,
624
+ promoted: wire.risk_envelope.promoted,
625
+ hardBlocks: wire.risk_envelope.hard_blocks ?? [],
626
+ ...wire.risk_envelope.factors && { factors: wire.risk_envelope.factors }
627
+ }
628
+ }
629
+ };
630
+ let constraintTrace = null;
631
+ if (wire.constraint_trace !== void 0 && wire.constraint_trace !== null && typeof wire.constraint_trace === "object") {
632
+ constraintTrace = wire.constraint_trace;
633
+ }
634
+ return { evaluation, constraintTrace };
635
+ }
98
636
  /**
99
637
  * Verify that a previously issued permit is still valid.
100
638
  *
639
+ * @deprecated Use {@link verifyPermitById} — the canonical REST
640
+ * surface (`POST /v1/permits/{id}/verify`) returns the unified
641
+ * verification envelope plus the full {@link PermitRecord}, instead
642
+ * of the legacy `{verified, outcome, permitHash}` shape this method
643
+ * emits. Will be removed in `@atlasent/sdk@3`.
644
+ *
101
645
  * A `verified: false` response is **not** thrown — inspect the
102
646
  * returned object. Only transport / server errors throw.
103
647
  */
104
648
  async verifyPermit(input) {
649
+ _warnOversizeContext(input.context);
105
650
  const body = {
106
- decision_id: input.permitId,
107
- action: input.action ?? "",
108
- agent: input.agent ?? "",
109
- context: input.context ?? {},
110
- api_key: this.apiKey
651
+ permit_token: input.permitId,
652
+ action_type: input.action ?? "",
653
+ actor_id: input.agent ?? ""
111
654
  };
655
+ if (input.environment !== void 0) {
656
+ body.environment = input.environment;
657
+ }
658
+ if (input.execution_hash !== void 0) {
659
+ body.execution_hash = input.execution_hash;
660
+ }
112
661
  const { body: wire, rateLimit } = await this.post(
113
662
  "/v1-verify-permit",
114
663
  body
115
664
  );
116
- if (typeof wire.verified !== "boolean") {
665
+ const valid = typeof wire.valid === "boolean" ? wire.valid : wire.verified;
666
+ if (typeof valid !== "boolean") {
117
667
  throw new AtlaSentError(
118
- "Malformed response from /v1-verify-permit: missing `verified`",
668
+ "Malformed response from /v1-verify-permit: missing `valid` (or legacy `verified`)",
119
669
  { code: "bad_response" }
120
670
  );
121
671
  }
122
672
  return {
123
- verified: wire.verified,
673
+ verified: valid,
124
674
  outcome: wire.outcome ?? "",
125
675
  permitHash: wire.permit_hash ?? "",
126
676
  timestamp: wire.timestamp ?? "",
677
+ expiresAt: wire.expires_at ?? null,
678
+ rateLimit
679
+ };
680
+ }
681
+ /**
682
+ * Run the canonical Deploy Gate V1 flow:
683
+ * evaluate `production.deploy`, verify the issued permit server-side,
684
+ * and return allow/block plus audit/evidence metadata.
685
+ *
686
+ * This helper never treats a signed/offline permit artifact as sufficient
687
+ * authorization. Execution is allowed only when `POST /v1-evaluate` returns
688
+ * `decision: "allow"` with a permit AND `POST /v1-verify-permit` returns
689
+ * `verified: true` / `valid: true`.
690
+ */
691
+ async deployGate(input = {}) {
692
+ const agent = input.agent ?? "ci-deploy-bot";
693
+ const action = input.action ?? PRODUCTION_DEPLOY_ACTION;
694
+ const context = input.context ?? {};
695
+ const evaluation = await this.evaluate({ agent, action, context });
696
+ if (evaluation.decision !== "allow") {
697
+ return {
698
+ allowed: false,
699
+ evaluation,
700
+ reason: evaluation.reason || `Deploy Gate blocked by decision=${evaluation.decision}`,
701
+ evidence: deployGateEvidence({
702
+ permitId: evaluation.permitId,
703
+ auditHash: evaluation.auditHash
704
+ })
705
+ };
706
+ }
707
+ const verification = await this.verifyPermit({
708
+ permitId: evaluation.permitId,
709
+ agent,
710
+ action,
711
+ context
712
+ });
713
+ if (!verification.verified) {
714
+ return {
715
+ allowed: false,
716
+ evaluation,
717
+ verification,
718
+ reason: verification.outcome ? `Deploy Gate blocked by permit verification outcome=${verification.outcome}` : "Deploy Gate blocked because permit verification failed",
719
+ evidence: deployGateEvidence({
720
+ permitId: evaluation.permitId,
721
+ permitHash: verification.permitHash,
722
+ auditHash: evaluation.auditHash,
723
+ verifiedAt: verification.timestamp
724
+ })
725
+ };
726
+ }
727
+ return {
728
+ allowed: true,
729
+ evaluation,
730
+ verification,
731
+ reason: evaluation.reason || "Deploy Gate permit verified",
732
+ evidence: deployGateEvidence({
733
+ permitId: evaluation.permitId,
734
+ permitHash: verification.permitHash,
735
+ auditHash: evaluation.auditHash,
736
+ verifiedAt: verification.timestamp
737
+ })
738
+ };
739
+ }
740
+ /**
741
+ * Revoke a previously-issued permit so it can no longer pass
742
+ * {@link verifyPermit}.
743
+ *
744
+ * @deprecated Use {@link revokePermitById} — the canonical REST
745
+ * surface (`POST /v1/permits/{id}/revoke`) returns the full updated
746
+ * {@link PermitRecord} with `revoked_at`/`revoked_by`/`revoke_reason`
747
+ * populated, instead of the legacy `{revoked, permitId}` envelope
748
+ * this method emits. Will be removed in `@atlasent/sdk@3`.
749
+ *
750
+ * Use this when an agent's action is cancelled, superseded, or
751
+ * determined to be unauthorized after the fact. The revocation is
752
+ * recorded in the audit log with the optional `reason`.
753
+ *
754
+ * Throws {@link AtlaSentError} on transport / auth failures.
755
+ */
756
+ async revokePermit(input) {
757
+ const body = {
758
+ decision_id: input.permitId,
759
+ reason: input.reason ?? "",
760
+ api_key: this.apiKey
761
+ };
762
+ const { body: wire, rateLimit } = await this.post("/v1-revoke-permit", body);
763
+ if (typeof wire.revoked !== "boolean" || typeof wire.decision_id !== "string") {
764
+ throw new AtlaSentError(
765
+ "Malformed response from /v1-revoke-permit: missing `revoked` or `decision_id`",
766
+ { code: "bad_response" }
767
+ );
768
+ }
769
+ return {
770
+ revoked: wire.revoked,
771
+ permitId: wire.decision_id,
772
+ revokedAt: wire.revoked_at,
773
+ auditHash: wire.audit_hash,
127
774
  rateLimit
128
775
  };
129
776
  }
777
+ /**
778
+ * Revoke a permit through the canonical REST surface
779
+ * (`POST /v1/permits/{permitId}/revoke`).
780
+ *
781
+ * Returns the full updated {@link PermitRecord} with `status === 'revoked'`
782
+ * and `revoked_at` / `revoked_by` / `revoke_reason` populated. After
783
+ * revocation, subsequent verify calls return `410 PERMIT_REVOKED`.
784
+ *
785
+ * Idempotent on `409 permit_revoked` for already-revoked permits;
786
+ * server returns the existing revoked row in that case.
787
+ *
788
+ * Throws {@link AtlaSentError} on `404` (permit not in calling org),
789
+ * `409` (already in a terminal state), `410` (expired before revoke),
790
+ * or `429` (rate limited).
791
+ */
792
+ async revokePermitById(permitId, input = {}) {
793
+ if (!permitId) {
794
+ throw new AtlaSentError("permitId is required", { code: "bad_request" });
795
+ }
796
+ const body = {};
797
+ if (input.reason !== void 0) body.reason = input.reason;
798
+ const { body: wire, rateLimit } = await this.post(
799
+ `/v1/permits/${encodeURIComponent(permitId)}/revoke`,
800
+ body
801
+ );
802
+ return { permit: wire, rateLimit };
803
+ }
804
+ /**
805
+ * Verify a permit through the canonical REST surface
806
+ * (`POST /v1/permits/{permitId}/verify`).
807
+ *
808
+ * Returns the unified verification envelope (`valid`,
809
+ * `verification_type: 'permit'`, `reason`, `verified_at`, `evidence`)
810
+ * plus the full {@link PermitRecord} fields preserved at the top
811
+ * level. The `valid` field is the contract — pin to it.
812
+ *
813
+ * A `valid: false` is **not** thrown when the server returns 200 with
814
+ * a denial reason (matches the verify-shape unification on the wire);
815
+ * it is thrown on 4xx (`404` not found, `410` expired/consumed).
816
+ */
817
+ async verifyPermitById(permitId) {
818
+ if (!permitId) {
819
+ throw new AtlaSentError("permitId is required", { code: "bad_request" });
820
+ }
821
+ const { body: wire, rateLimit } = await this.post(`/v1/permits/${encodeURIComponent(permitId)}/verify`, {});
822
+ const { valid, verification_type, reason, verified_at, evidence, ...row } = wire;
823
+ return {
824
+ valid,
825
+ verification_type,
826
+ reason,
827
+ verified_at,
828
+ evidence,
829
+ permit: row,
830
+ rateLimit
831
+ };
832
+ }
833
+ /**
834
+ * Get a single permit's full lifecycle state.
835
+ *
836
+ * Calls `GET /v1/permits/{permitId}` (the canonical REST surface).
837
+ * Returns `status`, all timestamps, `revoked_at` / `revoked_by` /
838
+ * `revoke_reason` (when applicable), and the bound `payload_hash`
839
+ * / `decision_id`.
840
+ *
841
+ * Operator-facing introspection — answers "what state is this permit
842
+ * in, and why?" without reading audit logs.
843
+ *
844
+ * Throws {@link AtlaSentError} on `404` (permit not in calling org)
845
+ * or `410` (expired before retrieval).
846
+ */
847
+ async getPermit(permitId) {
848
+ if (!permitId) {
849
+ throw new AtlaSentError("permitId is required", { code: "bad_request" });
850
+ }
851
+ const { body: wire, rateLimit } = await this.get(
852
+ `/v1/permits/${encodeURIComponent(permitId)}`
853
+ );
854
+ return { permit: wire, rateLimit };
855
+ }
856
+ /**
857
+ * Poll whether a permit is currently valid.
858
+ *
859
+ * Calls `GET /v1/permits/{permitId}/valid` — a lightweight read
860
+ * returning only the status snapshot optimised for guard heartbeat
861
+ * polling. Guards with `permitRevalidationIntervalMs` set race this
862
+ * against `tool.execute()` and throw {@link PermitRevoked} when
863
+ * `status === "revoked"` arrives.
864
+ *
865
+ * Throws {@link AtlaSentError} on transport / auth failures.
866
+ */
867
+ async checkPermitValid(permitId) {
868
+ if (!permitId) {
869
+ throw new AtlaSentError("permitId is required", { code: "bad_request" });
870
+ }
871
+ const { body } = await this.get(
872
+ `/v1/permits/${encodeURIComponent(permitId)}/valid`
873
+ );
874
+ return body;
875
+ }
876
+ /**
877
+ * List permits issued to the calling org, most-recently-issued first.
878
+ *
879
+ * Calls `GET /v1/permits` (the canonical REST surface). Cursor-paged.
880
+ * Filters narrow on server side; pagination uses the `created_at`
881
+ * timestamp opaquely (`nextCursor`).
882
+ *
883
+ * Designed for incident review, debugging, and compliance
884
+ * reconstruction.
885
+ */
886
+ async listPermits(input = {}) {
887
+ const params = new URLSearchParams();
888
+ if (input.status) params.set("status", input.status);
889
+ if (input.actorId) params.set("actor_id", input.actorId);
890
+ if (input.actionType) params.set("action_type", input.actionType);
891
+ if (input.from) params.set("from", input.from);
892
+ if (input.to) params.set("to", input.to);
893
+ if (input.limit !== void 0) params.set("limit", String(input.limit));
894
+ if (input.cursor) params.set("cursor", input.cursor);
895
+ const { body: wire, rateLimit } = await this.get("/v1/permits", params);
896
+ if (!Array.isArray(wire.permits)) {
897
+ throw new AtlaSentError(
898
+ "Malformed response from /v1/permits: missing `permits` array",
899
+ { code: "bad_response" }
900
+ );
901
+ }
902
+ const result = {
903
+ permits: wire.permits,
904
+ total: typeof wire.total === "number" ? wire.total : wire.permits.length,
905
+ rateLimit
906
+ };
907
+ if (wire.next_cursor !== void 0) result.nextCursor = wire.next_cursor;
908
+ return result;
909
+ }
130
910
  /**
131
911
  * Self-introspection: ask the server to describe the API key this
132
912
  * client was constructed with. Returns the key's ID, organization,
@@ -141,9 +921,7 @@ var AtlaSentClient = class {
141
921
  * taxonomy as {@link AtlaSentClient.evaluate}.
142
922
  */
143
923
  async keySelf() {
144
- const { body: wire, rateLimit } = await this.get(
145
- "/v1-api-key-self"
146
- );
924
+ const { body: wire, rateLimit } = await this.get("/v1-api-key-self");
147
925
  if (typeof wire.key_id !== "string" || typeof wire.organization_id !== "string") {
148
926
  throw new AtlaSentError(
149
927
  "Malformed response from /v1-api-key-self: missing `key_id` or `organization_id`",
@@ -218,8 +996,276 @@ var AtlaSentClient = class {
218
996
  }
219
997
  return { ...wire, rateLimit };
220
998
  }
221
- async post(path, body) {
222
- return this.request(path, "POST", body, void 0);
999
+ /**
1000
+ * Re-evaluate a recorded decision against its originally-pinned policy
1001
+ * bundle and engine version, and report whether the result agrees with
1002
+ * what was recorded.
1003
+ *
1004
+ * Wraps `POST /v1-decisions-replay/:id/replay`. **Side-effect-free** — no
1005
+ * audit chain row is written and no permit is issued (per ADR-016).
1006
+ * Useful for compliance review, regression testing of bundle changes,
1007
+ * and post-incident investigation.
1008
+ *
1009
+ * Outcomes encoded in the response:
1010
+ * - `variance: "NONE"` — replay agrees with the original decision.
1011
+ * - `variance: "DECISION_CHANGED"` — same envelope, same bundle, different
1012
+ * decision. Almost always indicates non-determinism in a rule
1013
+ * (e.g. wall-clock comparison) and warrants investigation.
1014
+ * - `variance: "ENVELOPE_DRIFT"` — the recorded request envelope no longer
1015
+ * hashes to the recorded value. The replay short-circuits without
1016
+ * running the engine; `replay_decision` is absent. Treat as evidence
1017
+ * of substrate tamper or a recorder bug.
1018
+ *
1019
+ * Server-side 409 responses (replay refused because the engine version
1020
+ * does not accept replay, or because no bundle was pinned) surface as
1021
+ * `AtlaSentError` with `code: "replay_not_eligible"` — callers should
1022
+ * treat them as expected for old / un-pinned decisions, not as bugs.
1023
+ *
1024
+ * Requires the `evaluate:write` API key scope.
1025
+ *
1026
+ * @param decisionId The UUID of the recorded decision to replay.
1027
+ * Matches `execution_evaluations.request_id`.
1028
+ *
1029
+ * @example
1030
+ * ```ts
1031
+ * const result = await client.replayDecision("dec_abc123");
1032
+ * if (result.variance === "DECISION_CHANGED") {
1033
+ * console.warn(
1034
+ * `Decision ${result.decision_id} changed on replay: ` +
1035
+ * `${result.original_decision} → ${result.replay_decision}`,
1036
+ * );
1037
+ * }
1038
+ * ```
1039
+ */
1040
+ async replayDecision(decisionId) {
1041
+ if (typeof decisionId !== "string" || decisionId.length === 0) {
1042
+ throw new AtlaSentError("decisionId is required", {
1043
+ code: "bad_request"
1044
+ });
1045
+ }
1046
+ const path = `/v1-decisions-replay/${encodeURIComponent(decisionId)}/replay`;
1047
+ const { body: wire, rateLimit } = await this.post(
1048
+ path,
1049
+ {}
1050
+ );
1051
+ if (typeof wire.decision_id !== "string" || typeof wire.original_decision !== "string" || typeof wire.engine_version_kind !== "string" || typeof wire.accepts_replay !== "boolean" || typeof wire.variance !== "string" || typeof wire.envelope_verification !== "string" || typeof wire.replayed_at !== "string") {
1052
+ throw new AtlaSentError(
1053
+ "Malformed response from /v1-decisions-replay/:id/replay: missing required fields",
1054
+ { code: "bad_response" }
1055
+ );
1056
+ }
1057
+ return { ...wire, rateLimit };
1058
+ }
1059
+ /**
1060
+ * ADR-015 Phase C — SDK-canonical replay runtime.
1061
+ *
1062
+ * Re-evaluates a recorded decision against its originally-pinned policy
1063
+ * bundle and engine version via `POST /v1/decisions/:id/replay`.
1064
+ * Side-effect-free server-side: no audit chain row is written and no
1065
+ * permit is issued (ADR-016 `mode: "replay"` sentinel).
1066
+ *
1067
+ * Differences from {@link replayDecision} (the 2.7.0 raw-wire surface):
1068
+ *
1069
+ * | | `replayDecision()` | `replay()` |
1070
+ * | --- | --- | --- |
1071
+ * | Path | `/v1-decisions-replay/:id/replay` | `/v1/decisions/:id/replay` |
1072
+ * | Variance | raw wire (`DECISION_CHANGED`) | SDK-canonical (`POLICY_DRIFT`) |
1073
+ * | 409 handling | throws `AtlaSentError` | returns `ENGINE_DRIFT` / `BUNDLE_MISSING` |
1074
+ * | Input shape | `decisionId: string` | `{ evaluationId }` |
1075
+ *
1076
+ * **Never throws on `409 replay_not_eligible`** — instead returns a
1077
+ * `ReplayResponse` with `varianceKind: "ENGINE_DRIFT"` (engine retired
1078
+ * beyond archival window) or `"BUNDLE_MISSING"` (no bundle pinned on
1079
+ * the original evaluation). Callers can always `switch` on
1080
+ * `result.varianceKind` without a try/catch.
1081
+ *
1082
+ * Fix-forward note: this method was originally landed in PR #275 but
1083
+ * dropped from the squash merge. The TS types (`ReplayResponse`,
1084
+ * `ReplayRequest`) and CHANGELOG made it through; the method itself
1085
+ * did not. Restored here to match the Python {@link
1086
+ * AtlaSentClient}.replay() that landed in atlasent-sdk@2.6.0 (Python).
1087
+ */
1088
+ async replay(input) {
1089
+ if (!input || typeof input.evaluationId !== "string" || input.evaluationId.length === 0) {
1090
+ throw new AtlaSentError("evaluationId is required", {
1091
+ code: "bad_request"
1092
+ });
1093
+ }
1094
+ const path = `/v1/decisions/${encodeURIComponent(input.evaluationId)}/replay`;
1095
+ let wire;
1096
+ let rateLimit;
1097
+ try {
1098
+ const result = await this.post(path, {});
1099
+ wire = result.body;
1100
+ rateLimit = result.rateLimit;
1101
+ } catch (err) {
1102
+ if (err instanceof AtlaSentError && err.status === 409) {
1103
+ const msg = (err.message ?? "").toLowerCase();
1104
+ const varianceKind2 = msg.includes("bundle") ? "BUNDLE_MISSING" : "ENGINE_DRIFT";
1105
+ return {
1106
+ decisionId: input.evaluationId,
1107
+ varianceKind: varianceKind2,
1108
+ originalDecision: "deny",
1109
+ acceptsReplay: false,
1110
+ replayedAt: (/* @__PURE__ */ new Date()).toISOString(),
1111
+ rateLimit: null
1112
+ };
1113
+ }
1114
+ throw err;
1115
+ }
1116
+ const VARIANCE_MAP = {
1117
+ NONE: "NONE",
1118
+ DECISION_CHANGED: "POLICY_DRIFT",
1119
+ ENVELOPE_DRIFT: "ENVELOPE_DRIFT",
1120
+ CHAIN_TAMPER: "CHAIN_TAMPER",
1121
+ BUNDLE_MISSING: "BUNDLE_MISSING",
1122
+ ENGINE_DRIFT: "ENGINE_DRIFT"
1123
+ };
1124
+ const rawVariance = typeof wire.variance === "string" ? wire.variance : "";
1125
+ const varianceKind = VARIANCE_MAP[rawVariance] ?? "NONE";
1126
+ const replayDec = typeof wire.replay_decision === "string" ? wire.replay_decision.toLowerCase() : void 0;
1127
+ const originalDec = typeof wire.original_decision === "string" ? wire.original_decision.toLowerCase() : "deny";
1128
+ const response = {
1129
+ decisionId: typeof wire.decision_id === "string" ? wire.decision_id : input.evaluationId,
1130
+ varianceKind,
1131
+ originalDecision: originalDec,
1132
+ acceptsReplay: typeof wire.accepts_replay === "boolean" ? wire.accepts_replay : true,
1133
+ replayedAt: typeof wire.replayed_at === "string" ? wire.replayed_at : (/* @__PURE__ */ new Date()).toISOString(),
1134
+ rateLimit
1135
+ };
1136
+ if (typeof wire.original_deny_code === "string") response.originalDenyCode = wire.original_deny_code;
1137
+ if (replayDec !== void 0) response.replayedDecision = replayDec;
1138
+ if (typeof wire.replay_deny_code === "string") response.replayedDenyCode = wire.replay_deny_code;
1139
+ if (typeof wire.engine_version === "string") response.engineVersion = wire.engine_version;
1140
+ if (typeof wire.engine_version_kind === "string") response.engineVersionKind = wire.engine_version_kind;
1141
+ if (typeof wire.envelope_verification === "string") response.envelopeVerification = wire.envelope_verification;
1142
+ return response;
1143
+ }
1144
+ /**
1145
+ * Open a streaming evaluation session against `POST /v1-evaluate-stream`.
1146
+ *
1147
+ * Yields {@link StreamDecisionEvent} and {@link StreamProgressEvent} objects
1148
+ * as the server emits them. The iterator ends cleanly when the server sends
1149
+ * `event: done`; it throws {@link AtlaSentError} on transport errors or when
1150
+ * the server sends `event: error`.
1151
+ *
1152
+ * The final {@link StreamDecisionEvent} (isFinal: true) carries a `permitId`
1153
+ * suitable for passing to {@link verifyPermit} after the stream closes.
1154
+ *
1155
+ * Hardening:
1156
+ * - Throws {@link StreamTimeoutError} when no event arrives within
1157
+ * `opts.timeoutMs` (default 30 s). Pass `0` to disable.
1158
+ * - Retries up to `opts.maxRetries` times (default 3) with 1 s / 2 s / 4 s
1159
+ * delays on network drop (before a terminal event). Sends `Last-Event-ID`
1160
+ * on reconnect when the server has emitted event IDs.
1161
+ * - Throws {@link StreamParseError} on partial / malformed JSON rather than
1162
+ * crashing with a raw `SyntaxError`.
1163
+ * - Closes cleanly on `event: done` or a decision event with `done: true`.
1164
+ *
1165
+ * ```ts
1166
+ * for await (const event of client.protectStream({ agent, action })) {
1167
+ * if (event.type === "decision" && event.isFinal) {
1168
+ * await client.verifyPermit({ permitId: event.permitId });
1169
+ * }
1170
+ * }
1171
+ * ```
1172
+ */
1173
+ async *protectStream(input, opts = {}) {
1174
+ const streamTimeoutMs = opts.timeoutMs ?? 3e4;
1175
+ const maxRetries = opts.maxRetries ?? 3;
1176
+ const body = {
1177
+ action: input.action,
1178
+ agent: input.agent,
1179
+ context: input.context ?? {},
1180
+ api_key: this.apiKey
1181
+ };
1182
+ const requestId = globalThis.crypto.randomUUID();
1183
+ const url = `${this.baseUrl}/v1-evaluate-stream`;
1184
+ let lastEventId;
1185
+ let retryCount = 0;
1186
+ while (true) {
1187
+ const headers = {
1188
+ Accept: "text/event-stream",
1189
+ "Content-Type": "application/json",
1190
+ Authorization: `Bearer ${this.apiKey}`,
1191
+ "User-Agent": this.userAgent,
1192
+ // ADR-025: wire-protocol version declared on every request.
1193
+ "X-AtlaSent-Protocol-Version": "1",
1194
+ "X-Request-ID": requestId
1195
+ };
1196
+ if (lastEventId !== void 0) {
1197
+ headers["Last-Event-ID"] = lastEventId;
1198
+ }
1199
+ const connectionTimeoutSignal = AbortSignal.timeout(this.timeoutMs);
1200
+ const signal = opts.signal ? AbortSignal.any([connectionTimeoutSignal, opts.signal]) : connectionTimeoutSignal;
1201
+ let response;
1202
+ try {
1203
+ response = await this.fetchImpl(url, {
1204
+ method: "POST",
1205
+ headers,
1206
+ body: JSON.stringify(body),
1207
+ signal
1208
+ });
1209
+ } catch (err) {
1210
+ const mapped = mapFetchError(err, requestId);
1211
+ if (mapped.code === "network" && retryCount < maxRetries) {
1212
+ retryCount++;
1213
+ await sleep(1e3 * Math.pow(2, retryCount - 1));
1214
+ continue;
1215
+ }
1216
+ throw mapped;
1217
+ }
1218
+ if (!response.ok) {
1219
+ throw await buildHttpError(response, requestId);
1220
+ }
1221
+ if (!response.body) {
1222
+ throw new AtlaSentError("Expected streaming body from AtlaSent API", {
1223
+ code: "bad_response",
1224
+ status: response.status,
1225
+ requestId
1226
+ });
1227
+ }
1228
+ let streamDone = false;
1229
+ let networkDrop = false;
1230
+ try {
1231
+ for await (const event of parseSseStream(
1232
+ response.body,
1233
+ requestId,
1234
+ streamTimeoutMs,
1235
+ (id) => {
1236
+ lastEventId = id;
1237
+ }
1238
+ )) {
1239
+ yield event;
1240
+ if (event.type === "decision" && event.isFinal) {
1241
+ streamDone = true;
1242
+ }
1243
+ }
1244
+ streamDone = true;
1245
+ } catch (err) {
1246
+ if (err instanceof AtlaSentError && err.code === "network") {
1247
+ networkDrop = true;
1248
+ } else {
1249
+ throw err;
1250
+ }
1251
+ }
1252
+ if (streamDone) break;
1253
+ if (networkDrop && retryCount < maxRetries) {
1254
+ retryCount++;
1255
+ await sleep(1e3 * Math.pow(2, retryCount - 1));
1256
+ continue;
1257
+ }
1258
+ if (networkDrop) {
1259
+ throw new AtlaSentError(
1260
+ `AtlaSent stream dropped after ${retryCount} reconnection attempts`,
1261
+ { code: "network", requestId }
1262
+ );
1263
+ }
1264
+ break;
1265
+ }
1266
+ }
1267
+ async post(path, body, query) {
1268
+ return this.request(path, "POST", body, query);
223
1269
  }
224
1270
  async get(path, query) {
225
1271
  return this.request(path, "GET", void 0, query);
@@ -231,48 +1277,688 @@ var AtlaSentClient = class {
231
1277
  const headers = {
232
1278
  Accept: "application/json",
233
1279
  Authorization: `Bearer ${this.apiKey}`,
234
- "User-Agent": `@atlasent/sdk/${SDK_VERSION} node/${process.version}`,
235
- "X-Request-ID": requestId
1280
+ "User-Agent": this.userAgent,
1281
+ "X-Request-ID": requestId,
1282
+ // ADR-025: wire-protocol version declared on every request.
1283
+ "X-AtlaSent-Protocol-Version": "1"
236
1284
  };
237
1285
  if (method === "POST") headers["Content-Type"] = "application/json";
238
- const init = {
239
- method,
240
- headers,
241
- signal: AbortSignal.timeout(this.timeoutMs)
242
- };
243
- if (method === "POST") init.body = JSON.stringify(body);
244
- let response;
245
- try {
246
- response = await this.fetchImpl(url, init);
247
- } catch (err) {
248
- throw mapFetchError(err, requestId);
1286
+ const bodyStr = method === "POST" ? JSON.stringify(body) : void 0;
1287
+ for (let attempt = 0; ; attempt++) {
1288
+ const init = {
1289
+ method,
1290
+ headers,
1291
+ signal: AbortSignal.timeout(this.timeoutMs)
1292
+ };
1293
+ if (bodyStr !== void 0) init.body = bodyStr;
1294
+ let response;
1295
+ try {
1296
+ response = await this.fetchImpl(url, init);
1297
+ } catch (err) {
1298
+ const mapped = mapFetchError(err, requestId);
1299
+ if (isRetryable(mapped) && hasAttemptsLeft(attempt, this.retryPolicy)) {
1300
+ await sleep(computeBackoffMs(attempt, this.retryPolicy, mapped));
1301
+ continue;
1302
+ }
1303
+ throw mapped;
1304
+ }
1305
+ if (!response.ok) {
1306
+ const httpErr = await buildHttpError(response, requestId);
1307
+ if (isRetryable(httpErr) && hasAttemptsLeft(attempt, this.retryPolicy)) {
1308
+ await sleep(computeBackoffMs(attempt, this.retryPolicy, httpErr));
1309
+ continue;
1310
+ }
1311
+ throw httpErr;
1312
+ }
1313
+ let parsed;
1314
+ try {
1315
+ parsed = await response.json();
1316
+ } catch (err) {
1317
+ const jsonErr = new AtlaSentError(
1318
+ "Invalid JSON response from AtlaSent API",
1319
+ {
1320
+ code: "bad_response",
1321
+ status: response.status,
1322
+ requestId,
1323
+ cause: err
1324
+ }
1325
+ );
1326
+ if (isRetryable(jsonErr) && hasAttemptsLeft(attempt, this.retryPolicy)) {
1327
+ await sleep(computeBackoffMs(attempt, this.retryPolicy, jsonErr));
1328
+ continue;
1329
+ }
1330
+ throw jsonErr;
1331
+ }
1332
+ if (parsed === null || typeof parsed !== "object") {
1333
+ const shapeErr = new AtlaSentError(
1334
+ "Expected a JSON object from AtlaSent API",
1335
+ {
1336
+ code: "bad_response",
1337
+ status: response.status,
1338
+ requestId
1339
+ }
1340
+ );
1341
+ if (isRetryable(shapeErr) && hasAttemptsLeft(attempt, this.retryPolicy)) {
1342
+ await sleep(computeBackoffMs(attempt, this.retryPolicy, shapeErr));
1343
+ continue;
1344
+ }
1345
+ throw shapeErr;
1346
+ }
1347
+ return {
1348
+ body: parsed,
1349
+ rateLimit: parseRateLimitHeaders(response.headers)
1350
+ };
249
1351
  }
250
- if (!response.ok) {
251
- throw await buildHttpError(response, requestId);
1352
+ }
1353
+ /**
1354
+ * Open a new HITL escalation. Bridges a `hold` outcome from
1355
+ * `protect()` to the approval queue: an agent that receives a
1356
+ * `hold` decision calls this to enroll the proposed action for
1357
+ * human review. The returned escalation can then be polled with
1358
+ * `getHitlEscalation()` or driven to terminal by
1359
+ * `approveHitlEscalation()` / `rejectHitlEscalation()`.
1360
+ *
1361
+ * Quorum, pool size, fallback decision and routing inherit from
1362
+ * the server-side policy when omitted from `input`.
1363
+ *
1364
+ * Calls `POST /v1/hitl`.
1365
+ */
1366
+ async createHitlEscalation(input) {
1367
+ const { body, rateLimit } = await this.post(
1368
+ "/v1/hitl",
1369
+ input
1370
+ );
1371
+ return { escalation: body, rateLimit };
1372
+ }
1373
+ /**
1374
+ * List HITL escalations for the calling org. Defaults to
1375
+ * `status=pending`; pass `status` to query other queues
1376
+ * (`escalated`, `approved`, `rejected`, `auto_approved`,
1377
+ * `timed_out`).
1378
+ *
1379
+ * Calls `GET /v1/hitl`.
1380
+ */
1381
+ async listHitlEscalations(input = {}) {
1382
+ const params = new URLSearchParams();
1383
+ if (input.status) params.set("status", input.status);
1384
+ if (input.agentId) params.set("agent_id", input.agentId);
1385
+ if (input.assignedToUserId)
1386
+ params.set("assigned_to_user_id", input.assignedToUserId);
1387
+ if (input.limit !== void 0) params.set("limit", String(input.limit));
1388
+ if (input.cursor) params.set("cursor", input.cursor);
1389
+ const { body, rateLimit } = await this.get(
1390
+ "/v1/hitl",
1391
+ params
1392
+ );
1393
+ return { data: body, rateLimit };
1394
+ }
1395
+ /**
1396
+ * Get a HITL escalation. The server payload includes a live
1397
+ * `quorum_progress` snapshot when the escalation is still open.
1398
+ *
1399
+ * Calls `GET /v1/hitl/:id`.
1400
+ */
1401
+ async getHitlEscalation(escalationId) {
1402
+ if (!escalationId) {
1403
+ throw new AtlaSentError("escalationId is required", {
1404
+ code: "bad_request"
1405
+ });
252
1406
  }
253
- let parsed;
254
- try {
255
- parsed = await response.json();
256
- } catch (err) {
257
- throw new AtlaSentError("Invalid JSON response from AtlaSent API", {
258
- code: "bad_response",
259
- status: response.status,
260
- requestId,
261
- cause: err
1407
+ const { body, rateLimit } = await this.get(
1408
+ `/v1/hitl/${encodeURIComponent(escalationId)}`
1409
+ );
1410
+ return { escalation: body, rateLimit };
1411
+ }
1412
+ /**
1413
+ * List per-approver vote rows for an escalation.
1414
+ * Calls `GET /v1/hitl/:id/approvals`.
1415
+ */
1416
+ async listHitlApprovals(escalationId) {
1417
+ const { body, rateLimit } = await this.get(`/v1/hitl/${encodeURIComponent(escalationId)}/approvals`);
1418
+ return { approvals: body.approvals ?? [], rateLimit };
1419
+ }
1420
+ /**
1421
+ * List the escalation chain hops for an escalation. Each `/escalate`
1422
+ * call appends one row.
1423
+ * Calls `GET /v1/hitl/:id/chain`.
1424
+ */
1425
+ async getHitlChain(escalationId) {
1426
+ const { body, rateLimit } = await this.get(
1427
+ `/v1/hitl/${encodeURIComponent(escalationId)}/chain`
1428
+ );
1429
+ return { chain: body.chain ?? [], rateLimit };
1430
+ }
1431
+ /**
1432
+ * Record an approve vote. Resolves the escalation only once the
1433
+ * server-side quorum count is satisfied; before that the response
1434
+ * carries a refreshed escalation row with the latest
1435
+ * `quorum_progress`.
1436
+ *
1437
+ * Calls `POST /v1/hitl/:id/approve`. The server returns 409
1438
+ * `duplicate_vote` if the same principal has already voted, and
1439
+ * 409 `already_rejected` if a concurrent reject crossed the line.
1440
+ */
1441
+ async approveHitlEscalation(escalationId, input = {}) {
1442
+ const { body, rateLimit } = await this.post(
1443
+ `/v1/hitl/${encodeURIComponent(escalationId)}/approve`,
1444
+ input
1445
+ );
1446
+ return { escalation: body, rateLimit };
1447
+ }
1448
+ /**
1449
+ * Record a reject vote. Reject is short-circuit terminal — a single
1450
+ * reject closes the escalation regardless of how many approves have
1451
+ * accumulated.
1452
+ *
1453
+ * Calls `POST /v1/hitl/:id/reject`.
1454
+ */
1455
+ async rejectHitlEscalation(escalationId, input = {}) {
1456
+ const { body, rateLimit } = await this.post(
1457
+ `/v1/hitl/${encodeURIComponent(escalationId)}/reject`,
1458
+ input
1459
+ );
1460
+ return { escalation: body, rateLimit };
1461
+ }
1462
+ /**
1463
+ * Re-route an open escalation to a higher tier. Bounded by the
1464
+ * escalation's `max_escalation_depth` — the server returns 409
1465
+ * `chain_exhausted` and applies the configured fallback decision
1466
+ * once the ceiling is hit.
1467
+ *
1468
+ * Calls `POST /v1/hitl/:id/escalate`.
1469
+ */
1470
+ async escalateHitlEscalation(escalationId, input) {
1471
+ const { body, rateLimit } = await this.post(
1472
+ `/v1/hitl/${encodeURIComponent(escalationId)}/escalate`,
1473
+ input
1474
+ );
1475
+ return { escalation: body, rateLimit };
1476
+ }
1477
+ /**
1478
+ * Manually apply the escalation's `fallback_decision`. Useful for
1479
+ * admin recovery of a hung escalation when the cron sweeper hasn't
1480
+ * run yet, or to short-circuit a stuck flow during incident
1481
+ * response.
1482
+ *
1483
+ * Calls `POST /v1/hitl/:id/timeout`.
1484
+ */
1485
+ async timeoutHitlEscalation(escalationId) {
1486
+ const { body, rateLimit } = await this.post(
1487
+ `/v1/hitl/${encodeURIComponent(escalationId)}/timeout`,
1488
+ {}
1489
+ );
1490
+ return { escalation: body, rateLimit };
1491
+ }
1492
+ /**
1493
+ * Run a named governance graph traversal query.
1494
+ *
1495
+ * Dispatches to `GET /v1/governance/graph/query?type=<queryType>`.
1496
+ * Each query type returns a different row shape — the return type
1497
+ * narrows automatically based on the literal `queryType` argument.
1498
+ *
1499
+ * `"user_approvals"` requires `params.actor_id` — the server returns
1500
+ * a 400 if it is absent.
1501
+ */
1502
+ async queryGovernanceGraph(queryType, params = {}) {
1503
+ const qs = new URLSearchParams({ type: queryType });
1504
+ if (params.actor_id) qs.set("actor_id", params.actor_id);
1505
+ const { body, rateLimit } = await this.get("/v1/governance/graph/query", qs);
1506
+ return { ...body, rateLimit };
1507
+ }
1508
+ /**
1509
+ * Reconstruct the multi-system execution timeline for a specific incident.
1510
+ *
1511
+ * Calls `GET /v1/governance/timeline/incident/{incidentId}`. Backed
1512
+ * server-side by `reconstruct_incident_chains_v2()`, which fixes the
1513
+ * `executor_id → actor_id` bug that silently produced empty timelines
1514
+ * in the original function.
1515
+ *
1516
+ * Returns full execution rows including the §13.1 columns
1517
+ * (`delegation_chain_id`, `replay_of_execution_id`, `incident_id`,
1518
+ * `policy_version_id`, `bundle_version_id`) alongside the actor
1519
+ * timeline and evidence rows.
1520
+ */
1521
+ async getIncidentTimeline(incidentId) {
1522
+ if (!incidentId) {
1523
+ throw new AtlaSentError("incidentId is required", {
1524
+ code: "bad_request"
262
1525
  });
263
1526
  }
264
- if (parsed === null || typeof parsed !== "object") {
265
- throw new AtlaSentError("Expected a JSON object from AtlaSent API", {
266
- code: "bad_response",
267
- status: response.status,
268
- requestId
1527
+ const { body, rateLimit } = await this.get(`/v1/governance/timeline/incident/${encodeURIComponent(incidentId)}`);
1528
+ return { ...body, rateLimit };
1529
+ }
1530
+ // ── Connector Management ─────────────────────────────────────────────────
1531
+ /**
1532
+ * List connectors registered for the calling org.
1533
+ * Calls `GET /v1/governance/connectors`.
1534
+ */
1535
+ async listConnectors(options = {}) {
1536
+ const params = new URLSearchParams();
1537
+ if (options.cursor) params.set("cursor", options.cursor);
1538
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
1539
+ const { body, rateLimit } = await this.get("/v1/governance/connectors", params);
1540
+ const result = {
1541
+ connectors: body.connectors ?? [],
1542
+ total: body.total,
1543
+ rateLimit
1544
+ };
1545
+ if (body.next_cursor) result.nextCursor = body.next_cursor;
1546
+ return result;
1547
+ }
1548
+ /**
1549
+ * Register and install a new connector for the calling org.
1550
+ * Calls `POST /v1/governance/connectors`.
1551
+ */
1552
+ async installConnector(input) {
1553
+ const { body, rateLimit } = await this.post("/v1/governance/connectors", input);
1554
+ return { connector: body, rateLimit };
1555
+ }
1556
+ /**
1557
+ * Store encrypted credentials for a connector.
1558
+ * Calls `POST /v1/governance/connectors/{id}/authenticate`.
1559
+ */
1560
+ async authenticateConnector(connectorId, input) {
1561
+ if (!connectorId) {
1562
+ throw new AtlaSentError("connectorId is required", {
1563
+ code: "bad_request"
269
1564
  });
270
1565
  }
1566
+ const { body, rateLimit } = await this.post(
1567
+ `/v1/governance/connectors/${encodeURIComponent(connectorId)}/authenticate`,
1568
+ input
1569
+ );
271
1570
  return {
272
- body: parsed,
273
- rateLimit: parseRateLimitHeaders(response.headers)
1571
+ credential_id: body.credential_id,
1572
+ version: body.version,
1573
+ rateLimit
274
1574
  };
275
1575
  }
1576
+ /**
1577
+ * Trigger an incremental sync for a connector.
1578
+ * Calls `POST /v1/governance/connectors/{id}/sync`.
1579
+ */
1580
+ async syncConnector(connectorId) {
1581
+ if (!connectorId) {
1582
+ throw new AtlaSentError("connectorId is required", {
1583
+ code: "bad_request"
1584
+ });
1585
+ }
1586
+ const { body, rateLimit } = await this.post(`/v1/governance/connectors/${encodeURIComponent(connectorId)}/sync`, {});
1587
+ return { ...body, rateLimit };
1588
+ }
1589
+ /**
1590
+ * Revoke a connector and all its associated credentials.
1591
+ * Calls `POST /v1/governance/connectors/{id}/revoke`.
1592
+ */
1593
+ async revokeConnector(connectorId, reason) {
1594
+ if (!connectorId) {
1595
+ throw new AtlaSentError("connectorId is required", {
1596
+ code: "bad_request"
1597
+ });
1598
+ }
1599
+ const body = {};
1600
+ if (reason !== void 0) body.reason = reason;
1601
+ const { body: wire, rateLimit } = await this.post(
1602
+ `/v1/governance/connectors/${encodeURIComponent(connectorId)}/revoke`,
1603
+ body
1604
+ );
1605
+ return { ...wire, rateLimit };
1606
+ }
1607
+ /**
1608
+ * Rotate the credentials for a connector.
1609
+ * Calls `POST /v1/governance/connectors/{id}/rotate-credentials`.
1610
+ */
1611
+ async rotateConnectorCredentials(connectorId) {
1612
+ if (!connectorId) {
1613
+ throw new AtlaSentError("connectorId is required", {
1614
+ code: "bad_request"
1615
+ });
1616
+ }
1617
+ const { body, rateLimit } = await this.post(
1618
+ `/v1/governance/connectors/${encodeURIComponent(connectorId)}/rotate-credentials`,
1619
+ {}
1620
+ );
1621
+ return { ...body, rateLimit };
1622
+ }
1623
+ /**
1624
+ * List enforcement policies for the calling org, optionally filtered by connector type.
1625
+ * Calls `GET /v1/governance/enforcement-policies`.
1626
+ */
1627
+ async listEnforcementPolicies(connectorType) {
1628
+ const params = new URLSearchParams();
1629
+ if (connectorType) params.set("connector_type", connectorType);
1630
+ const { body, rateLimit } = await this.get("/v1/governance/enforcement-policies", params);
1631
+ return { policies: body.policies ?? [], total: body.total, rateLimit };
1632
+ }
1633
+ /**
1634
+ * Create or update a connector enforcement policy.
1635
+ * Calls `POST /v1/governance/enforcement-policies`.
1636
+ */
1637
+ async upsertEnforcementPolicy(input) {
1638
+ const { body, rateLimit } = await this.post("/v1/governance/enforcement-policies", input);
1639
+ return { policy: body, rateLimit };
1640
+ }
1641
+ // ── Organizational Risk Graph ─────────────────────────────────────────────
1642
+ /**
1643
+ * Trigger a fresh org-level risk score computation.
1644
+ * Calls `POST /v1/governance/risk/compute`.
1645
+ */
1646
+ async computeOrgRisk(options = {}) {
1647
+ const { body, rateLimit } = await this.post("/v1/governance/risk/compute", options);
1648
+ return { score: body, rateLimit };
1649
+ }
1650
+ /**
1651
+ * Retrieve the most recently computed risk score for the calling org.
1652
+ * Calls `GET /v1/governance/risk/latest`.
1653
+ */
1654
+ async getLatestOrgRisk() {
1655
+ const { body, rateLimit } = await this.get("/v1/governance/risk/latest");
1656
+ return { score: body.score ?? null, rateLimit };
1657
+ }
1658
+ /**
1659
+ * Page through historical org risk scores, most-recent first.
1660
+ * Calls `GET /v1/governance/risk/history`.
1661
+ */
1662
+ async listOrgRiskHistory(options = {}) {
1663
+ const params = new URLSearchParams();
1664
+ if (options.cursor) params.set("cursor", options.cursor);
1665
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
1666
+ const { body, rateLimit } = await this.get("/v1/governance/risk/history", params);
1667
+ const result = {
1668
+ scores: body.scores ?? [],
1669
+ total: body.total,
1670
+ rateLimit
1671
+ };
1672
+ if (body.next_cursor) result.nextCursor = body.next_cursor;
1673
+ return result;
1674
+ }
1675
+ // ── Cross-Org Permission Negotiation ──────────────────────────────────────
1676
+ async checkCrossOrgPermission(req) {
1677
+ const { body } = await this.post(
1678
+ "/v1/cross-org/permissions/check",
1679
+ req
1680
+ );
1681
+ return body;
1682
+ }
1683
+ async listCrossOrgPermissionChecks(params) {
1684
+ const qs = new URLSearchParams();
1685
+ if (params?.source_org_id) qs.set("source_org_id", params.source_org_id);
1686
+ if (params?.target_org_id) qs.set("target_org_id", params.target_org_id);
1687
+ if (params?.allowed !== void 0)
1688
+ qs.set("allowed", String(params.allowed));
1689
+ if (params?.limit !== void 0) qs.set("limit", String(params.limit));
1690
+ const { body } = await this.get("/v1/cross-org/permissions/checks", qs);
1691
+ return body.checks ?? [];
1692
+ }
1693
+ // ── Anomaly Response Automation ───────────────────────────────────────────
1694
+ async listAnomalyResponseRules() {
1695
+ const { body } = await this.get(
1696
+ "/v1/anomaly-response/rules"
1697
+ );
1698
+ return body.rules ?? [];
1699
+ }
1700
+ async createAnomalyResponseRule(req) {
1701
+ const { body } = await this.post(
1702
+ "/v1/anomaly-response/rules",
1703
+ req
1704
+ );
1705
+ return body;
1706
+ }
1707
+ async updateAnomalyResponseRule(id, updates) {
1708
+ const { body } = await this.post(
1709
+ `/v1/anomaly-response/rules/${encodeURIComponent(id)}/update`,
1710
+ updates
1711
+ );
1712
+ return body;
1713
+ }
1714
+ async deleteAnomalyResponseRule(id) {
1715
+ await this.post(
1716
+ `/v1/anomaly-response/rules/${encodeURIComponent(id)}/delete`,
1717
+ {}
1718
+ );
1719
+ }
1720
+ async triggerAnomalyResponse(req) {
1721
+ const { body } = await this.post(
1722
+ "/v1/anomaly-response/trigger",
1723
+ req
1724
+ );
1725
+ return body.events ?? [];
1726
+ }
1727
+ async listAnomalyResponseEvents(params) {
1728
+ const qs = new URLSearchParams();
1729
+ if (params?.execution_id) qs.set("execution_id", params.execution_id);
1730
+ if (params?.limit !== void 0) qs.set("limit", String(params.limit));
1731
+ const { body } = await this.get(
1732
+ "/v1/anomaly-response/events",
1733
+ qs
1734
+ );
1735
+ return body.events ?? [];
1736
+ }
1737
+ // ── Budget Exception Workflows ────────────────────────────────────────────
1738
+ async listBudgetExceptions(params) {
1739
+ const qs = new URLSearchParams();
1740
+ if (params?.status) qs.set("status", params.status);
1741
+ if (params?.budget_policy_id)
1742
+ qs.set("budget_policy_id", params.budget_policy_id);
1743
+ if (params?.limit !== void 0) qs.set("limit", String(params.limit));
1744
+ if (params?.offset !== void 0) qs.set("offset", String(params.offset));
1745
+ const { body } = await this.get(
1746
+ "/v1/budget-exceptions",
1747
+ qs
1748
+ );
1749
+ return body.exceptions ?? [];
1750
+ }
1751
+ async getBudgetException(id) {
1752
+ const { body } = await this.get(
1753
+ `/v1/budget-exceptions/${encodeURIComponent(id)}`
1754
+ );
1755
+ return body;
1756
+ }
1757
+ async createBudgetException(req) {
1758
+ const { body } = await this.post(
1759
+ "/v1/budget-exceptions",
1760
+ req
1761
+ );
1762
+ return body;
1763
+ }
1764
+ async approveBudgetException(id, req) {
1765
+ const { body } = await this.post(
1766
+ `/v1/budget-exceptions/${encodeURIComponent(id)}/approve`,
1767
+ req
1768
+ );
1769
+ return body;
1770
+ }
1771
+ async rejectBudgetException(id, review_notes) {
1772
+ const { body } = await this.post(
1773
+ `/v1/budget-exceptions/${encodeURIComponent(id)}/reject`,
1774
+ { review_notes }
1775
+ );
1776
+ return body;
1777
+ }
1778
+ async cancelBudgetException(id) {
1779
+ const { body } = await this.post(
1780
+ `/v1/budget-exceptions/${encodeURIComponent(id)}/cancel`,
1781
+ {}
1782
+ );
1783
+ return body;
1784
+ }
1785
+ // ── Regulatory Escalation Chain ───────────────────────────────────────────
1786
+ async listRegulatoryAuthorityLevels() {
1787
+ const { body } = await this.get(
1788
+ "/v1/regulatory/authority-levels"
1789
+ );
1790
+ return body.levels ?? [];
1791
+ }
1792
+ async createRegulatoryAuthorityLevel(req) {
1793
+ const { body } = await this.post(
1794
+ "/v1/regulatory/authority-levels",
1795
+ req
1796
+ );
1797
+ return body;
1798
+ }
1799
+ async listRegulatoryEscalations(params) {
1800
+ const qs = new URLSearchParams();
1801
+ if (params?.status) qs.set("status", params.status);
1802
+ if (params?.subject_type) qs.set("subject_type", params.subject_type);
1803
+ if (params?.subject_id) qs.set("subject_id", params.subject_id);
1804
+ const { body } = await this.get(
1805
+ "/v1/regulatory/escalations",
1806
+ qs
1807
+ );
1808
+ return body.escalations ?? [];
1809
+ }
1810
+ async createRegulatoryEscalation(req) {
1811
+ const { body } = await this.post(
1812
+ "/v1/regulatory/escalations",
1813
+ req
1814
+ );
1815
+ return body;
1816
+ }
1817
+ async acknowledgeRegulatoryEscalation(id) {
1818
+ const { body } = await this.post(
1819
+ `/v1/regulatory/escalations/${encodeURIComponent(id)}/acknowledge`,
1820
+ {}
1821
+ );
1822
+ return body;
1823
+ }
1824
+ async resolveRegulatoryEscalation(id, resolution, resolution_details) {
1825
+ const { body } = await this.post(
1826
+ `/v1/regulatory/escalations/${encodeURIComponent(id)}/resolve`,
1827
+ { resolution, resolution_details }
1828
+ );
1829
+ return body;
1830
+ }
1831
+ async overrideRegulatoryEscalation(id, reason) {
1832
+ const { body } = await this.post(
1833
+ `/v1/regulatory/escalations/${encodeURIComponent(id)}/override`,
1834
+ { reason }
1835
+ );
1836
+ return body;
1837
+ }
1838
+ // ── Incentive Signal Feedback Loop ────────────────────────────────────────
1839
+ async listSignalActions(signal_id) {
1840
+ const { body } = await this.get(
1841
+ `/v1/governance/signals/${encodeURIComponent(signal_id)}/actions`
1842
+ );
1843
+ return body.actions ?? [];
1844
+ }
1845
+ async recordSignalAction(signal_id, req) {
1846
+ const { body } = await this.post(
1847
+ `/v1/governance/signals/${encodeURIComponent(signal_id)}/actions`,
1848
+ req
1849
+ );
1850
+ return body;
1851
+ }
1852
+ async recordSignalOutcome(signal_id, action_id, req) {
1853
+ const { body } = await this.post(
1854
+ `/v1/governance/signals/${encodeURIComponent(signal_id)}/actions/${encodeURIComponent(action_id)}/outcome`,
1855
+ req
1856
+ );
1857
+ return body;
1858
+ }
1859
+ async getSignalActionSummary() {
1860
+ const { body } = await this.get(
1861
+ "/v1/governance/signals/actions/summary"
1862
+ );
1863
+ return body;
1864
+ }
1865
+ // ── Cross-Org Impersonation ───────────────────────────────────────────────
1866
+ async listImpersonationGrants() {
1867
+ const { body } = await this.get(
1868
+ "/v1/cross-org/impersonation/grants"
1869
+ );
1870
+ return body.grants ?? [];
1871
+ }
1872
+ async createImpersonationGrant(req) {
1873
+ const { body } = await this.post(
1874
+ "/v1/cross-org/impersonation/grants",
1875
+ req
1876
+ );
1877
+ return body;
1878
+ }
1879
+ async revokeImpersonationGrant(id) {
1880
+ await this.post(
1881
+ `/v1/cross-org/impersonation/grants/${encodeURIComponent(id)}/revoke`,
1882
+ {}
1883
+ );
1884
+ }
1885
+ async issueImpersonationToken(grant_id, requested_duration_seconds) {
1886
+ const { body } = await this.post(
1887
+ `/v1/cross-org/impersonation/grants/${encodeURIComponent(grant_id)}/token`,
1888
+ { requested_duration_seconds }
1889
+ );
1890
+ return body;
1891
+ }
1892
+ async validateImpersonationToken(token) {
1893
+ const { body } = await this.post(
1894
+ "/v1/cross-org/impersonation/validate",
1895
+ { token }
1896
+ );
1897
+ return body;
1898
+ }
1899
+ // ── Constrained governance agents (read surface) ──────────────────────────
1900
+ //
1901
+ // Three GETs onto the v1-governance-agents edge function. Doctrine:
1902
+ // findings produced by these endpoints are advisory signal, never
1903
+ // authority. There is no `runGovernanceAgent` method on this client —
1904
+ // invocation belongs in CI (atlasent-action `governance-agents` mode),
1905
+ // not in application code.
1906
+ /**
1907
+ * List the advisory governance-agent registry for the calling org.
1908
+ *
1909
+ * Calls `GET /v1/governance/agents`. The registry is reference data
1910
+ * seeded at runtime-DB migration time; every row has
1911
+ * `authority_class = "advisory"` and `can_authorize = false` —
1912
+ * structural invariants enforced by the schema, not policy.
1913
+ */
1914
+ async listGovernanceAgents() {
1915
+ const { body } = await this.get(
1916
+ "/v1/governance/agents"
1917
+ );
1918
+ return [...body.agents ?? []];
1919
+ }
1920
+ /**
1921
+ * List advisory findings emitted against one governed change.
1922
+ *
1923
+ * Calls `GET /v1/governance/findings?change_id=…[&agent_slug=…]`.
1924
+ * Returns the typed-finding rows in `created_at DESC` order, including
1925
+ * `routed_gate_id` when the finding→gate trigger linked them. Findings
1926
+ * with `can_authorize === false` (always) are advisory; rendering them
1927
+ * never satisfies a gate.
1928
+ */
1929
+ async listGovernanceFindings(query) {
1930
+ if (!query?.change_id) {
1931
+ throw new AtlaSentError("change_id is required", { code: "bad_request" });
1932
+ }
1933
+ const params = new URLSearchParams({ change_id: query.change_id });
1934
+ if (query.agent_slug) params.set("agent_slug", query.agent_slug);
1935
+ const { body } = await this.get(
1936
+ "/v1/governance/findings",
1937
+ params
1938
+ );
1939
+ return [...body.findings ?? []];
1940
+ }
1941
+ /**
1942
+ * List agent run records against one governed change.
1943
+ *
1944
+ * Calls `GET /v1/governance/evaluations?change_id=…[&agent_slug=…]`.
1945
+ * Returns every persisted evaluation, including `failed` / `timeout`
1946
+ * runs and `completed` runs with zero findings — the latter is the
1947
+ * positive signal "the agent ran and found nothing", which the UI
1948
+ * surfaces as `clear`.
1949
+ */
1950
+ async listGovernanceEvaluations(query) {
1951
+ if (!query?.change_id) {
1952
+ throw new AtlaSentError("change_id is required", { code: "bad_request" });
1953
+ }
1954
+ const params = new URLSearchParams({ change_id: query.change_id });
1955
+ if (query.agent_slug) params.set("agent_slug", query.agent_slug);
1956
+ const { body } = await this.get(
1957
+ "/v1/governance/evaluations",
1958
+ params
1959
+ );
1960
+ return [...body.evaluations ?? []];
1961
+ }
276
1962
  };
277
1963
  function parseRateLimitHeaders(headers) {
278
1964
  const rawLimit = headers.get("x-ratelimit-limit");
@@ -417,6 +2103,9 @@ function buildAuditEventsQuery(query) {
417
2103
  }
418
2104
  return params;
419
2105
  }
2106
+ function sleep(ms) {
2107
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
2108
+ }
420
2109
  function parseRetryAfter(raw) {
421
2110
  if (!raw) return void 0;
422
2111
  const seconds = Number(raw);
@@ -428,13 +2117,133 @@ function parseRetryAfter(raw) {
428
2117
  }
429
2118
  return void 0;
430
2119
  }
2120
+ async function* parseSseStream(body, requestId, timeoutMs, onEventId) {
2121
+ const reader = body.getReader();
2122
+ const decoder = new TextDecoder("utf-8");
2123
+ let buf = "";
2124
+ async function readChunk() {
2125
+ if (timeoutMs <= 0) {
2126
+ return reader.read();
2127
+ }
2128
+ return new Promise((resolve2, reject) => {
2129
+ const timer = setTimeout(() => {
2130
+ reject(new StreamTimeoutError(timeoutMs));
2131
+ }, timeoutMs);
2132
+ reader.read().then(
2133
+ (result) => {
2134
+ clearTimeout(timer);
2135
+ resolve2(result);
2136
+ },
2137
+ (err) => {
2138
+ clearTimeout(timer);
2139
+ reject(err);
2140
+ }
2141
+ );
2142
+ });
2143
+ }
2144
+ try {
2145
+ for (; ; ) {
2146
+ let done;
2147
+ let value;
2148
+ try {
2149
+ const result = await readChunk();
2150
+ done = result.done;
2151
+ value = result.value;
2152
+ } catch (err) {
2153
+ if (err instanceof StreamTimeoutError) throw err;
2154
+ throw new AtlaSentError(
2155
+ `AtlaSent stream read failed: ${err instanceof Error ? err.message : String(err)}`,
2156
+ { code: "network", requestId, cause: err }
2157
+ );
2158
+ }
2159
+ if (done) break;
2160
+ buf += decoder.decode(value, { stream: true });
2161
+ let boundary;
2162
+ while ((boundary = buf.indexOf("\n\n")) !== -1) {
2163
+ const block = buf.slice(0, boundary);
2164
+ buf = buf.slice(boundary + 2);
2165
+ let eventType = "message";
2166
+ let data = "";
2167
+ let eventId;
2168
+ for (const line of block.split("\n")) {
2169
+ if (line.startsWith("event: ")) eventType = line.slice(7).trim();
2170
+ else if (line.startsWith("data: ")) data = line.slice(6);
2171
+ else if (line.startsWith("id: ")) eventId = line.slice(4).trim();
2172
+ else if (line.startsWith("id:")) eventId = line.slice(3).trim();
2173
+ }
2174
+ if (eventId !== void 0) onEventId(eventId);
2175
+ if (!data) continue;
2176
+ if (eventType === "done") return;
2177
+ let parsed;
2178
+ try {
2179
+ parsed = JSON.parse(data);
2180
+ } catch (err) {
2181
+ throw new StreamParseError(data, err);
2182
+ }
2183
+ if (eventType === "error") {
2184
+ const e = parsed;
2185
+ throw new AtlaSentError(
2186
+ e.message ?? "Stream error from AtlaSent API",
2187
+ {
2188
+ code: e.code ?? "server_error",
2189
+ requestId: e.request_id ?? requestId
2190
+ }
2191
+ );
2192
+ }
2193
+ if (eventType === "decision") {
2194
+ const d = parsed;
2195
+ if (typeof d.permitted !== "boolean" || typeof d.decision_id !== "string") {
2196
+ throw new AtlaSentError(
2197
+ "Malformed decision event from AtlaSent API",
2198
+ {
2199
+ code: "bad_response",
2200
+ requestId
2201
+ }
2202
+ );
2203
+ }
2204
+ const streamDecision = d.permitted ? "allow" : "deny";
2205
+ const isFinal = d.is_final ?? false;
2206
+ yield {
2207
+ type: "decision",
2208
+ decision: streamDecision,
2209
+ decision_canonical: streamDecision,
2210
+ permitId: d.decision_id,
2211
+ reason: d.reason ?? "",
2212
+ auditHash: d.audit_hash ?? "",
2213
+ timestamp: d.timestamp ?? "",
2214
+ isFinal
2215
+ };
2216
+ if (isFinal || d.done === true) return;
2217
+ } else if (eventType === "progress") {
2218
+ const p = parsed;
2219
+ yield {
2220
+ type: "progress",
2221
+ stage: String(p["stage"] ?? ""),
2222
+ ...p
2223
+ };
2224
+ if (p.done === true) return;
2225
+ } else {
2226
+ if (parsed !== null && typeof parsed === "object" && parsed.done === true) {
2227
+ return;
2228
+ }
2229
+ }
2230
+ }
2231
+ }
2232
+ if (buf.trim().length > 0) {
2233
+ throw new StreamParseError(buf);
2234
+ }
2235
+ } finally {
2236
+ reader.releaseLock();
2237
+ }
2238
+ }
431
2239
 
432
2240
  // src/protect.ts
433
2241
  var sharedClient = null;
434
2242
  var overrides = {};
435
2243
  function getClient() {
436
2244
  if (sharedClient) return sharedClient;
437
- const apiKey = overrides.apiKey ?? process.env.ATLASENT_API_KEY;
2245
+ const envApiKey = typeof process !== "undefined" && process.env ? process.env.ATLASENT_API_KEY : void 0;
2246
+ const apiKey = overrides.apiKey ?? envApiKey;
438
2247
  if (!apiKey) {
439
2248
  throw new AtlaSentError(
440
2249
  "AtlaSent is not configured. Set ATLASENT_API_KEY in the environment, or call atlasent.configure({ apiKey }).",
@@ -443,20 +2252,62 @@ function getClient() {
443
2252
  }
444
2253
  const options = { apiKey };
445
2254
  if (overrides.baseUrl !== void 0) options.baseUrl = overrides.baseUrl;
446
- if (overrides.timeoutMs !== void 0) options.timeoutMs = overrides.timeoutMs;
2255
+ if (overrides.timeoutMs !== void 0)
2256
+ options.timeoutMs = overrides.timeoutMs;
447
2257
  if (overrides.fetch !== void 0) options.fetch = overrides.fetch;
2258
+ if (overrides.retryPolicy !== void 0)
2259
+ options.retryPolicy = overrides.retryPolicy;
448
2260
  sharedClient = new AtlaSentClient(options);
449
2261
  return sharedClient;
450
2262
  }
2263
+ var ACTION_TYPE_RE = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
451
2264
  function wireDecisionToDenied(serverDecision) {
452
2265
  const lower = serverDecision.toLowerCase();
453
2266
  if (lower === "hold" || lower === "escalate") return lower;
454
2267
  return "deny";
455
2268
  }
2269
+ function sortKeysDeep(val) {
2270
+ if (Array.isArray(val)) return val.map(sortKeysDeep);
2271
+ if (val !== null && typeof val === "object") {
2272
+ return Object.keys(val).sort().reduce((acc, k) => {
2273
+ acc[k] = sortKeysDeep(val[k]);
2274
+ return acc;
2275
+ }, {});
2276
+ }
2277
+ return val;
2278
+ }
2279
+ async function computeExecutionHash(payload) {
2280
+ const sorted = sortKeysDeep(payload);
2281
+ const canonical = JSON.stringify(sorted);
2282
+ if (typeof globalThis !== "undefined" && globalThis.crypto?.subtle?.digest) {
2283
+ const bytes = new TextEncoder().encode(canonical);
2284
+ const buf = await globalThis.crypto.subtle.digest("SHA-256", bytes);
2285
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
2286
+ }
2287
+ try {
2288
+ const { createHash } = await import(
2289
+ /* @vite-ignore */
2290
+ /* webpackIgnore: true */
2291
+ "crypto"
2292
+ );
2293
+ return createHash("sha256").update(canonical, "utf8").digest("hex");
2294
+ } catch {
2295
+ console.warn(
2296
+ "[atlasent] Could not compute execution_hash: neither crypto.subtle nor node:crypto is available in this runtime."
2297
+ );
2298
+ return "";
2299
+ }
2300
+ }
456
2301
  async function protect(request) {
2302
+ if (!ACTION_TYPE_RE.test(request.action)) {
2303
+ throw new AtlaSentError(
2304
+ `action must be in dot-notation format (e.g. "production.deploy"). Got: ${JSON.stringify(request.action)}`,
2305
+ { code: "bad_request" }
2306
+ );
2307
+ }
457
2308
  const client = getClient();
458
2309
  const evaluation = await client.evaluate(request);
459
- if (evaluation.decision !== "ALLOW") {
2310
+ if (evaluation.decision !== "allow") {
460
2311
  throw new AtlaSentDeniedError({
461
2312
  decision: wireDecisionToDenied(evaluation.decision),
462
2313
  evaluationId: evaluation.permitId,
@@ -464,19 +2315,36 @@ async function protect(request) {
464
2315
  auditHash: evaluation.auditHash
465
2316
  });
466
2317
  }
2318
+ const environment = request.context?.environment;
2319
+ if (!environment) {
2320
+ throw new AtlaSentError(
2321
+ 'context.environment is required. Pass the environment where this action executes (e.g. "production", "staging").',
2322
+ { code: "bad_request" }
2323
+ );
2324
+ }
2325
+ const evaluatePayload = {
2326
+ action_type: request.action,
2327
+ actor_id: request.agent,
2328
+ context: request.context ?? {}
2329
+ };
2330
+ const execution_hash = await computeExecutionHash(evaluatePayload);
467
2331
  const verifyRequest = {
468
2332
  permitId: evaluation.permitId,
469
2333
  agent: request.agent,
470
- action: request.action
2334
+ action: request.action,
2335
+ environment,
2336
+ ...execution_hash ? { execution_hash } : {}
471
2337
  };
472
2338
  if (request.context !== void 0) verifyRequest.context = request.context;
473
2339
  const verification = await client.verifyPermit(verifyRequest);
474
2340
  if (!verification.verified) {
2341
+ const outcome = normalizePermitOutcome(verification.outcome);
475
2342
  throw new AtlaSentDeniedError({
476
2343
  decision: "deny",
477
2344
  evaluationId: evaluation.permitId,
478
2345
  reason: `Permit failed verification (${verification.outcome})`,
479
- auditHash: evaluation.auditHash
2346
+ auditHash: evaluation.auditHash,
2347
+ ...outcome !== void 0 && { outcome }
480
2348
  });
481
2349
  }
482
2350
  return {
@@ -484,7 +2352,8 @@ async function protect(request) {
484
2352
  permitHash: verification.permitHash,
485
2353
  auditHash: evaluation.auditHash,
486
2354
  reason: evaluation.reason,
487
- timestamp: verification.timestamp
2355
+ timestamp: verification.timestamp,
2356
+ permitExpiresAt: verification.expiresAt ?? null
488
2357
  };
489
2358
  }
490
2359