@vorim/sdk 3.4.3 → 3.6.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/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog — @vorim/sdk
2
+
3
+ ## 3.5.0
4
+
5
+ ### Added — Runtime Control (gate actions before they happen)
6
+
7
+ - **`beforeAction(input, { throwOnDeny })`** — call `POST /v1/runtime/decisions`
8
+ before an agent performs an action and get a typed `RuntimeDecision`
9
+ (`allow` / `deny` / `modify` / `escalate` / `fallback`). Throws
10
+ `VorimDeniedError` on `deny` by default (deny is in the body, not the HTTP
11
+ status, so without this consumers would treat a denial as success).
12
+ - **`waitForDecisionResolution(decisionId, { timeoutMs, pollIntervalMs })`** —
13
+ poll an `escalate` decision until a human operator resolves it. The resolved
14
+ verdict is translated for you (`approved → allow`, `denied → deny`) and the
15
+ raw outcome is exposed as `escalationResolution`. Throws `ESCALATION_TIMEOUT`
16
+ (408) if still pending at timeout.
17
+ - **`VorimDeniedError`** — carries the full `RuntimeDecision` (`.decision`),
18
+ status 403, code `DECISION_DENIED`.
19
+ - **`runtimeFailOpen` constructor option** (default `true`) — on a transport
20
+ failure (network / DNS / timeout / 5xx) `beforeAction` returns a synthetic
21
+ `fallback` decision so a control-plane blip doesn't block the agent. Set
22
+ `false` to fail closed. A reachable server returning `deny` always denies.
23
+ - **`emit({ ..., decision_id })`** — carry the `decisionId` from `beforeAction`
24
+ into the post-action audit event so it links back to the runtime decision
25
+ that authorised it.
26
+ - New exported types: `BeforeActionInput`, `RuntimeDecision`, `DecisionVerdict`.
27
+
28
+ ### Notes
29
+
30
+ - `modify` is **client-cooperative**: the SDK returns the sanitised
31
+ `modifiedPayload`, but your agent must send it. The platform does not sit
32
+ inline. Carry `decisionId` into `emit()` to keep the action auditable.
33
+ - Malformed/verdict-less decision responses fail **closed** to `deny`.
34
+
35
+ Requires an API key with the `runtime:decide` scope and a Growth+ plan.
package/README.md CHANGED
@@ -258,6 +258,72 @@ await vorim.emitBatch([
258
258
  ]);
259
259
  ```
260
260
 
261
+ ### Runtime Control (gate actions before they happen)
262
+
263
+ Ask Vorim whether an action should proceed **before** your agent performs it.
264
+ `beforeAction()` returns a typed decision and, by default, **throws on deny** —
265
+ because a denial is carried in the response body, not the HTTP status, so
266
+ without `throwOnDeny` you'd treat a denial as success.
267
+
268
+ ```typescript
269
+ import { VorimDeniedError } from "@vorim/sdk";
270
+
271
+ try {
272
+ const decision = await vorim.beforeAction({
273
+ agentId: "agid_acme_a1b2c3d4", // always the public agid_* id
274
+ actionType: "tool_call",
275
+ actionTarget: "sendEmail",
276
+ requiredScope: "agent:communicate",
277
+ payload: { to: "customer@example.com", body: "..." },
278
+ });
279
+
280
+ // 'modify' verdicts hand back a sanitised payload (e.g. PII masked).
281
+ const payload = decision.modifiedPayload ?? { to: "customer@example.com" };
282
+
283
+ if (decision.decision === "allow" || decision.decision === "modify") {
284
+ await sendEmail(payload);
285
+ } else if (decision.decision === "escalate") {
286
+ // A human must approve. Poll until resolved (or timeout).
287
+ const resolved = await vorim.waitForDecisionResolution(decision.decisionId);
288
+ if (resolved.decision === "allow") await sendEmail(payload);
289
+ }
290
+
291
+ // Link the post-action audit event back to the decision.
292
+ await vorim.emit({
293
+ agent_id: "agid_acme_a1b2c3d4",
294
+ event_type: "tool_call",
295
+ action: "sendEmail",
296
+ result: "success",
297
+ decision_id: decision.decisionId, // ← correlates audit ↔ decision
298
+ });
299
+ } catch (err) {
300
+ if (err instanceof VorimDeniedError) {
301
+ // err.decision carries the reason and decisionId.
302
+ console.warn("Action denied:", err.decision.reason);
303
+ } else {
304
+ throw err;
305
+ }
306
+ }
307
+ ```
308
+
309
+ **Verdicts:** `allow` · `deny` (throws `VorimDeniedError` by default) ·
310
+ `modify` (use `decision.modifiedPayload`) · `escalate` (poll
311
+ `waitForDecisionResolution`) · `fallback` (engine couldn't decide).
312
+
313
+ > **`modify` is client-cooperative.** Vorim returns the sanitised
314
+ > `modifiedPayload`; your agent must send it in place of the original. The
315
+ > platform does not sit inline and does not currently enforce that you do —
316
+ > carry `decisionId` into the matching `emit()` so the action stays auditable.
317
+
318
+ **Fail-open:** if the decision API is unreachable, `beforeAction()` returns a
319
+ synthetic `fallback` decision so a control-plane blip doesn't block your agent.
320
+ Pass `runtimeFailOpen: false` to the constructor to fail closed instead. A
321
+ reachable server returning `deny` always denies, regardless of this flag.
322
+
323
+ > Requires an API key with the `runtime:decide` scope and a Growth+ plan.
324
+ > `modify` verdicts are produced by policy rules; the rule-authoring API
325
+ > ships in a later release — until then rules are provisioned by Vorim.
326
+
261
327
  ### Trust Verification
262
328
 
263
329
  ```typescript
package/dist/index.cjs CHANGED
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  CANONICAL_TOOL_CATALOGUE_VERSION: () => CANONICAL_TOOL_CATALOGUE_VERSION,
34
+ VorimDeniedError: () => VorimDeniedError,
34
35
  VorimError: () => VorimError,
35
36
  VorimSDK: () => VorimSDK,
36
37
  canonicalPayloadV0: () => canonicalPayloadV0,
@@ -64,10 +65,9 @@ function jcsCanonicalise(value) {
64
65
  return "[" + value.map(jcsCanonicalise).join(",") + "]";
65
66
  }
66
67
  if (typeof value === "object") {
67
- const keys = Object.keys(value).sort();
68
- const parts = keys.map((k) => {
69
- return JSON.stringify(k) + ":" + jcsCanonicalise(value[k]);
70
- });
68
+ const obj = value;
69
+ const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
70
+ const parts = keys.map((k) => JSON.stringify(k) + ":" + jcsCanonicalise(obj[k]));
71
71
  return "{" + parts.join(",") + "}";
72
72
  }
73
73
  throw new Error(`jcsCanonicalise: unsupported value type: ${typeof value}`);
@@ -117,8 +117,11 @@ async function prepareReplayContext(inputs) {
117
117
  }
118
118
 
119
119
  // src/index.ts
120
- var SDK_VERSION = true ? "3.4.3" : "0.0.0";
120
+ var SDK_VERSION = true ? "3.6.0" : "0.0.0";
121
121
  var USER_AGENT = `vorim-sdk/${SDK_VERSION}`;
122
+ function sleep(ms) {
123
+ return new Promise((resolve) => setTimeout(resolve, ms));
124
+ }
122
125
  function canonicalPayloadV0(event) {
123
126
  return [
124
127
  event.event_type,
@@ -140,6 +143,7 @@ var VorimSDK = class _VorimSDK {
140
143
  autoSign;
141
144
  canonicalForm;
142
145
  chainEvents;
146
+ runtimeFailOpen;
143
147
  /**
144
148
  * In-memory keyring mapping agent_id -> PEM-encoded Ed25519 private key.
145
149
  * Populated automatically by register() and registerEphemeral(), or
@@ -185,6 +189,7 @@ var VorimSDK = class _VorimSDK {
185
189
  }
186
190
  }
187
191
  this.chainEvents = config.chainEvents ?? false;
192
+ this.runtimeFailOpen = config.runtimeFailOpen ?? true;
188
193
  }
189
194
  /**
190
195
  * Register a previously-issued agent keypair so this SDK instance can
@@ -281,7 +286,8 @@ var VorimSDK = class _VorimSDK {
281
286
  */
282
287
  async listAgents(params) {
283
288
  const qs = new URLSearchParams(params).toString();
284
- return this.get(`/agents${qs ? "?" + qs : ""}`);
289
+ const env = await this.requestEnvelope("GET", `/agents${qs ? "?" + qs : ""}`);
290
+ return { agents: env.data ?? [], meta: env.meta ?? null };
285
291
  }
286
292
  /**
287
293
  * Update an agent's metadata.
@@ -410,6 +416,149 @@ var VorimSDK = class _VorimSDK {
410
416
  async revokeAgentDelegation(agentId, chainId) {
411
417
  return this.delete(`/agents/${agentId}/delegations/${chainId}`);
412
418
  }
419
+ // ─── Runtime Control (Strategy A) ──────────────────────────────────
420
+ /**
421
+ * Gate an agent action BEFORE it is performed. Calls
422
+ * `POST /v1/runtime/decisions` and returns a typed {@link RuntimeDecision}.
423
+ *
424
+ * By default (`throwOnDeny: true`) a `deny` verdict throws
425
+ * {@link VorimDeniedError} — deny is carried in the response body, not
426
+ * the HTTP status, so without this the caller would treat a denial as
427
+ * success. Pass `{ throwOnDeny: false }` to handle deny yourself.
428
+ *
429
+ * On a `modify` verdict, use `decision.modifiedPayload` in place of your
430
+ * original payload (e.g. a policy rule masked PII). This is
431
+ * client-cooperative: Vorim returns the sanitised payload but does not sit
432
+ * inline and does not enforce that you send it — carry `decisionId` into
433
+ * the matching {@link emit} so the action stays auditable. On `escalate`,
434
+ * poll {@link waitForDecisionResolution} for the human decision.
435
+ *
436
+ * Transport failures (network/DNS/timeout/5xx) respect the constructor's
437
+ * `runtimeFailOpen` flag: when true (default) a synthetic `fallback`
438
+ * decision is returned so a control-plane blip does not block the agent;
439
+ * when false the underlying error is re-thrown.
440
+ *
441
+ * Always pass the public `agid_*` id as `agentId`.
442
+ *
443
+ * @example
444
+ * const d = await vorim.beforeAction({
445
+ * agentId: 'agid_acme_evilbot',
446
+ * actionType: 'tool_call',
447
+ * actionTarget: 'sendEmail',
448
+ * requiredScope: 'agent:communicate',
449
+ * payload: { to: 'customer@example.com' },
450
+ * });
451
+ * if (d.decision === 'allow') await sendEmail(d.modifiedPayload ?? payload);
452
+ */
453
+ async beforeAction(input, options = {}) {
454
+ const throwOnDeny = options.throwOnDeny ?? true;
455
+ const body = {
456
+ agent_id: input.agentId,
457
+ action_type: input.actionType,
458
+ action_target: input.actionTarget,
459
+ payload: input.payload,
460
+ context: input.context,
461
+ required_scope: input.requiredScope,
462
+ idempotency_key: input.idempotencyKey
463
+ };
464
+ let raw;
465
+ try {
466
+ raw = await this.post("/runtime/decisions", body);
467
+ } catch (err) {
468
+ const status = err instanceof VorimError ? err.status : 0;
469
+ const isTransport = status === 0 || status >= 500;
470
+ if (isTransport && this.runtimeFailOpen) {
471
+ return {
472
+ decisionId: "",
473
+ decision: "fallback",
474
+ reason: "Runtime decision API unreachable; client fail-open applied",
475
+ decisionRuleId: null,
476
+ modifiedPayload: null,
477
+ expiresAt: new Date(Date.now() + 6e4).toISOString(),
478
+ latencyMs: 0,
479
+ isFallback: true,
480
+ policyVersion: 0,
481
+ escalationResolution: null
482
+ };
483
+ }
484
+ throw err;
485
+ }
486
+ const decision = this.toRuntimeDecision(raw);
487
+ if (decision.decision === "deny" && throwOnDeny) {
488
+ throw new VorimDeniedError(decision);
489
+ }
490
+ return decision;
491
+ }
492
+ /**
493
+ * Poll a decision until it leaves the `escalate` state (a human
494
+ * approved or denied it) or the timeout elapses. Returns the resolved
495
+ * decision; throws {@link VorimError} `ESCALATION_TIMEOUT` (408) if the
496
+ * decision is still pending when the timeout is reached.
497
+ *
498
+ * Requires an API key with the `runtime:decide` scope (the same scope
499
+ * `beforeAction` uses).
500
+ */
501
+ async waitForDecisionResolution(decisionId, options = {}) {
502
+ const timeoutMs = options.timeoutMs ?? 3e5;
503
+ const pollIntervalMs = options.pollIntervalMs ?? 1e3;
504
+ const start = Date.now();
505
+ do {
506
+ const raw = await this.get(`/runtime/decisions/${decisionId}`);
507
+ if (raw?.decision !== "escalate" || raw?.escalation_resolution) {
508
+ return this.toRuntimeDecision(raw);
509
+ }
510
+ const remaining = timeoutMs - (Date.now() - start);
511
+ if (remaining <= 0) break;
512
+ await sleep(Math.min(pollIntervalMs, remaining));
513
+ } while (Date.now() - start < timeoutMs);
514
+ throw new VorimError(
515
+ 408,
516
+ "ESCALATION_TIMEOUT",
517
+ `Decision ${decisionId} still pending after ${timeoutMs}ms`,
518
+ { decisionId }
519
+ );
520
+ }
521
+ /**
522
+ * Map a snake_case decision row (from POST or GET) to the camelCase
523
+ * {@link RuntimeDecision}. The SDK has no generic camelizer, so the
524
+ * mapping is explicit — and tolerant of either the POST shape
525
+ * (`decision_rule_id`, `latency_ms`) or absent fields on a GET row.
526
+ *
527
+ * Escalation-resolution translation (load-bearing): the server resolves
528
+ * an escalation by setting `escalation_resolution` but does NOT flip the
529
+ * row's `decision` column off `'escalate'`. If we returned that verbatim,
530
+ * a caller's obvious `if (d.decision === 'deny')` check would never fire
531
+ * on a human DENIAL — a silent fail-open. So when a resolution is present
532
+ * we translate the verdict: approved → 'allow', denied → 'deny'. The raw
533
+ * resolution is also surfaced as `escalationResolution` for callers that
534
+ * want it explicitly.
535
+ *
536
+ * A missing verdict (malformed/truncated response) fails CLOSED to 'deny'
537
+ * rather than 'fallback', so the deny check fires on garbled input.
538
+ */
539
+ toRuntimeDecision(raw) {
540
+ const resolution = raw?.escalation_resolution === "approved" ? "approved" : raw?.escalation_resolution === "denied" ? "denied" : null;
541
+ const hasVerdict = typeof raw?.decision === "string";
542
+ let decision;
543
+ if (raw?.decision === "escalate" && resolution === "approved") decision = "allow";
544
+ else if (raw?.decision === "escalate" && resolution === "denied") decision = "deny";
545
+ else if (hasVerdict) decision = raw.decision;
546
+ else decision = "deny";
547
+ return {
548
+ decisionId: raw?.decision_id ?? "",
549
+ decision,
550
+ reason: raw?.reason ?? (hasVerdict ? "" : "Malformed decision response (no verdict); failing closed"),
551
+ decisionRuleId: raw?.decision_rule_id ?? null,
552
+ modifiedPayload: raw?.modified_payload ?? null,
553
+ // Avoid '' (Date.parse('') === NaN). A verdict-less row gets an epoch
554
+ // timestamp so any staleness check treats it as already expired.
555
+ expiresAt: raw?.expires_at ?? (hasVerdict ? "" : (/* @__PURE__ */ new Date(0)).toISOString()),
556
+ latencyMs: raw?.latency_ms ?? 0,
557
+ isFallback: raw?.is_fallback ?? !hasVerdict,
558
+ policyVersion: raw?.policy_version ?? 0,
559
+ escalationResolution: resolution
560
+ };
561
+ }
413
562
  // ─── Audit ────────────────────────────────────────────────────────
414
563
  /**
415
564
  * Emit an audit event for an agent action.
@@ -686,7 +835,12 @@ var VorimSDK = class _VorimSDK {
686
835
  async delete(path) {
687
836
  return this.request("DELETE", path);
688
837
  }
689
- async request(method, path, body) {
838
+ /** Like request() but returns the FULL { data, meta, ... } envelope
839
+ * instead of unwrapping to `data`. Used where meta (pagination) matters. */
840
+ async requestEnvelope(method, path, body) {
841
+ return this.request(method, path, body, false);
842
+ }
843
+ async request(method, path, body, unwrap = true) {
690
844
  const controller = new AbortController();
691
845
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
692
846
  try {
@@ -710,7 +864,7 @@ var VorimSDK = class _VorimSDK {
710
864
  );
711
865
  }
712
866
  const json = await response.json();
713
- return json.data;
867
+ return unwrap ? json.data : json;
714
868
  } finally {
715
869
  clearTimeout(timeoutId);
716
870
  }
@@ -745,12 +899,21 @@ var VorimError = class extends Error {
745
899
  code;
746
900
  details;
747
901
  };
902
+ var VorimDeniedError = class extends VorimError {
903
+ constructor(decision) {
904
+ super(403, "DECISION_DENIED", decision.reason, { decisionId: decision.decisionId });
905
+ this.decision = decision;
906
+ this.name = "VorimDeniedError";
907
+ }
908
+ decision;
909
+ };
748
910
  function createVorim(config) {
749
911
  return new VorimSDK(config);
750
912
  }
751
913
  // Annotate the CommonJS export names for ESM import in node:
752
914
  0 && (module.exports = {
753
915
  CANONICAL_TOOL_CATALOGUE_VERSION,
916
+ VorimDeniedError,
754
917
  VorimError,
755
918
  VorimSDK,
756
919
  canonicalPayloadV0,