@atlasent/sdk 2.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 +35 -0
- package/dist/hono.cjs +445 -11
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +2 -2
- package/dist/hono.d.ts +2 -2
- package/dist/hono.js +445 -11
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +2185 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1699 -180
- package/dist/index.d.ts +1699 -180
- package/dist/index.js +2159 -13
- package/dist/index.js.map +1 -1
- package/dist/{protect-DiRVfVLq.d.cts → protect-C0t0fP1y.d.cts} +449 -2
- package/dist/{protect-DiRVfVLq.d.ts → protect-C0t0fP1y.d.ts} +449 -2
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -42,6 +42,41 @@ client.deployGate({ agent?, action?, context? })
|
|
|
42
42
|
|
|
43
43
|
`verifyPermit()` confirms a previously-issued permit server-side. Signed/offline permit artifacts never imply deployment authorization by themselves.
|
|
44
44
|
|
|
45
|
+
## Decision replay
|
|
46
|
+
|
|
47
|
+
Re-evaluate a recorded decision against its originally-pinned policy bundle and engine version. **Side-effect-free**: no audit row is written, no permit is minted (ADR-016 `mode: "replay"` sentinel). Useful for compliance review, regression-testing bundle changes, and post-incident investigation.
|
|
48
|
+
|
|
49
|
+
Two surfaces exist; pick the one that matches your call site:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// SDK-canonical (preferred for new code) — wire DECISION_CHANGED is normalized
|
|
53
|
+
// to POLICY_DRIFT; 409 replay_not_eligible returns ENGINE_DRIFT or BUNDLE_MISSING
|
|
54
|
+
// instead of throwing. You can always `switch` on the variance kind.
|
|
55
|
+
const r = await client.replay({ evaluationId: "dec_abc123" });
|
|
56
|
+
switch (r.varianceKind) {
|
|
57
|
+
case "NONE": /* replay agrees */ break;
|
|
58
|
+
case "POLICY_DRIFT": /* same envelope/bundle, different decision */ break;
|
|
59
|
+
case "ENVELOPE_DRIFT": /* recorded envelope no longer hashes */ break;
|
|
60
|
+
case "ENGINE_DRIFT": /* original engine retired beyond archival */ break;
|
|
61
|
+
case "BUNDLE_MISSING": /* original eval had no bundle pinned */ break;
|
|
62
|
+
case "CHAIN_TAMPER": /* audit-chain v5 detector tripped */ break;
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// Raw-wire surface — variance values pass through verbatim
|
|
68
|
+
// (NONE / DECISION_CHANGED / ENVELOPE_DRIFT); 409 throws AtlaSentError
|
|
69
|
+
const result = await client.replayDecision("dec_abc123");
|
|
70
|
+
if (result.variance === "DECISION_CHANGED") {
|
|
71
|
+
console.warn(
|
|
72
|
+
`Decision ${result.decision_id} drifted: ` +
|
|
73
|
+
`${result.original_decision} → ${result.replay_decision}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`/v1/decisions/:id/replay` is alpha per `atlasent-api/docs/STABLE_V2_PROMOTION.md` — wire shapes can shift without a deprecation cycle until it graduates to stable v1.
|
|
79
|
+
|
|
45
80
|
## CI deploy-gate pattern
|
|
46
81
|
|
|
47
82
|
```ts
|
package/dist/hono.cjs
CHANGED
|
@@ -173,9 +173,8 @@ function normalizeEvaluateRequest(input) {
|
|
|
173
173
|
action_type: legacy.action,
|
|
174
174
|
actor_id: legacy.agent
|
|
175
175
|
};
|
|
176
|
-
if (legacy.context !== void 0)
|
|
177
|
-
|
|
178
|
-
}
|
|
176
|
+
if (legacy.context !== void 0) normalized.context = legacy.context;
|
|
177
|
+
if (legacy.explain !== void 0) normalized.explain = legacy.explain;
|
|
179
178
|
return normalized;
|
|
180
179
|
}
|
|
181
180
|
return input;
|
|
@@ -347,6 +346,7 @@ var AtlaSentClient = class {
|
|
|
347
346
|
actor_id: normalized.actor_id,
|
|
348
347
|
context: normalized.context ?? {}
|
|
349
348
|
};
|
|
349
|
+
if (normalized.explain !== void 0) body.explain = normalized.explain;
|
|
350
350
|
const { body: wire, rateLimit } = await this.post(
|
|
351
351
|
"/v1-evaluate",
|
|
352
352
|
body
|
|
@@ -383,9 +383,211 @@ var AtlaSentClient = class {
|
|
|
383
383
|
reason,
|
|
384
384
|
auditHash: wire.audit_hash ?? "",
|
|
385
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 } : {},
|
|
386
466
|
rateLimit
|
|
387
467
|
};
|
|
388
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
|
+
}
|
|
389
591
|
/**
|
|
390
592
|
* Pre-flight evaluation that always returns the constraint trace.
|
|
391
593
|
*
|
|
@@ -452,7 +654,17 @@ var AtlaSentClient = class {
|
|
|
452
654
|
reason,
|
|
453
655
|
auditHash: wire.audit_hash ?? "",
|
|
454
656
|
timestamp: wire.timestamp ?? "",
|
|
455
|
-
rateLimit
|
|
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
|
+
}
|
|
456
668
|
};
|
|
457
669
|
let constraintTrace = null;
|
|
458
670
|
if (wire.constraint_trace !== void 0 && wire.constraint_trace !== null && typeof wire.constraint_trace === "object") {
|
|
@@ -501,6 +713,7 @@ var AtlaSentClient = class {
|
|
|
501
713
|
outcome: wire.outcome ?? "",
|
|
502
714
|
permitHash: wire.permit_hash ?? "",
|
|
503
715
|
timestamp: wire.timestamp ?? "",
|
|
716
|
+
expiresAt: wire.expires_at ?? null,
|
|
504
717
|
rateLimit
|
|
505
718
|
};
|
|
506
719
|
}
|
|
@@ -822,6 +1035,151 @@ var AtlaSentClient = class {
|
|
|
822
1035
|
}
|
|
823
1036
|
return { ...wire, rateLimit };
|
|
824
1037
|
}
|
|
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
|
+
}
|
|
825
1183
|
/**
|
|
826
1184
|
* Open a streaming evaluation session against `POST /v1-evaluate-stream`.
|
|
827
1185
|
*
|
|
@@ -870,6 +1228,8 @@ var AtlaSentClient = class {
|
|
|
870
1228
|
"Content-Type": "application/json",
|
|
871
1229
|
Authorization: `Bearer ${this.apiKey}`,
|
|
872
1230
|
"User-Agent": this.userAgent,
|
|
1231
|
+
// ADR-025: wire-protocol version declared on every request.
|
|
1232
|
+
"X-AtlaSent-Protocol-Version": "1",
|
|
873
1233
|
"X-Request-ID": requestId
|
|
874
1234
|
};
|
|
875
1235
|
if (lastEventId !== void 0) {
|
|
@@ -957,7 +1317,9 @@ var AtlaSentClient = class {
|
|
|
957
1317
|
Accept: "application/json",
|
|
958
1318
|
Authorization: `Bearer ${this.apiKey}`,
|
|
959
1319
|
"User-Agent": this.userAgent,
|
|
960
|
-
"X-Request-ID": requestId
|
|
1320
|
+
"X-Request-ID": requestId,
|
|
1321
|
+
// ADR-025: wire-protocol version declared on every request.
|
|
1322
|
+
"X-AtlaSent-Protocol-Version": "1"
|
|
961
1323
|
};
|
|
962
1324
|
if (method === "POST") headers["Content-Type"] = "application/json";
|
|
963
1325
|
const bodyStr = method === "POST" ? JSON.stringify(body) : void 0;
|
|
@@ -1573,6 +1935,69 @@ var AtlaSentClient = class {
|
|
|
1573
1935
|
);
|
|
1574
1936
|
return body;
|
|
1575
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
|
+
}
|
|
1576
2001
|
};
|
|
1577
2002
|
function parseRateLimitHeaders(headers) {
|
|
1578
2003
|
const rawLimit = headers.get("x-ratelimit-limit");
|
|
@@ -1874,6 +2299,7 @@ function getClient() {
|
|
|
1874
2299
|
sharedClient = new AtlaSentClient(options);
|
|
1875
2300
|
return sharedClient;
|
|
1876
2301
|
}
|
|
2302
|
+
var ACTION_TYPE_RE = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
|
|
1877
2303
|
function wireDecisionToDenied(serverDecision) {
|
|
1878
2304
|
const lower = serverDecision.toLowerCase();
|
|
1879
2305
|
if (lower === "hold" || lower === "escalate") return lower;
|
|
@@ -1912,6 +2338,12 @@ async function computeExecutionHash(payload) {
|
|
|
1912
2338
|
}
|
|
1913
2339
|
}
|
|
1914
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
|
+
}
|
|
1915
2347
|
const client = getClient();
|
|
1916
2348
|
const evaluation = await client.evaluate(request);
|
|
1917
2349
|
if (evaluation.decision !== "allow") {
|
|
@@ -1922,12 +2354,13 @@ async function protect(request) {
|
|
|
1922
2354
|
auditHash: evaluation.auditHash
|
|
1923
2355
|
});
|
|
1924
2356
|
}
|
|
1925
|
-
const environment = request.context?.environment
|
|
1926
|
-
|
|
1927
|
-
|
|
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" }
|
|
1928
2362
|
);
|
|
1929
|
-
|
|
1930
|
-
})();
|
|
2363
|
+
}
|
|
1931
2364
|
const evaluatePayload = {
|
|
1932
2365
|
action_type: request.action,
|
|
1933
2366
|
actor_id: request.agent,
|
|
@@ -1958,7 +2391,8 @@ async function protect(request) {
|
|
|
1958
2391
|
permitHash: verification.permitHash,
|
|
1959
2392
|
auditHash: evaluation.auditHash,
|
|
1960
2393
|
reason: evaluation.reason,
|
|
1961
|
-
timestamp: verification.timestamp
|
|
2394
|
+
timestamp: verification.timestamp,
|
|
2395
|
+
permitExpiresAt: verification.expiresAt ?? null
|
|
1962
2396
|
};
|
|
1963
2397
|
}
|
|
1964
2398
|
|