@armoriq/sdk-dev 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_build_env.d.ts.map +1 -1
- package/dist/_build_env.js +1 -1
- package/dist/_build_env.js.map +1 -1
- package/dist/cli/commands/auth.d.ts.map +1 -1
- package/dist/cli/commands/auth.js +4 -1
- package/dist/cli/commands/auth.js.map +1 -1
- package/dist/client.d.ts +35 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +77 -1
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +19 -1
- package/dist/config.js.map +1 -1
- package/dist/crypto_verify.d.ts +3 -0
- package/dist/crypto_verify.d.ts.map +1 -1
- package/dist/crypto_verify.js +77 -0
- package/dist/crypto_verify.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -3
- package/dist/index.js.map +1 -1
- package/dist/integrations/microsoft_copilot.d.ts +84 -0
- package/dist/integrations/microsoft_copilot.d.ts.map +1 -0
- package/dist/integrations/microsoft_copilot.js +126 -0
- package/dist/integrations/microsoft_copilot.js.map +1 -0
- package/dist/models.d.ts +17 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/observability/handle.d.ts +41 -0
- package/dist/observability/handle.d.ts.map +1 -0
- package/dist/observability/handle.js +54 -0
- package/dist/observability/handle.js.map +1 -0
- package/dist/observability/index.d.ts +15 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +39 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/model-prices.d.ts +39 -0
- package/dist/observability/model-prices.d.ts.map +1 -0
- package/dist/observability/model-prices.js +127 -0
- package/dist/observability/model-prices.js.map +1 -0
- package/dist/observability/recorder.d.ts +245 -0
- package/dist/observability/recorder.d.ts.map +1 -0
- package/dist/observability/recorder.js +475 -0
- package/dist/observability/recorder.js.map +1 -0
- package/dist/observability/schema.d.ts +163 -0
- package/dist/observability/schema.d.ts.map +1 -0
- package/dist/observability/schema.js +291 -0
- package/dist/observability/schema.js.map +1 -0
- package/dist/observability/shipper.d.ts +84 -0
- package/dist/observability/shipper.d.ts.map +1 -0
- package/dist/observability/shipper.js +206 -0
- package/dist/observability/shipper.js.map +1 -0
- package/dist/observability/trace-summary.d.ts +34 -0
- package/dist/observability/trace-summary.d.ts.map +1 -0
- package/dist/observability/trace-summary.js +108 -0
- package/dist/observability/trace-summary.js.map +1 -0
- package/dist/session.d.ts +148 -3
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +1035 -195
- package/dist/session.js.map +1 -1
- package/dist/token_usage.d.ts +18 -1
- package/dist/token_usage.d.ts.map +1 -1
- package/dist/token_usage.js +112 -2
- package/dist/token_usage.js.map +1 -1
- package/package.json +3 -2
- package/dist/integrations/google_adk_mcp_toolset.d.ts +0 -64
- package/dist/integrations/google_adk_mcp_toolset.d.ts.map +0 -1
- package/dist/integrations/google_adk_mcp_toolset.js +0 -91
- package/dist/integrations/google_adk_mcp_toolset.js.map +0 -1
package/dist/session.js
CHANGED
|
@@ -17,10 +17,13 @@
|
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
18
|
exports.ArmorIQSession = void 0;
|
|
19
19
|
exports.normalizeDefaultAction = normalizeDefaultAction;
|
|
20
|
+
const crypto_1 = require("crypto");
|
|
21
|
+
const config_1 = require("./config");
|
|
20
22
|
const crypto_verify_1 = require("./crypto_verify");
|
|
21
23
|
const exceptions_1 = require("./exceptions");
|
|
22
24
|
const models_1 = require("./models");
|
|
23
25
|
const plan_builder_1 = require("./plan_builder");
|
|
26
|
+
const observability_1 = require("./observability");
|
|
24
27
|
/**
|
|
25
28
|
* Normalize a policy-authoring default-enforcement-action value into the
|
|
26
29
|
* SDK-facing terminal enum. Mirrors the backend's enforce-boundary normalization
|
|
@@ -42,6 +45,63 @@ function normalizeDefaultAction(value) {
|
|
|
42
45
|
return 'block';
|
|
43
46
|
}
|
|
44
47
|
}
|
|
48
|
+
const TRUNCATE_BYTES = 4 * 1024;
|
|
49
|
+
function truncateForSpan(value) {
|
|
50
|
+
if (value === undefined || value === null)
|
|
51
|
+
return value;
|
|
52
|
+
let json;
|
|
53
|
+
try {
|
|
54
|
+
json = typeof value === 'string' ? value : JSON.stringify(value);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
json = String(value);
|
|
58
|
+
}
|
|
59
|
+
const bytes = Buffer.byteLength(json, 'utf8');
|
|
60
|
+
if (bytes <= TRUNCATE_BYTES)
|
|
61
|
+
return value;
|
|
62
|
+
const sliced = json.slice(0, TRUNCATE_BYTES);
|
|
63
|
+
return `${sliced}…(truncated, original ${bytes} bytes)`;
|
|
64
|
+
}
|
|
65
|
+
function safeObs(fn) {
|
|
66
|
+
try {
|
|
67
|
+
return fn();
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
71
|
+
console.warn(`[armoriq] observability emit failed (continuing): ${msg}`);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function makeSpanRecord(ctx, name, attributes, status = 'ok', durationMs = null, parentSpanId) {
|
|
76
|
+
const now = new Date().toISOString();
|
|
77
|
+
return {
|
|
78
|
+
id: (0, crypto_1.randomUUID)(),
|
|
79
|
+
parentSpanId: parentSpanId ?? null,
|
|
80
|
+
sessionId: ctx.sessionId,
|
|
81
|
+
kind: 'span',
|
|
82
|
+
name,
|
|
83
|
+
startTime: now,
|
|
84
|
+
endTime: now,
|
|
85
|
+
durationMs,
|
|
86
|
+
status,
|
|
87
|
+
attributes,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function makeEventRecord(ctx, name, attributes, parentSpanId) {
|
|
91
|
+
const now = new Date().toISOString();
|
|
92
|
+
return {
|
|
93
|
+
id: (0, crypto_1.randomUUID)(),
|
|
94
|
+
parentSpanId: parentSpanId ?? null,
|
|
95
|
+
sessionId: ctx.sessionId,
|
|
96
|
+
kind: 'event',
|
|
97
|
+
name,
|
|
98
|
+
startTime: now,
|
|
99
|
+
endTime: now,
|
|
100
|
+
durationMs: 0,
|
|
101
|
+
status: 'ok',
|
|
102
|
+
attributes,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
45
105
|
class ArmorIQSession {
|
|
46
106
|
userEmail;
|
|
47
107
|
client;
|
|
@@ -64,6 +124,34 @@ class ArmorIQSession {
|
|
|
64
124
|
currentToken;
|
|
65
125
|
mcpByAction = new Map();
|
|
66
126
|
declaredTools = new Set();
|
|
127
|
+
obs = null;
|
|
128
|
+
/**
|
|
129
|
+
* This session's one stable observability session id — stamped onto every
|
|
130
|
+
* trace this session emits (via the recorder's `defaultSessionId`, see
|
|
131
|
+
* `ObservabilityConfig.sessionId`). Always a valid UUID; the backend's
|
|
132
|
+
* `obs_sessions.id` column is UUID-typed, so a non-UUID id (e.g. the
|
|
133
|
+
* legacy `contextId` default of the literal string `'default'`) can never
|
|
134
|
+
* form a session and traces stamped with it can never be grouped.
|
|
135
|
+
*
|
|
136
|
+
* Resolution order:
|
|
137
|
+
* 1. `opts.sessionId` if it's a valid UUID.
|
|
138
|
+
* 2. `opts.contextId` if it's a valid UUID.
|
|
139
|
+
* 3. A freshly minted `crypto.randomUUID()` (default — no arg needed).
|
|
140
|
+
*/
|
|
141
|
+
sessionId;
|
|
142
|
+
/**
|
|
143
|
+
* Model A (trace-per-plan): the ONE active observability trace for the
|
|
144
|
+
* current plan. Every chokepoint (enforce/check/report/dispatch/...)
|
|
145
|
+
* records a container span under this trace via `ensurePlanTrace()`
|
|
146
|
+
* instead of opening its own trace — matching Langfuse/OTel's
|
|
147
|
+
* Session → Trace → Observation hierarchy (one IAP plan = one trace).
|
|
148
|
+
*
|
|
149
|
+
* Lifecycle: opened by `ensurePlanTrace()` (called from `startPlan()` or
|
|
150
|
+
* lazily by any chokepoint invoked before `startPlan()` — back-compat);
|
|
151
|
+
* ended by `endPlanTrace()`, called on the NEXT `startPlan()`, on
|
|
152
|
+
* `close()`/`dispose()`, and on `flushObservability()`.
|
|
153
|
+
*/
|
|
154
|
+
activePlanTrace = null;
|
|
67
155
|
constructor(client, opts) {
|
|
68
156
|
this.client = client;
|
|
69
157
|
const o = opts ?? {};
|
|
@@ -76,12 +164,298 @@ class ArmorIQSession {
|
|
|
76
164
|
const granEnv = (typeof process !== 'undefined' && process.env?.ARMORIQ_REANCHOR_GRANULARITY) || '';
|
|
77
165
|
this.reanchorGranularity =
|
|
78
166
|
o.reanchorGranularity ?? (granEnv === 'deferred' ? 'deferred' : 'eager');
|
|
167
|
+
this.sessionId = (0, observability_1.isValidUuid)(o.sessionId)
|
|
168
|
+
? o.sessionId
|
|
169
|
+
: (0, observability_1.isValidUuid)(o.contextId)
|
|
170
|
+
? o.contextId
|
|
171
|
+
: (0, crypto_1.randomUUID)();
|
|
172
|
+
const obsCfg = o.observability;
|
|
173
|
+
if (obsCfg?.enabled === false) {
|
|
174
|
+
this.obs = null;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const internals = this.client._sessionInternals();
|
|
178
|
+
const endpoint = obsCfg?.endpoint ?? internals.backendEndpoint;
|
|
179
|
+
const apiKey = obsCfg?.apiKey ?? internals.apiKey;
|
|
180
|
+
const product = obsCfg?.product ?? config_1.DEFAULT_OBSERVABILITY_PRODUCT;
|
|
181
|
+
// Reuse the session's configured httpClient (consumer interceptors,
|
|
182
|
+
// test mocks) unless the caller explicitly overrides it via
|
|
183
|
+
// `observability.httpClient`. Without this, the shipper falls back to
|
|
184
|
+
// its own bare `axios.create(...)` and silently bypasses whatever
|
|
185
|
+
// transport the rest of the SDK uses — including test mocks, which
|
|
186
|
+
// then fire real network POSTs from the shipper's flush interval.
|
|
187
|
+
const httpClient = obsCfg?.httpClient ?? internals.httpClient;
|
|
188
|
+
// `internals.userId`/`internals.agentId` default to `''` (see the
|
|
189
|
+
// `ArmorIQClient` constructor) when the caller configured neither an
|
|
190
|
+
// option nor the corresponding env var — normalize that to `null` so
|
|
191
|
+
// an unconfigured identity reads as genuinely absent on the trace,
|
|
192
|
+
// rather than shipping an empty string.
|
|
193
|
+
const nonEmpty = (v) => (v ? v : null);
|
|
194
|
+
// `internals.userId` is `client.userEmailOverride || client.userId`
|
|
195
|
+
// (client.ts) — an arbitrary identity string (often an email address,
|
|
196
|
+
// or the multi-user placeholder `'__sdk_multiuser__'`), NOT guaranteed
|
|
197
|
+
// to be a UUID. The observability trace's `userId` column/wire schema
|
|
198
|
+
// IS UUID-typed (`z.uuid()` on the backend) — forwarding a non-UUID
|
|
199
|
+
// value 400s the entire ingest batch at the shipper. Validate before
|
|
200
|
+
// forwarding; a non-UUID userId is dropped to `null` rather than
|
|
201
|
+
// breaking ingestion (`agentId` has no such constraint — see below).
|
|
202
|
+
const validUserId = (v) => (0, observability_1.isValidUuid)(v) ? v : null;
|
|
203
|
+
const merged = {
|
|
204
|
+
...(0, config_1.defaultObservabilityConfig)(),
|
|
205
|
+
...(obsCfg ?? {}),
|
|
206
|
+
enabled: true,
|
|
207
|
+
endpoint,
|
|
208
|
+
apiKey,
|
|
209
|
+
product,
|
|
210
|
+
httpClient,
|
|
211
|
+
// The session's one stable UUID — applied to every trace this
|
|
212
|
+
// recorder starts unless a chokepoint explicitly overrides it (none
|
|
213
|
+
// do; see the `startTrace(...)` call sites below, all of which now
|
|
214
|
+
// pass `this.sessionId` directly for clarity/back-compat).
|
|
215
|
+
sessionId: obsCfg?.sessionId ?? this.sessionId,
|
|
216
|
+
// Denormalize the client's configured user/agent identity onto every
|
|
217
|
+
// trace this recorder starts, unless the caller explicitly overrides
|
|
218
|
+
// via `observability.userId`/`observability.agentId`. `internals`
|
|
219
|
+
// already carries both (see `ArmorIQClient._sessionInternals()`);
|
|
220
|
+
// previously neither was forwarded here, so every SDK-emitted trace
|
|
221
|
+
// shipped with `agentId: null` even when the client was constructed
|
|
222
|
+
// with a real agent id. `agentId` is a free-form string on the wire
|
|
223
|
+
// (backend column is `text`, not a UUID FK) — no UUID validation
|
|
224
|
+
// required, unlike `sessionId`/`userId`.
|
|
225
|
+
userId: obsCfg?.userId ?? validUserId(internals.userId),
|
|
226
|
+
agentId: obsCfg?.agentId ?? nonEmpty(internals.agentId),
|
|
227
|
+
};
|
|
228
|
+
this.obs = new observability_1.ObservabilityRecorder(merged);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The session's `ObservabilityRecorder`, or `null` when observability is
|
|
233
|
+
* disabled for this session. Pass this explicitly to
|
|
234
|
+
* `summarizeTranscriptUsage(path, recorder, ctx)` / `client.captureTranscriptTokens({
|
|
235
|
+
* ..., observabilityRecorder, planTraceCtx })` so transcript-derived generation spans are
|
|
236
|
+
* attributed to the correct session — the SDK no longer keeps a
|
|
237
|
+
* process-global "active recorder" (see #4: concurrent sessions would
|
|
238
|
+
* otherwise misattribute usage to whichever session constructed last).
|
|
239
|
+
*/
|
|
240
|
+
get observabilityRecorder() {
|
|
241
|
+
return this.obs;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Model A (Task 4): the session's active plan trace context, or `null` if
|
|
245
|
+
* none is open yet (lazily opens on first use — same as every chokepoint).
|
|
246
|
+
* Pass this as `summarizeTranscriptUsage(path, session.observabilityRecorder,
|
|
247
|
+
* session.activePlanTraceContext)` (or via
|
|
248
|
+
* `client.captureTranscriptTokens({ ..., planTraceCtx: session.activePlanTraceContext })`)
|
|
249
|
+
* so transcript-derived generation spans nest under the SAME plan trace as
|
|
250
|
+
* every other chokepoint this session recorded, instead of opening a
|
|
251
|
+
* standalone `llm.usage.report` trace.
|
|
252
|
+
*/
|
|
253
|
+
get activePlanTraceContext() {
|
|
254
|
+
return this.obs ? this.ensurePlanTrace() : null;
|
|
255
|
+
}
|
|
256
|
+
// ─── Model A: active-plan-trace lifecycle ──────────────────────
|
|
257
|
+
/**
|
|
258
|
+
* Return the session's active plan trace, lazily opening one if none is
|
|
259
|
+
* active. This is the ONE trace every chokepoint's container span nests
|
|
260
|
+
* under (Model A: trace-per-plan). Returns `null` when observability is
|
|
261
|
+
* disabled — every call site already treats a null ctx as "skip emit".
|
|
262
|
+
*
|
|
263
|
+
* Back-compat (plan §"Global Constraints"): a chokepoint invoked with no
|
|
264
|
+
* prior `startPlan()` (e.g. a standalone `enforceLocal()`/`report()` call)
|
|
265
|
+
* still needs to emit — so this opens a plan trace lazily on first use
|
|
266
|
+
* rather than requiring `startPlan()` to have run first.
|
|
267
|
+
*/
|
|
268
|
+
ensurePlanTrace(name = 'iap.plan', attrs = {}) {
|
|
269
|
+
if (!this.obs)
|
|
270
|
+
return null;
|
|
271
|
+
if (this.activePlanTrace)
|
|
272
|
+
return this.activePlanTrace;
|
|
273
|
+
const ctx = safeObs(() => (0, observability_1.startTrace)(this.obs, name, attrs, this.sessionId)) ?? null;
|
|
274
|
+
this.activePlanTrace = ctx;
|
|
275
|
+
return ctx;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* End the active plan trace (if any) and clear it. Called on the NEXT
|
|
279
|
+
* `startPlan()` (closing the PRIOR plan before opening a new one),
|
|
280
|
+
* `close()`/`dispose()`, and `flushObservability()` — so the in-flight
|
|
281
|
+
* plan trace always ships before the process exits or a new plan begins.
|
|
282
|
+
*/
|
|
283
|
+
endPlanTrace(status = 'ok') {
|
|
284
|
+
if (!this.obs || !this.activePlanTrace)
|
|
285
|
+
return;
|
|
286
|
+
const ctx = this.activePlanTrace;
|
|
287
|
+
this.activePlanTrace = null;
|
|
288
|
+
safeObs(() => (0, observability_1.endTrace)(this.obs, ctx, { status }));
|
|
79
289
|
}
|
|
80
290
|
// ─── Plan capture ──────────────────────────────────────────────
|
|
291
|
+
async reanchorCall(intentToken, plan, reason) {
|
|
292
|
+
const startNs = this.obs ? performance.now() : 0;
|
|
293
|
+
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
294
|
+
const spanId = ctx && this.obs
|
|
295
|
+
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
296
|
+
name: 'iap.reanchor',
|
|
297
|
+
attributes: { reason, planStepCount: plan?.steps?.length ?? null },
|
|
298
|
+
})) ?? undefined
|
|
299
|
+
: undefined;
|
|
300
|
+
let caught;
|
|
301
|
+
let trustId;
|
|
302
|
+
try {
|
|
303
|
+
const result = await this.client.reanchor(intentToken, plan, reason);
|
|
304
|
+
trustId = result?.trustId;
|
|
305
|
+
if (ctx) {
|
|
306
|
+
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.reanchor.delta', {
|
|
307
|
+
kind: 'event',
|
|
308
|
+
message: 'delta recorded',
|
|
309
|
+
level: 'info',
|
|
310
|
+
trustId: trustId ?? null,
|
|
311
|
+
}, spanId)));
|
|
312
|
+
}
|
|
313
|
+
return { trustId };
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
caught = err;
|
|
317
|
+
if (ctx) {
|
|
318
|
+
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.reanchor.delta_failed', {
|
|
319
|
+
kind: 'event',
|
|
320
|
+
message: `delta failed: ${err.message}`,
|
|
321
|
+
level: 'error',
|
|
322
|
+
}, spanId)));
|
|
323
|
+
}
|
|
324
|
+
throw err;
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
if (ctx) {
|
|
328
|
+
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
329
|
+
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.reanchor.http', {
|
|
330
|
+
kind: 'span',
|
|
331
|
+
trustId: trustId ?? null,
|
|
332
|
+
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
333
|
+
}, caught ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
334
|
+
if (spanId) {
|
|
335
|
+
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
336
|
+
status: caught ? 'error' : 'ok',
|
|
337
|
+
durationMs: computedDuration,
|
|
338
|
+
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
async getIntentTokenCall(planCapture, validitySeconds) {
|
|
345
|
+
const startNs = this.obs ? performance.now() : 0;
|
|
346
|
+
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
347
|
+
const spanId = ctx && this.obs
|
|
348
|
+
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
349
|
+
name: 'iap.intent_token.mint',
|
|
350
|
+
attributes: { planStepCount: planCapture?.plan?.steps?.length ?? null },
|
|
351
|
+
})) ?? undefined
|
|
352
|
+
: undefined;
|
|
353
|
+
let caught;
|
|
354
|
+
let token;
|
|
355
|
+
try {
|
|
356
|
+
token = await this.client.getIntentToken(planCapture, undefined, validitySeconds);
|
|
357
|
+
if (ctx) {
|
|
358
|
+
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.intent_token.minted', {
|
|
359
|
+
kind: 'event',
|
|
360
|
+
message: 'new intent token minted',
|
|
361
|
+
level: 'info',
|
|
362
|
+
tokenId: token?.tokenId ?? null,
|
|
363
|
+
planId: token?.planId ?? null,
|
|
364
|
+
}, spanId)));
|
|
365
|
+
}
|
|
366
|
+
return token;
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
caught = err;
|
|
370
|
+
throw err;
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
if (ctx) {
|
|
374
|
+
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
375
|
+
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.intent_token.mint.http', {
|
|
376
|
+
kind: 'span',
|
|
377
|
+
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
378
|
+
}, caught ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
379
|
+
if (spanId) {
|
|
380
|
+
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
381
|
+
status: caught ? 'error' : 'ok',
|
|
382
|
+
durationMs: computedDuration,
|
|
383
|
+
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
384
|
+
}));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
81
389
|
async startPlan(toolCalls, goal) {
|
|
390
|
+
const startNs = this.obs ? performance.now() : 0;
|
|
391
|
+
// Model A: close the PRIOR plan's trace (if any) before opening the new
|
|
392
|
+
// one — one IAP plan = one trace, so a new startPlan() is a plan
|
|
393
|
+
// boundary. Then open the new active plan trace and record a
|
|
394
|
+
// container span (`iap.plan.start`) for this call's own work, nested
|
|
395
|
+
// under it exactly like every other chokepoint's container span.
|
|
396
|
+
if (this.obs)
|
|
397
|
+
safeObs(() => this.endPlanTrace('ok'));
|
|
398
|
+
const ctx = this.obs
|
|
399
|
+
? this.ensurePlanTrace('iap.plan', {
|
|
400
|
+
toolCount: toolCalls?.length ?? 0,
|
|
401
|
+
goal: goal ?? null,
|
|
402
|
+
mode: this.mode,
|
|
403
|
+
})
|
|
404
|
+
: null;
|
|
405
|
+
const spanId = ctx && this.obs
|
|
406
|
+
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
407
|
+
name: 'iap.plan.start',
|
|
408
|
+
attributes: {
|
|
409
|
+
toolCount: toolCalls?.length ?? 0,
|
|
410
|
+
goal: goal ?? null,
|
|
411
|
+
mode: this.mode,
|
|
412
|
+
},
|
|
413
|
+
})) ?? undefined
|
|
414
|
+
: undefined;
|
|
415
|
+
let caught;
|
|
416
|
+
let planTraceStatus = 'ok';
|
|
417
|
+
let planTraceError;
|
|
418
|
+
try {
|
|
419
|
+
const result = await this._startPlanImpl(toolCalls, goal, ctx);
|
|
420
|
+
if (ctx) {
|
|
421
|
+
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.plan.grew', {
|
|
422
|
+
kind: 'event',
|
|
423
|
+
message: `plan captured: ${toolCalls?.length ?? 0} tool(s)`,
|
|
424
|
+
level: 'info',
|
|
425
|
+
toolCount: toolCalls?.length ?? 0,
|
|
426
|
+
})));
|
|
427
|
+
}
|
|
428
|
+
return result;
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
caught = err;
|
|
432
|
+
planTraceStatus = 'error';
|
|
433
|
+
planTraceError = err.message;
|
|
434
|
+
throw err;
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
if (ctx && spanId) {
|
|
438
|
+
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
439
|
+
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
440
|
+
status: planTraceStatus === 'error' ? 'error' : 'ok',
|
|
441
|
+
durationMs: computedDuration,
|
|
442
|
+
errorMessage: planTraceError,
|
|
443
|
+
}));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
async _startPlanImpl(toolCalls, goal, _ctx) {
|
|
82
448
|
if (!toolCalls || toolCalls.length === 0) {
|
|
83
449
|
throw new Error('startPlan called with no tool calls.');
|
|
84
450
|
}
|
|
451
|
+
// #519 — in local mode, warm the trusted IAP public key (config/env, else
|
|
452
|
+
// one-shot fetch) so the synchronous enforceLocal() can verify the EdDSA
|
|
453
|
+
// intent-token JWT. Best-effort: a failure here just means enforceLocal
|
|
454
|
+
// fails closed later. Skipped in proxy/sdk mode (the proxy/backend verifies)
|
|
455
|
+
// to avoid an unnecessary network call for those consumers.
|
|
456
|
+
if (this.mode === 'local') {
|
|
457
|
+
await this.client.ensureIapPublicKey();
|
|
458
|
+
}
|
|
85
459
|
const h = (0, plan_builder_1.hashToolCalls)(toolCalls);
|
|
86
460
|
if (this.currentToken && this.currentPlanHash === h) {
|
|
87
461
|
return this.currentToken;
|
|
@@ -121,7 +495,7 @@ class ArmorIQSession {
|
|
|
121
495
|
// For autoReanchor (pragmatic v2) in deferred mode: still need to
|
|
122
496
|
// re-mint because step_proofs are baked at issuance. Skip the
|
|
123
497
|
// delta call here; flushReanchor() catches up later.
|
|
124
|
-
const tokenA = await this.
|
|
498
|
+
const tokenA = await this.getIntentTokenCall(planCapture, this.validitySeconds);
|
|
125
499
|
this.currentPlanHash = h;
|
|
126
500
|
this.currentToken = tokenA;
|
|
127
501
|
this.stepIndex = 0;
|
|
@@ -134,7 +508,7 @@ class ArmorIQSession {
|
|
|
134
508
|
// preserved across the chain (paper's actual C3).
|
|
135
509
|
if (this.trueReanchor && this.currentToken) {
|
|
136
510
|
try {
|
|
137
|
-
await this.
|
|
511
|
+
await this.reanchorCall(this.currentToken, plan, 'plan grew (true-reanchor)');
|
|
138
512
|
// Update local state without re-minting. The JWT stays the same;
|
|
139
513
|
// only currentPlanHash advances so subsequent enforceLocal /
|
|
140
514
|
// dispatch calls see the new plan.
|
|
@@ -156,47 +530,97 @@ class ArmorIQSession {
|
|
|
156
530
|
// pins to the JWT's plan_hash and step_proofs are baked at issuance.
|
|
157
531
|
if (this.autoReanchor && this.currentToken) {
|
|
158
532
|
try {
|
|
159
|
-
await this.
|
|
533
|
+
await this.reanchorCall(this.currentToken, plan, 'plan grew mid-session');
|
|
160
534
|
}
|
|
161
535
|
catch (err) {
|
|
162
536
|
const msg = err instanceof Error ? err.message : String(err);
|
|
163
537
|
console.warn(`[armoriq] auto-reanchor delta failed (continuing): ${msg}`);
|
|
164
538
|
}
|
|
165
539
|
}
|
|
166
|
-
const token = await this.
|
|
540
|
+
const token = await this.getIntentTokenCall(planCapture, this.validitySeconds);
|
|
167
541
|
this.currentPlanHash = h;
|
|
168
542
|
this.currentToken = token;
|
|
169
543
|
this.stepIndex = 0;
|
|
170
544
|
return token;
|
|
171
545
|
}
|
|
172
546
|
// ─── Policy enforcement ────────────────────────────────────────
|
|
173
|
-
|
|
547
|
+
/**
|
|
548
|
+
* `parentContainerSpanId` is an internal-only hook (not part of the public
|
|
549
|
+
* signature contract other SDK consumers rely on — it's simply an extra
|
|
550
|
+
* optional trailing arg) so `check()` can nest this call's container span
|
|
551
|
+
* under `iap.check`'s own container span instead of both sitting as
|
|
552
|
+
* siblings directly under the plan trace (Task 3 risk: "check → enforce
|
|
553
|
+
* double-open" — `iap.check`'s span wraps the dispatched enforce span,
|
|
554
|
+
* it never opens a second redundant plan trace or duplicate container).
|
|
555
|
+
*/
|
|
556
|
+
enforceLocal(toolName, toolArgs, parentContainerSpanId) {
|
|
557
|
+
const startNs = this.obs ? performance.now() : 0;
|
|
558
|
+
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
559
|
+
const spanId = ctx && this.obs
|
|
560
|
+
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
561
|
+
name: 'iap.enforce.local',
|
|
562
|
+
attributes: { toolName, toolArgs: truncateForSpan(toolArgs) },
|
|
563
|
+
parentSpanId: parentContainerSpanId ?? null,
|
|
564
|
+
})) ?? undefined
|
|
565
|
+
: undefined;
|
|
566
|
+
let caught;
|
|
567
|
+
try {
|
|
568
|
+
return this._enforceLocalImpl(toolName, toolArgs, ctx, spanId);
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
caught = err;
|
|
572
|
+
throw err;
|
|
573
|
+
}
|
|
574
|
+
finally {
|
|
575
|
+
if (ctx && spanId) {
|
|
576
|
+
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
577
|
+
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
578
|
+
status: caught ? 'error' : 'ok',
|
|
579
|
+
durationMs: computedDuration,
|
|
580
|
+
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
581
|
+
}));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
_enforceLocalImpl(toolName, toolArgs, ctx, spanId) {
|
|
174
586
|
if (!this.currentToken) {
|
|
175
|
-
|
|
587
|
+
const r = {
|
|
176
588
|
allowed: false,
|
|
177
589
|
action: 'block',
|
|
178
590
|
reason: 'No intent token — call startPlan() first',
|
|
179
591
|
};
|
|
592
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
|
|
593
|
+
return r;
|
|
180
594
|
}
|
|
181
595
|
if (models_1.IntentToken.isExpired(this.currentToken)) {
|
|
182
|
-
|
|
596
|
+
const r = { allowed: false, action: 'block', reason: 'token-expired' };
|
|
597
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
|
|
598
|
+
return r;
|
|
183
599
|
}
|
|
184
600
|
// The local decision is read from the token's policy fields, so the token
|
|
185
601
|
// must be cryptographically authentic first - otherwise a forged token
|
|
186
|
-
// could assert an arbitrary allowlist.
|
|
187
|
-
|
|
188
|
-
|
|
602
|
+
// could assert an arbitrary allowlist. #519: verify the backend's EdDSA
|
|
603
|
+
// (Ed25519) JWT against the TRUSTED public key (option/env, or fetched from
|
|
604
|
+
// GET /iap/public-key and warmed in startPlan) — never a key embedded in
|
|
605
|
+
// the token. Fail CLOSED if the key is unavailable or the signature is bad.
|
|
606
|
+
const iapKey = this.client.iapPublicKeySync();
|
|
607
|
+
if (!iapKey || !(0, crypto_verify_1.verifyEdDSAJwt)(this.currentToken.jwtToken ?? '', iapKey)) {
|
|
608
|
+
const r = { allowed: false, action: 'block', reason: 'token-signature-invalid' };
|
|
609
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
|
|
610
|
+
return r;
|
|
189
611
|
}
|
|
190
612
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
191
613
|
const inPlan = this.declaredTools.has(toolName) ||
|
|
192
614
|
this.declaredTools.has(action) ||
|
|
193
615
|
this.declaredTools.has(`${mcp}__${action}`);
|
|
194
616
|
if (!inPlan) {
|
|
195
|
-
|
|
617
|
+
const r = {
|
|
196
618
|
allowed: false,
|
|
197
619
|
action: 'block',
|
|
198
620
|
reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
|
|
199
621
|
};
|
|
622
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp }, spanId);
|
|
623
|
+
return r;
|
|
200
624
|
}
|
|
201
625
|
const pv = (this.currentToken.policyValidation ?? {});
|
|
202
626
|
const snapshot = (this.currentToken.policySnapshot ?? []);
|
|
@@ -246,12 +670,14 @@ class ArmorIQSession {
|
|
|
246
670
|
: `Tool '${action}' is denied by policy`);
|
|
247
671
|
// Deny-precedence: an explicitly denied tool is never allowed, even when
|
|
248
672
|
// the default action is 'allow'. hold only if the policy default is hold.
|
|
249
|
-
|
|
673
|
+
const r = {
|
|
250
674
|
allowed: false,
|
|
251
675
|
action: defaultAction === 'hold' ? 'hold' : 'block',
|
|
252
676
|
reason,
|
|
253
677
|
matchedPolicy: governingPolicyName,
|
|
254
678
|
};
|
|
679
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
680
|
+
return r;
|
|
255
681
|
}
|
|
256
682
|
}
|
|
257
683
|
// Honor the backend-baked resolved allow set: an explicit *empty*
|
|
@@ -259,11 +685,13 @@ class ArmorIQSession {
|
|
|
259
685
|
// ⇒ deny-all, regardless of what any individual snapshot rule allows.
|
|
260
686
|
const bakedAllowedTools = pv.allowed_tools;
|
|
261
687
|
if (Array.isArray(bakedAllowedTools) && bakedAllowedTools.length === 0) {
|
|
262
|
-
|
|
688
|
+
const r = {
|
|
263
689
|
allowed: false,
|
|
264
690
|
action: 'block',
|
|
265
691
|
reason: `Tool '${action}' is not allowed by any policy in scope (empty resolved allow-list)`,
|
|
266
692
|
};
|
|
693
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
694
|
+
return r;
|
|
267
695
|
}
|
|
268
696
|
if (governingRule) {
|
|
269
697
|
const allowed = governingRule.allowedTools ?? [];
|
|
@@ -276,19 +704,23 @@ class ArmorIQSession {
|
|
|
276
704
|
// policy default action: an 'allow' default permits it, hold/block
|
|
277
705
|
// otherwise.
|
|
278
706
|
if (defaultAction === 'allow') {
|
|
279
|
-
|
|
707
|
+
const r = {
|
|
280
708
|
allowed: true,
|
|
281
709
|
action: 'allow',
|
|
282
710
|
reason: `Allowed by default enforcement action for policy '${governingPolicyName}'`,
|
|
283
711
|
matchedPolicy: governingPolicyName,
|
|
284
712
|
};
|
|
713
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
714
|
+
return r;
|
|
285
715
|
}
|
|
286
|
-
|
|
716
|
+
const r = {
|
|
287
717
|
allowed: false,
|
|
288
718
|
action: defaultAction,
|
|
289
719
|
reason: `Tool '${action}' is not in the allowed tools for policy '${governingPolicyName}'`,
|
|
290
720
|
matchedPolicy: governingPolicyName,
|
|
291
721
|
};
|
|
722
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
723
|
+
return r;
|
|
292
724
|
}
|
|
293
725
|
}
|
|
294
726
|
}
|
|
@@ -296,17 +728,81 @@ class ArmorIQSession {
|
|
|
296
728
|
const thresholdDecision = this.evaluateAmountThreshold(governingRule, toolArgs, action, mcp);
|
|
297
729
|
if (thresholdDecision) {
|
|
298
730
|
thresholdDecision.matchedPolicy = governingPolicyName;
|
|
731
|
+
this._emitEnforcePolicyCall(ctx, toolName, thresholdDecision, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
299
732
|
return thresholdDecision;
|
|
300
733
|
}
|
|
301
734
|
}
|
|
302
|
-
|
|
735
|
+
const r = {
|
|
303
736
|
allowed: true,
|
|
304
737
|
action: 'allow',
|
|
305
738
|
reason: 'Allowed by local policy evaluation',
|
|
306
739
|
matchedPolicy: governingPolicyName,
|
|
307
740
|
};
|
|
741
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
742
|
+
return r;
|
|
743
|
+
}
|
|
744
|
+
_emitEnforcePolicyCall(ctx, toolName, result, source, extras, parentSpanId) {
|
|
745
|
+
if (!ctx || !this.obs)
|
|
746
|
+
return;
|
|
747
|
+
const decision = result.action === 'allow'
|
|
748
|
+
? 'allow'
|
|
749
|
+
: result.action === 'hold'
|
|
750
|
+
? 'hold'
|
|
751
|
+
: 'deny';
|
|
752
|
+
const enforcementAction = result.action;
|
|
753
|
+
safeObs(() => (0, observability_1.recordPolicyCall)(this.obs, ctx, {
|
|
754
|
+
policyId: null,
|
|
755
|
+
policyName: result.matchedPolicy ?? null,
|
|
756
|
+
decision,
|
|
757
|
+
reason: result.reason ?? null,
|
|
758
|
+
source,
|
|
759
|
+
input: { toolName },
|
|
760
|
+
output: {
|
|
761
|
+
allowed: result.allowed,
|
|
762
|
+
action: result.action,
|
|
763
|
+
matchedPolicy: result.matchedPolicy ?? null,
|
|
764
|
+
delegationId: result.delegationId ?? null,
|
|
765
|
+
},
|
|
766
|
+
policyHash: null,
|
|
767
|
+
policyVersion: null,
|
|
768
|
+
matchedRuleId: null,
|
|
769
|
+
dataClasses: [],
|
|
770
|
+
enforcementAction,
|
|
771
|
+
obligations: extras.obligations ?? result.obligations ?? [],
|
|
772
|
+
delegationId: result.delegationId ?? null,
|
|
773
|
+
}, parentSpanId));
|
|
774
|
+
}
|
|
775
|
+
/** See `enforceLocal`'s doc comment re: `parentContainerSpanId` (internal-only hook for `check()`). */
|
|
776
|
+
async enforceSdk(toolName, toolArgs, userEmail, parentContainerSpanId) {
|
|
777
|
+
const startNs = this.obs ? performance.now() : 0;
|
|
778
|
+
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
779
|
+
const spanId = ctx && this.obs
|
|
780
|
+
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
781
|
+
name: 'iap.enforce.sdk',
|
|
782
|
+
attributes: { toolName, toolArgs: truncateForSpan(toolArgs), userEmail: userEmail ?? null },
|
|
783
|
+
parentSpanId: parentContainerSpanId ?? null,
|
|
784
|
+
})) ?? undefined
|
|
785
|
+
: undefined;
|
|
786
|
+
let caught;
|
|
787
|
+
try {
|
|
788
|
+
return await this._enforceSdkImpl(toolName, toolArgs, userEmail, ctx, spanId);
|
|
789
|
+
}
|
|
790
|
+
catch (err) {
|
|
791
|
+
caught = err;
|
|
792
|
+
throw err;
|
|
793
|
+
}
|
|
794
|
+
finally {
|
|
795
|
+
if (ctx && spanId) {
|
|
796
|
+
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
797
|
+
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
798
|
+
status: caught ? 'error' : 'ok',
|
|
799
|
+
durationMs: computedDuration,
|
|
800
|
+
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
801
|
+
}));
|
|
802
|
+
}
|
|
803
|
+
}
|
|
308
804
|
}
|
|
309
|
-
async
|
|
805
|
+
async _enforceSdkImpl(toolName, toolArgs, userEmail, ctx, spanId) {
|
|
310
806
|
if (!this.currentToken) {
|
|
311
807
|
throw new Error(`enforceSdk("${toolName}") called before startPlan().`);
|
|
312
808
|
}
|
|
@@ -316,11 +812,13 @@ class ArmorIQSession {
|
|
|
316
812
|
this.declaredTools.has(action) ||
|
|
317
813
|
this.declaredTools.has(`${resolvedMcp}__${action}`);
|
|
318
814
|
if (!inPlan) {
|
|
319
|
-
|
|
815
|
+
const r = {
|
|
320
816
|
allowed: false,
|
|
321
817
|
action: 'block',
|
|
322
818
|
reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
|
|
323
819
|
};
|
|
820
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
|
|
821
|
+
return r;
|
|
324
822
|
}
|
|
325
823
|
const internals = this.client._sessionInternals();
|
|
326
824
|
try {
|
|
@@ -347,32 +845,68 @@ class ArmorIQSession {
|
|
|
347
845
|
? data.obligations
|
|
348
846
|
: [];
|
|
349
847
|
if (actionDecision === 'hold') {
|
|
350
|
-
|
|
848
|
+
const r = {
|
|
351
849
|
allowed: false,
|
|
352
850
|
action: 'hold',
|
|
353
851
|
reason: data.reason ?? data.message,
|
|
354
852
|
matchedPolicy: matched,
|
|
355
853
|
obligations,
|
|
356
|
-
}
|
|
854
|
+
};
|
|
855
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
|
|
856
|
+
return this.handleHold(toolName, toolArgs, r, userEmail);
|
|
357
857
|
}
|
|
358
|
-
|
|
858
|
+
const r = {
|
|
359
859
|
allowed,
|
|
360
860
|
action: actionDecision,
|
|
361
861
|
reason: data.reason ?? data.message,
|
|
362
862
|
matchedPolicy: matched,
|
|
363
863
|
obligations,
|
|
364
864
|
};
|
|
865
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
|
|
866
|
+
return r;
|
|
365
867
|
}
|
|
366
868
|
catch (e) {
|
|
367
869
|
console.error(`enforceSdk() failed: ${e.message}. Blocking tool call (fail-closed).`);
|
|
368
|
-
|
|
870
|
+
const r = {
|
|
369
871
|
allowed: false,
|
|
370
872
|
action: 'block',
|
|
371
873
|
reason: `enforce-unavailable: ${e.message}`,
|
|
372
874
|
};
|
|
875
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
|
|
876
|
+
return r;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/** See `enforceLocal`'s doc comment re: `parentContainerSpanId` (internal-only hook for `check()`). */
|
|
880
|
+
async enforce(toolName, toolArgs, parentContainerSpanId) {
|
|
881
|
+
const startNs = this.obs ? performance.now() : 0;
|
|
882
|
+
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
883
|
+
const spanId = ctx && this.obs
|
|
884
|
+
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
885
|
+
name: 'iap.enforce.proxy',
|
|
886
|
+
attributes: { toolName, toolArgs: truncateForSpan(toolArgs) },
|
|
887
|
+
parentSpanId: parentContainerSpanId ?? null,
|
|
888
|
+
})) ?? undefined
|
|
889
|
+
: undefined;
|
|
890
|
+
let caught;
|
|
891
|
+
try {
|
|
892
|
+
return await this._enforceImpl(toolName, toolArgs, ctx, spanId);
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
caught = err;
|
|
896
|
+
throw err;
|
|
897
|
+
}
|
|
898
|
+
finally {
|
|
899
|
+
if (ctx && spanId) {
|
|
900
|
+
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
901
|
+
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
902
|
+
status: caught ? 'error' : 'ok',
|
|
903
|
+
durationMs: computedDuration,
|
|
904
|
+
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
905
|
+
}));
|
|
906
|
+
}
|
|
373
907
|
}
|
|
374
908
|
}
|
|
375
|
-
async
|
|
909
|
+
async _enforceImpl(toolName, toolArgs, ctx, spanId) {
|
|
376
910
|
if (!this.currentToken) {
|
|
377
911
|
throw new Error(`enforce("${toolName}") called before startPlan().`);
|
|
378
912
|
}
|
|
@@ -382,11 +916,13 @@ class ArmorIQSession {
|
|
|
382
916
|
this.declaredTools.has(action) ||
|
|
383
917
|
this.declaredTools.has(`${resolvedMcp}__${action}`);
|
|
384
918
|
if (!inPlan) {
|
|
385
|
-
|
|
919
|
+
const r = {
|
|
386
920
|
allowed: false,
|
|
387
921
|
action: 'block',
|
|
388
922
|
reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
|
|
389
923
|
};
|
|
924
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
925
|
+
return r;
|
|
390
926
|
}
|
|
391
927
|
const internals = this.client._sessionInternals();
|
|
392
928
|
try {
|
|
@@ -413,16 +949,18 @@ class ArmorIQSession {
|
|
|
413
949
|
// Fail closed: only an explicit allow from a 2xx response permits the call.
|
|
414
950
|
// Any error status (403 or otherwise) or an ambiguous body blocks.
|
|
415
951
|
if (response.status >= 400) {
|
|
416
|
-
|
|
952
|
+
const r = {
|
|
417
953
|
allowed: false,
|
|
418
954
|
action: data.action ?? 'block',
|
|
419
955
|
reason: data.reason ?? data.message ?? `enforce-rejected: HTTP ${response.status}`,
|
|
420
956
|
matchedPolicy: policyName,
|
|
421
957
|
};
|
|
958
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
959
|
+
return r;
|
|
422
960
|
}
|
|
423
961
|
const allowedFlag = data.allowed === true;
|
|
424
962
|
const actionDecision = data.enforcementAction ?? data.action ?? (allowedFlag ? 'allow' : 'block');
|
|
425
|
-
|
|
963
|
+
const r = {
|
|
426
964
|
allowed: allowedFlag,
|
|
427
965
|
action: actionDecision,
|
|
428
966
|
reason: data.reason,
|
|
@@ -430,161 +968,325 @@ class ArmorIQSession {
|
|
|
430
968
|
matchedPolicy: policyName,
|
|
431
969
|
obligations: Array.isArray(data.obligations) ? data.obligations : [],
|
|
432
970
|
};
|
|
971
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
972
|
+
return r;
|
|
433
973
|
}
|
|
434
974
|
catch (e) {
|
|
435
975
|
console.error(`enforce() failed: ${e.message}. Blocking tool call (fail-closed).`);
|
|
436
|
-
|
|
976
|
+
const r = {
|
|
437
977
|
allowed: false,
|
|
438
978
|
action: 'block',
|
|
439
979
|
reason: `enforce-unavailable: ${e.message}`,
|
|
440
980
|
};
|
|
981
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
982
|
+
return r;
|
|
441
983
|
}
|
|
442
984
|
}
|
|
443
985
|
async check(toolName, toolArgs, userEmail) {
|
|
444
|
-
|
|
445
|
-
|
|
986
|
+
const startNs = this.obs ? performance.now() : 0;
|
|
987
|
+
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
988
|
+
// Task 3 risk — "check → enforce double-open": `check` opens its OWN
|
|
989
|
+
// `iap.check` container span (nested under the single active plan
|
|
990
|
+
// trace, never a second trace), then passes this span's id as the
|
|
991
|
+
// PARENT for whichever enforce* it dispatches into. The dispatched
|
|
992
|
+
// enforce's container span therefore nests under `iap.check`'s span —
|
|
993
|
+
// it does not open a redundant plan trace or a sibling/duplicate
|
|
994
|
+
// container span.
|
|
995
|
+
const spanId = ctx && this.obs
|
|
996
|
+
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
997
|
+
name: 'iap.check',
|
|
998
|
+
attributes: {
|
|
999
|
+
toolName,
|
|
1000
|
+
toolArgs: truncateForSpan(toolArgs),
|
|
1001
|
+
mode: this.mode,
|
|
1002
|
+
userEmail: userEmail ?? null,
|
|
1003
|
+
},
|
|
1004
|
+
})) ?? undefined
|
|
1005
|
+
: undefined;
|
|
1006
|
+
let caught;
|
|
1007
|
+
try {
|
|
1008
|
+
if (this.mode === 'sdk') {
|
|
1009
|
+
return await this.enforceSdk(toolName, toolArgs, userEmail, spanId);
|
|
1010
|
+
}
|
|
1011
|
+
if (this.mode === 'local') {
|
|
1012
|
+
const decision = this.enforceLocal(toolName, toolArgs, spanId);
|
|
1013
|
+
if (decision.action === 'hold') {
|
|
1014
|
+
return {
|
|
1015
|
+
...decision,
|
|
1016
|
+
action: 'block',
|
|
1017
|
+
reason: (decision.reason ?? 'requires approval') +
|
|
1018
|
+
' — switch ARMORIQ_MODE=proxy to enable approval workflows for this action.',
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
return decision;
|
|
1022
|
+
}
|
|
1023
|
+
const decision = await this.enforce(toolName, toolArgs, spanId);
|
|
1024
|
+
if (decision.action !== 'hold')
|
|
1025
|
+
return decision;
|
|
1026
|
+
return this.handleHold(toolName, toolArgs, decision, userEmail);
|
|
446
1027
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
1028
|
+
catch (err) {
|
|
1029
|
+
caught = err;
|
|
1030
|
+
throw err;
|
|
1031
|
+
}
|
|
1032
|
+
finally {
|
|
1033
|
+
if (ctx && spanId) {
|
|
1034
|
+
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
1035
|
+
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1036
|
+
status: caught ? 'error' : 'ok',
|
|
1037
|
+
durationMs: computedDuration,
|
|
1038
|
+
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
1039
|
+
}));
|
|
456
1040
|
}
|
|
457
|
-
return decision;
|
|
458
1041
|
}
|
|
459
|
-
const decision = await this.enforce(toolName, toolArgs);
|
|
460
|
-
if (decision.action !== 'hold')
|
|
461
|
-
return decision;
|
|
462
|
-
return this.handleHold(toolName, toolArgs, decision, userEmail);
|
|
463
1042
|
}
|
|
464
1043
|
// ─── Report / dispatch ─────────────────────────────────────────
|
|
465
1044
|
async report(toolName, toolArgs, result, opts) {
|
|
466
|
-
const o =
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
1045
|
+
const o = {
|
|
1046
|
+
...opts,
|
|
1047
|
+
durationMs: opts?.durationMs != null && Number.isFinite(opts.durationMs)
|
|
1048
|
+
? Math.round(opts.durationMs)
|
|
1049
|
+
: opts?.durationMs,
|
|
1050
|
+
};
|
|
1051
|
+
const startNs = this.obs ? performance.now() : 0;
|
|
1052
|
+
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
1053
|
+
const spanId = ctx && this.obs
|
|
1054
|
+
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
1055
|
+
name: 'tool.report',
|
|
1056
|
+
attributes: {
|
|
1057
|
+
toolName,
|
|
1058
|
+
toolInput: truncateForSpan(toolArgs),
|
|
1059
|
+
status: o.status ?? 'success',
|
|
1060
|
+
},
|
|
1061
|
+
})) ?? undefined
|
|
1062
|
+
: undefined;
|
|
1063
|
+
let caught;
|
|
1064
|
+
let reportStatus = 'ok';
|
|
1065
|
+
let reportError;
|
|
470
1066
|
try {
|
|
471
|
-
const
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
output =
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1067
|
+
const { mcp, action } = this.toolNameParser(toolName);
|
|
1068
|
+
const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
|
|
1069
|
+
const internals = this.client._sessionInternals();
|
|
1070
|
+
try {
|
|
1071
|
+
const token = this.currentToken;
|
|
1072
|
+
const userEmail = this.userEmail ?? this.client.userEmailOverride;
|
|
1073
|
+
let output = result;
|
|
1074
|
+
if (typeof result === 'string')
|
|
1075
|
+
output = { text: result };
|
|
1076
|
+
else if (result === null || result === undefined)
|
|
1077
|
+
output = {};
|
|
1078
|
+
await internals.httpClient.post(`${internals.backendEndpoint}/iap/audit`, {
|
|
1079
|
+
token: token?.jwtToken ?? token?.tokenId ?? 'unknown',
|
|
1080
|
+
plan_id: token?.planId ?? token?.tokenId ?? 'unknown',
|
|
1081
|
+
step_index: this.stepIndex,
|
|
1082
|
+
action,
|
|
1083
|
+
tool: action,
|
|
1084
|
+
mcp: resolvedMcp,
|
|
1085
|
+
input: toolArgs,
|
|
1086
|
+
output,
|
|
1087
|
+
status: o.status ?? 'success',
|
|
1088
|
+
error_message: o.errorMessage,
|
|
1089
|
+
duration_ms: o.durationMs,
|
|
1090
|
+
is_delegated: o.isDelegated,
|
|
1091
|
+
delegated_by: o.delegatedBy,
|
|
1092
|
+
user_email: userEmail,
|
|
1093
|
+
delegated_to: o.delegatedTo,
|
|
1094
|
+
executed_at: new Date().toISOString(),
|
|
1095
|
+
}, {
|
|
1096
|
+
headers: { 'X-API-Key': internals.apiKey, 'Content-Type': 'application/json' },
|
|
1097
|
+
timeout: 5000,
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
catch (e) {
|
|
1101
|
+
console.warn(`report() failed: ${e.message}`);
|
|
1102
|
+
}
|
|
1103
|
+
this.stepIndex += 1;
|
|
499
1104
|
}
|
|
500
|
-
catch (
|
|
501
|
-
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
caught = err;
|
|
1107
|
+
reportStatus = 'error';
|
|
1108
|
+
reportError = err.message;
|
|
1109
|
+
throw err;
|
|
1110
|
+
}
|
|
1111
|
+
finally {
|
|
1112
|
+
if (ctx) {
|
|
1113
|
+
const computedDuration = o.durationMs ?? (startNs > 0 ? Math.round(performance.now() - startNs) : undefined);
|
|
1114
|
+
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'tool.report', {
|
|
1115
|
+
kind: 'span',
|
|
1116
|
+
toolName,
|
|
1117
|
+
toolInput: truncateForSpan(toolArgs),
|
|
1118
|
+
toolOutput: truncateForSpan(result),
|
|
1119
|
+
// `status` here is the caller-supplied tool outcome
|
|
1120
|
+
// ('success'|'failed'|'error' from ReportOptions), a distinct
|
|
1121
|
+
// side-channel signal from the top-level SpanRecord.status
|
|
1122
|
+
// ('ok'|'error'|'denied' span lifecycle status) below —
|
|
1123
|
+
// intentionally NOT a duplicate.
|
|
1124
|
+
status: o.status ?? 'success',
|
|
1125
|
+
errorMessage: o.errorMessage,
|
|
1126
|
+
}, reportStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
1127
|
+
if (spanId) {
|
|
1128
|
+
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1129
|
+
status: reportStatus === 'error' ? 'error' : 'ok',
|
|
1130
|
+
durationMs: computedDuration,
|
|
1131
|
+
errorMessage: reportError,
|
|
1132
|
+
}));
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
502
1135
|
}
|
|
503
|
-
this.stepIndex += 1;
|
|
504
1136
|
}
|
|
505
1137
|
async dispatch(toolName, toolArgs) {
|
|
506
|
-
|
|
507
|
-
|
|
1138
|
+
const startNs = this.obs ? performance.now() : 0;
|
|
1139
|
+
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
1140
|
+
const spanId = ctx && this.obs
|
|
1141
|
+
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
1142
|
+
name: 'tool.dispatch',
|
|
1143
|
+
attributes: { toolName, toolInput: truncateForSpan(toolArgs) },
|
|
1144
|
+
})) ?? undefined
|
|
1145
|
+
: undefined;
|
|
1146
|
+
let caught;
|
|
1147
|
+
let dispatchResult;
|
|
1148
|
+
let dispatchStatus = 'ok';
|
|
1149
|
+
let dispatchError;
|
|
1150
|
+
try {
|
|
1151
|
+
if (!this.currentToken) {
|
|
1152
|
+
throw new Error(`dispatch("${toolName}") called before startPlan().`);
|
|
1153
|
+
}
|
|
1154
|
+
const { mcp, action } = this.toolNameParser(toolName);
|
|
1155
|
+
const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
|
|
1156
|
+
dispatchResult = await this.client.invoke(resolvedMcp, action, this.currentToken, toolArgs);
|
|
1157
|
+
this.stepIndex += 1;
|
|
1158
|
+
return dispatchResult?.result;
|
|
1159
|
+
}
|
|
1160
|
+
catch (err) {
|
|
1161
|
+
caught = err;
|
|
1162
|
+
dispatchStatus = 'error';
|
|
1163
|
+
dispatchError = err.message;
|
|
1164
|
+
throw err;
|
|
1165
|
+
}
|
|
1166
|
+
finally {
|
|
1167
|
+
if (ctx) {
|
|
1168
|
+
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
1169
|
+
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'tool.dispatch', {
|
|
1170
|
+
kind: 'span',
|
|
1171
|
+
toolName,
|
|
1172
|
+
toolInput: truncateForSpan(toolArgs),
|
|
1173
|
+
result: truncateForSpan(dispatchResult),
|
|
1174
|
+
}, dispatchStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
1175
|
+
if (spanId) {
|
|
1176
|
+
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1177
|
+
status: dispatchStatus === 'error' ? 'error' : 'ok',
|
|
1178
|
+
durationMs: computedDuration,
|
|
1179
|
+
errorMessage: dispatchError,
|
|
1180
|
+
}));
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
508
1183
|
}
|
|
509
|
-
const { mcp, action } = this.toolNameParser(toolName);
|
|
510
|
-
const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
|
|
511
|
-
const result = await this.client.invoke(resolvedMcp, action, this.currentToken, toolArgs);
|
|
512
|
-
this.stepIndex += 1;
|
|
513
|
-
return result.result;
|
|
514
1184
|
}
|
|
515
1185
|
// ─── Helpers ────────────────────────────────────────────────────
|
|
516
1186
|
async handleHold(toolName, toolArgs, holdDecision, userEmail) {
|
|
517
|
-
const
|
|
518
|
-
const
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
1187
|
+
const startNs = this.obs ? performance.now() : 0;
|
|
1188
|
+
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
1189
|
+
const spanId = ctx && this.obs
|
|
1190
|
+
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
1191
|
+
name: 'delegation.hold',
|
|
1192
|
+
attributes: {
|
|
1193
|
+
toolName,
|
|
1194
|
+
toolArgs: truncateForSpan(toolArgs),
|
|
1195
|
+
matchedPolicy: holdDecision.matchedPolicy ?? null,
|
|
1196
|
+
},
|
|
1197
|
+
})) ?? undefined
|
|
1198
|
+
: undefined;
|
|
1199
|
+
let caught;
|
|
1200
|
+
let result;
|
|
1201
|
+
let holdStatus = 'ok';
|
|
1202
|
+
let holdError;
|
|
529
1203
|
try {
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
1204
|
+
const internals = this.client._sessionInternals();
|
|
1205
|
+
const email = userEmail ?? internals.userId ?? 'unknown@armoriq';
|
|
1206
|
+
const { mcp, action } = this.toolNameParser(toolName);
|
|
1207
|
+
const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
|
|
1208
|
+
const rawAmount = ArmorIQSession.extractAmount(toolArgs) ?? 0;
|
|
1209
|
+
const safeAmount = typeof rawAmount === 'number' && rawAmount >= 0.01 ? rawAmount : 0.01;
|
|
1210
|
+
try {
|
|
1211
|
+
const approved = await this.client.checkApprovedDelegation(email, action, safeAmount);
|
|
1212
|
+
if (approved) {
|
|
1213
|
+
try {
|
|
1214
|
+
if (approved.delegationId) {
|
|
1215
|
+
await this.client.markDelegationExecuted(email, approved.delegationId);
|
|
1216
|
+
}
|
|
535
1217
|
}
|
|
1218
|
+
catch {
|
|
1219
|
+
// best-effort
|
|
1220
|
+
}
|
|
1221
|
+
result = {
|
|
1222
|
+
allowed: true,
|
|
1223
|
+
action: 'allow',
|
|
1224
|
+
reason: `Allowed by approved delegation ${approved.delegationId ?? ''}`.trim(),
|
|
1225
|
+
delegationId: approved.delegationId,
|
|
1226
|
+
matchedPolicy: holdDecision.matchedPolicy,
|
|
1227
|
+
obligations: holdDecision.obligations ?? [],
|
|
1228
|
+
};
|
|
1229
|
+
return result;
|
|
536
1230
|
}
|
|
537
|
-
catch {
|
|
538
|
-
// best-effort
|
|
539
|
-
}
|
|
540
|
-
return {
|
|
541
|
-
allowed: true,
|
|
542
|
-
action: 'allow',
|
|
543
|
-
reason: `Allowed by approved delegation ${approved.delegationId ?? ''}`.trim(),
|
|
544
|
-
delegationId: approved.delegationId,
|
|
545
|
-
matchedPolicy: holdDecision.matchedPolicy,
|
|
546
|
-
obligations: holdDecision.obligations ?? [],
|
|
547
|
-
};
|
|
548
1231
|
}
|
|
1232
|
+
catch {
|
|
1233
|
+
// best-effort
|
|
1234
|
+
}
|
|
1235
|
+
let delegationId;
|
|
1236
|
+
try {
|
|
1237
|
+
const r = await this.client.createDelegationRequest({
|
|
1238
|
+
tool: action,
|
|
1239
|
+
action,
|
|
1240
|
+
arguments: toolArgs,
|
|
1241
|
+
amount: safeAmount,
|
|
1242
|
+
requesterEmail: email,
|
|
1243
|
+
requesterRole: 'agent_user',
|
|
1244
|
+
requesterLimit: 0,
|
|
1245
|
+
domain: resolvedMcp,
|
|
1246
|
+
planId: this.currentToken?.planId,
|
|
1247
|
+
intentReference: this.currentToken?.tokenId,
|
|
1248
|
+
merkleRoot: (this.currentToken?.rawToken ?? {}).merkle_root,
|
|
1249
|
+
reason: holdDecision.reason,
|
|
1250
|
+
});
|
|
1251
|
+
delegationId = r.delegationId;
|
|
1252
|
+
}
|
|
1253
|
+
catch (e) {
|
|
1254
|
+
console.warn(`createDelegationRequest failed: ${e.message}`);
|
|
1255
|
+
}
|
|
1256
|
+
result = {
|
|
1257
|
+
allowed: false,
|
|
1258
|
+
action: 'hold',
|
|
1259
|
+
reason: holdDecision.reason ?? 'Pending approval',
|
|
1260
|
+
delegationId,
|
|
1261
|
+
matchedPolicy: holdDecision.matchedPolicy,
|
|
1262
|
+
obligations: holdDecision.obligations ?? [],
|
|
1263
|
+
};
|
|
1264
|
+
return result;
|
|
549
1265
|
}
|
|
550
|
-
catch {
|
|
551
|
-
|
|
1266
|
+
catch (err) {
|
|
1267
|
+
caught = err;
|
|
1268
|
+
holdStatus = 'error';
|
|
1269
|
+
holdError = err.message;
|
|
1270
|
+
throw err;
|
|
552
1271
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
planId: this.currentToken?.planId,
|
|
571
|
-
intentReference: this.currentToken?.tokenId,
|
|
572
|
-
merkleRoot: (this.currentToken?.rawToken ?? {}).merkle_root,
|
|
573
|
-
reason: holdDecision.reason,
|
|
574
|
-
});
|
|
575
|
-
delegationId = result.delegationId;
|
|
1272
|
+
finally {
|
|
1273
|
+
if (ctx) {
|
|
1274
|
+
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
1275
|
+
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'delegation.hold', {
|
|
1276
|
+
kind: 'span',
|
|
1277
|
+
delegationId: result?.delegationId ?? null,
|
|
1278
|
+
decision: result?.action ?? 'hold',
|
|
1279
|
+
errorMessage: holdError,
|
|
1280
|
+
}, holdStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
1281
|
+
if (spanId) {
|
|
1282
|
+
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1283
|
+
status: holdStatus === 'error' ? 'error' : 'ok',
|
|
1284
|
+
durationMs: computedDuration,
|
|
1285
|
+
errorMessage: holdError,
|
|
1286
|
+
}));
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
576
1289
|
}
|
|
577
|
-
catch (e) {
|
|
578
|
-
console.warn(`createDelegationRequest failed: ${e.message}`);
|
|
579
|
-
}
|
|
580
|
-
return {
|
|
581
|
-
allowed: false,
|
|
582
|
-
action: 'hold',
|
|
583
|
-
reason: holdDecision.reason ?? 'Pending approval',
|
|
584
|
-
delegationId,
|
|
585
|
-
matchedPolicy: holdDecision.matchedPolicy,
|
|
586
|
-
obligations: holdDecision.obligations ?? [],
|
|
587
|
-
};
|
|
588
1290
|
}
|
|
589
1291
|
/**
|
|
590
1292
|
* Poll a held delegation until it is decided; non-blocking (async).
|
|
@@ -594,38 +1296,86 @@ class ArmorIQSession {
|
|
|
594
1296
|
* timeout/interval are in seconds (mirrors the Python SDK await_approval).
|
|
595
1297
|
*/
|
|
596
1298
|
async awaitApproval(delegationId, opts = {}) {
|
|
597
|
-
const
|
|
598
|
-
const
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
1299
|
+
const startNs = this.obs ? performance.now() : 0;
|
|
1300
|
+
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
1301
|
+
const spanId = ctx && this.obs
|
|
1302
|
+
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
1303
|
+
name: 'delegation.poll',
|
|
1304
|
+
attributes: { delegationId, timeout: opts.timeout ?? 300, interval: opts.interval ?? 5 },
|
|
1305
|
+
})) ?? undefined
|
|
1306
|
+
: undefined;
|
|
1307
|
+
let caught;
|
|
1308
|
+
let pollOutcome = 'timeout';
|
|
1309
|
+
try {
|
|
1310
|
+
const timeout = opts.timeout ?? 300;
|
|
1311
|
+
const interval = opts.interval ?? 5;
|
|
1312
|
+
const transient = new Set([429, 502, 503, 504]);
|
|
1313
|
+
const email = opts.userEmail || this.client.userId || 'unknown@armoriq';
|
|
1314
|
+
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
|
|
1315
|
+
let waited = 0;
|
|
1316
|
+
let pollCount = 0;
|
|
1317
|
+
while (waited < timeout) {
|
|
1318
|
+
await sleep(interval);
|
|
1319
|
+
waited += interval;
|
|
1320
|
+
pollCount += 1;
|
|
1321
|
+
let status;
|
|
1322
|
+
try {
|
|
1323
|
+
status = await this.client.getDelegationStatus(delegationId);
|
|
1324
|
+
}
|
|
1325
|
+
catch (e) {
|
|
1326
|
+
if (e instanceof exceptions_1.DelegationException &&
|
|
1327
|
+
e.statusCode &&
|
|
1328
|
+
transient.has(e.statusCode)) {
|
|
1329
|
+
const backoff = Math.min(interval * 2, 15);
|
|
1330
|
+
await sleep(backoff);
|
|
1331
|
+
waited += backoff;
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
throw e;
|
|
1335
|
+
}
|
|
1336
|
+
if (ctx) {
|
|
1337
|
+
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'delegation.poll.tick', {
|
|
1338
|
+
kind: 'event',
|
|
1339
|
+
message: `poll #${pollCount} status=${status}`,
|
|
1340
|
+
level: 'info',
|
|
1341
|
+
delegationId,
|
|
1342
|
+
}, spanId)));
|
|
1343
|
+
}
|
|
1344
|
+
if (status === 'approved') {
|
|
1345
|
+
await this.client.markDelegationExecuted(email, delegationId);
|
|
1346
|
+
pollOutcome = 'approved';
|
|
1347
|
+
return 'approved';
|
|
1348
|
+
}
|
|
1349
|
+
if (status === 'rejected') {
|
|
1350
|
+
pollOutcome = 'rejected';
|
|
1351
|
+
return 'rejected';
|
|
618
1352
|
}
|
|
619
|
-
throw e;
|
|
620
1353
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
1354
|
+
pollOutcome = 'timeout';
|
|
1355
|
+
return 'timeout';
|
|
1356
|
+
}
|
|
1357
|
+
catch (err) {
|
|
1358
|
+
caught = err;
|
|
1359
|
+
throw err;
|
|
1360
|
+
}
|
|
1361
|
+
finally {
|
|
1362
|
+
if (ctx) {
|
|
1363
|
+
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
1364
|
+
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'delegation.poll.terminal', {
|
|
1365
|
+
kind: 'span',
|
|
1366
|
+
delegationId,
|
|
1367
|
+
decision: pollOutcome,
|
|
1368
|
+
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
1369
|
+
}, caught ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
1370
|
+
if (spanId) {
|
|
1371
|
+
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1372
|
+
status: caught ? 'error' : 'ok',
|
|
1373
|
+
durationMs: computedDuration,
|
|
1374
|
+
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
1375
|
+
}));
|
|
1376
|
+
}
|
|
624
1377
|
}
|
|
625
|
-
if (status === 'rejected')
|
|
626
|
-
return 'rejected';
|
|
627
1378
|
}
|
|
628
|
-
return 'timeout';
|
|
629
1379
|
}
|
|
630
1380
|
static extractAmount(args) {
|
|
631
1381
|
if (!args || typeof args !== 'object')
|
|
@@ -689,31 +1439,63 @@ class ArmorIQSession {
|
|
|
689
1439
|
* reanchor is off
|
|
690
1440
|
*/
|
|
691
1441
|
async flushReanchor() {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
1442
|
+
const startNs = this.obs ? performance.now() : 0;
|
|
1443
|
+
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
1444
|
+
const spanId = ctx && this.obs
|
|
1445
|
+
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
1446
|
+
name: 'iap.reanchor.flush',
|
|
1447
|
+
attributes: {
|
|
1448
|
+
hasPendingPlan: !!this.pendingReanchorPlan,
|
|
1449
|
+
hasToken: !!this.currentToken,
|
|
1450
|
+
},
|
|
1451
|
+
})) ?? undefined
|
|
1452
|
+
: undefined;
|
|
1453
|
+
let caught;
|
|
1454
|
+
let fired = false;
|
|
1455
|
+
let trustId;
|
|
706
1456
|
try {
|
|
707
|
-
|
|
708
|
-
|
|
1457
|
+
if (!this.pendingReanchorPlan) {
|
|
1458
|
+
return { fired: false };
|
|
1459
|
+
}
|
|
1460
|
+
if (!this.currentToken) {
|
|
1461
|
+
this.pendingReanchorPlan = undefined;
|
|
1462
|
+
return { fired: false };
|
|
1463
|
+
}
|
|
1464
|
+
if (!this.trueReanchor && !this.autoReanchor) {
|
|
1465
|
+
this.pendingReanchorPlan = undefined;
|
|
1466
|
+
return { fired: false };
|
|
1467
|
+
}
|
|
1468
|
+
const planToFlush = this.pendingReanchorPlan;
|
|
1469
|
+
this.pendingReanchorPlan = undefined;
|
|
1470
|
+
const result = await this.reanchorCall(this.currentToken, planToFlush, 'flush at request boundary');
|
|
1471
|
+
fired = true;
|
|
1472
|
+
trustId = result.trustId;
|
|
1473
|
+
return { fired: true, trustId };
|
|
709
1474
|
}
|
|
710
1475
|
catch (err) {
|
|
711
|
-
|
|
712
|
-
// session lifecycle.
|
|
1476
|
+
caught = err;
|
|
713
1477
|
const msg = err instanceof Error ? err.message : String(err);
|
|
714
1478
|
console.warn(`[armoriq] flushReanchor failed: ${msg}`);
|
|
715
1479
|
return { fired: false };
|
|
716
1480
|
}
|
|
1481
|
+
finally {
|
|
1482
|
+
if (ctx) {
|
|
1483
|
+
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
1484
|
+
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.reanchor.flush.result', {
|
|
1485
|
+
kind: 'span',
|
|
1486
|
+
fired,
|
|
1487
|
+
trustId: trustId ?? null,
|
|
1488
|
+
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
1489
|
+
}, caught ? 'error' : 'ok', null, spanId)));
|
|
1490
|
+
if (spanId) {
|
|
1491
|
+
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1492
|
+
status: caught ? 'error' : 'ok',
|
|
1493
|
+
durationMs: computedDuration,
|
|
1494
|
+
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
1495
|
+
}));
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
717
1499
|
}
|
|
718
1500
|
get currentTokenValue() {
|
|
719
1501
|
return this.currentToken;
|
|
@@ -721,6 +1503,64 @@ class ArmorIQSession {
|
|
|
721
1503
|
get currentMode() {
|
|
722
1504
|
return this.mode;
|
|
723
1505
|
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Force-flush all buffered observability traces/spans to the backend ingest
|
|
1508
|
+
* endpoint. No-op when observability is disabled (`observability.enabled: false`).
|
|
1509
|
+
*
|
|
1510
|
+
* Call this at the request boundary (e.g. Stop hook in armorClaude, end of
|
|
1511
|
+
* turn in ADK consumers) to guarantee all spans for the current session have
|
|
1512
|
+
* been POSTed before the process exits. The underlying shipper retries with
|
|
1513
|
+
* exponential backoff and **never throws** into the SDK consumer — a flush
|
|
1514
|
+
* failure is logged and the spans are dropped.
|
|
1515
|
+
*/
|
|
1516
|
+
async flushObservability() {
|
|
1517
|
+
if (!this.obs)
|
|
1518
|
+
return;
|
|
1519
|
+
// Model A: end the in-flight active plan trace FIRST so it's handed to
|
|
1520
|
+
// the shipper's queue before we flush — otherwise an in-progress plan
|
|
1521
|
+
// trace (no endTime yet) would be left behind in the ring buffer and
|
|
1522
|
+
// never ship (see ObservabilityRecorder.flush()'s in-flight retention).
|
|
1523
|
+
safeObs(() => this.endPlanTrace('ok'));
|
|
1524
|
+
await (0, observability_1.flushObservability)(this.obs);
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* Stop the session's observability lifecycle: clears the shipper's
|
|
1528
|
+
* repeating flush interval (`setInterval`, see `ObservabilityShipper`) and
|
|
1529
|
+
* performs one final drain/flush of anything still queued. Safe to call
|
|
1530
|
+
* multiple times; a no-op when observability is disabled.
|
|
1531
|
+
*
|
|
1532
|
+
* **Required at session end.** Without calling this, the recorder's
|
|
1533
|
+
* shipper keeps its interval timer alive indefinitely — in long-running
|
|
1534
|
+
* processes this is a resource leak, and in tests it is why processes
|
|
1535
|
+
* (and jest) can hang or emit "cannot log after tests done" / stray
|
|
1536
|
+
* network activity after teardown. `flushObservability()` alone does NOT
|
|
1537
|
+
* stop the interval — it only flushes; call `close()` (or `dispose()`) at
|
|
1538
|
+
* the natural end of the session's lifetime (e.g. Stop hook in armorClaude,
|
|
1539
|
+
* end of process, or end of a request/turn in ADK consumers) instead of
|
|
1540
|
+
* (or in addition to) `flushObservability()`.
|
|
1541
|
+
*
|
|
1542
|
+
* NEVER throws — observability teardown failures must not propagate into
|
|
1543
|
+
* the SDK consumer, matching every other observability code path.
|
|
1544
|
+
*/
|
|
1545
|
+
async close() {
|
|
1546
|
+
if (!this.obs)
|
|
1547
|
+
return;
|
|
1548
|
+
// Model A: end the active plan trace before stopping the shipper so its
|
|
1549
|
+
// final flush (inside obs.stop()) ships the completed plan trace rather
|
|
1550
|
+
// than leaving it stranded in-flight.
|
|
1551
|
+
safeObs(() => this.endPlanTrace('ok'));
|
|
1552
|
+
try {
|
|
1553
|
+
await this.obs.stop();
|
|
1554
|
+
}
|
|
1555
|
+
catch (err) {
|
|
1556
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1557
|
+
console.warn(`[armoriq] observability close() failed (continuing): ${msg}`);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
/** Alias for `close()`. Use whichever reads better at the call site. */
|
|
1561
|
+
async dispose() {
|
|
1562
|
+
await this.close();
|
|
1563
|
+
}
|
|
724
1564
|
}
|
|
725
1565
|
exports.ArmorIQSession = ArmorIQSession;
|
|
726
1566
|
//# sourceMappingURL=session.js.map
|