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