@armoriq/sdk-dev 0.6.1 → 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 +168 -3
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +1093 -204
- 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
|
@@ -16,10 +16,92 @@
|
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
18
|
exports.ArmorIQSession = void 0;
|
|
19
|
+
exports.normalizeDefaultAction = normalizeDefaultAction;
|
|
20
|
+
const crypto_1 = require("crypto");
|
|
21
|
+
const config_1 = require("./config");
|
|
19
22
|
const crypto_verify_1 = require("./crypto_verify");
|
|
20
23
|
const exceptions_1 = require("./exceptions");
|
|
21
24
|
const models_1 = require("./models");
|
|
22
25
|
const plan_builder_1 = require("./plan_builder");
|
|
26
|
+
const observability_1 = require("./observability");
|
|
27
|
+
/**
|
|
28
|
+
* Normalize a policy-authoring default-enforcement-action value into the
|
|
29
|
+
* SDK-facing terminal enum. Mirrors the backend's enforce-boundary normalization
|
|
30
|
+
* (wire contract §"Canonical enforcement-action enum"):
|
|
31
|
+
* allow | allow_log → allow ; hold → hold ; block | deny → block.
|
|
32
|
+
* Anything unknown fails closed to `block`.
|
|
33
|
+
*/
|
|
34
|
+
function normalizeDefaultAction(value) {
|
|
35
|
+
switch (value) {
|
|
36
|
+
case 'allow':
|
|
37
|
+
case 'allow_log':
|
|
38
|
+
return 'allow';
|
|
39
|
+
case 'hold':
|
|
40
|
+
return 'hold';
|
|
41
|
+
case 'block':
|
|
42
|
+
case 'deny':
|
|
43
|
+
return 'block';
|
|
44
|
+
default:
|
|
45
|
+
return 'block';
|
|
46
|
+
}
|
|
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
|
+
}
|
|
23
105
|
class ArmorIQSession {
|
|
24
106
|
userEmail;
|
|
25
107
|
client;
|
|
@@ -42,6 +124,34 @@ class ArmorIQSession {
|
|
|
42
124
|
currentToken;
|
|
43
125
|
mcpByAction = new Map();
|
|
44
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;
|
|
45
155
|
constructor(client, opts) {
|
|
46
156
|
this.client = client;
|
|
47
157
|
const o = opts ?? {};
|
|
@@ -54,12 +164,298 @@ class ArmorIQSession {
|
|
|
54
164
|
const granEnv = (typeof process !== 'undefined' && process.env?.ARMORIQ_REANCHOR_GRANULARITY) || '';
|
|
55
165
|
this.reanchorGranularity =
|
|
56
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 }));
|
|
57
289
|
}
|
|
58
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
|
+
}
|
|
59
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) {
|
|
60
448
|
if (!toolCalls || toolCalls.length === 0) {
|
|
61
449
|
throw new Error('startPlan called with no tool calls.');
|
|
62
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
|
+
}
|
|
63
459
|
const h = (0, plan_builder_1.hashToolCalls)(toolCalls);
|
|
64
460
|
if (this.currentToken && this.currentPlanHash === h) {
|
|
65
461
|
return this.currentToken;
|
|
@@ -99,7 +495,7 @@ class ArmorIQSession {
|
|
|
99
495
|
// For autoReanchor (pragmatic v2) in deferred mode: still need to
|
|
100
496
|
// re-mint because step_proofs are baked at issuance. Skip the
|
|
101
497
|
// delta call here; flushReanchor() catches up later.
|
|
102
|
-
const tokenA = await this.
|
|
498
|
+
const tokenA = await this.getIntentTokenCall(planCapture, this.validitySeconds);
|
|
103
499
|
this.currentPlanHash = h;
|
|
104
500
|
this.currentToken = tokenA;
|
|
105
501
|
this.stepIndex = 0;
|
|
@@ -112,7 +508,7 @@ class ArmorIQSession {
|
|
|
112
508
|
// preserved across the chain (paper's actual C3).
|
|
113
509
|
if (this.trueReanchor && this.currentToken) {
|
|
114
510
|
try {
|
|
115
|
-
await this.
|
|
511
|
+
await this.reanchorCall(this.currentToken, plan, 'plan grew (true-reanchor)');
|
|
116
512
|
// Update local state without re-minting. The JWT stays the same;
|
|
117
513
|
// only currentPlanHash advances so subsequent enforceLocal /
|
|
118
514
|
// dispatch calls see the new plan.
|
|
@@ -134,47 +530,97 @@ class ArmorIQSession {
|
|
|
134
530
|
// pins to the JWT's plan_hash and step_proofs are baked at issuance.
|
|
135
531
|
if (this.autoReanchor && this.currentToken) {
|
|
136
532
|
try {
|
|
137
|
-
await this.
|
|
533
|
+
await this.reanchorCall(this.currentToken, plan, 'plan grew mid-session');
|
|
138
534
|
}
|
|
139
535
|
catch (err) {
|
|
140
536
|
const msg = err instanceof Error ? err.message : String(err);
|
|
141
537
|
console.warn(`[armoriq] auto-reanchor delta failed (continuing): ${msg}`);
|
|
142
538
|
}
|
|
143
539
|
}
|
|
144
|
-
const token = await this.
|
|
540
|
+
const token = await this.getIntentTokenCall(planCapture, this.validitySeconds);
|
|
145
541
|
this.currentPlanHash = h;
|
|
146
542
|
this.currentToken = token;
|
|
147
543
|
this.stepIndex = 0;
|
|
148
544
|
return token;
|
|
149
545
|
}
|
|
150
546
|
// ─── Policy enforcement ────────────────────────────────────────
|
|
151
|
-
|
|
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) {
|
|
152
586
|
if (!this.currentToken) {
|
|
153
|
-
|
|
587
|
+
const r = {
|
|
154
588
|
allowed: false,
|
|
155
589
|
action: 'block',
|
|
156
590
|
reason: 'No intent token — call startPlan() first',
|
|
157
591
|
};
|
|
592
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
|
|
593
|
+
return r;
|
|
158
594
|
}
|
|
159
595
|
if (models_1.IntentToken.isExpired(this.currentToken)) {
|
|
160
|
-
|
|
596
|
+
const r = { allowed: false, action: 'block', reason: 'token-expired' };
|
|
597
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
|
|
598
|
+
return r;
|
|
161
599
|
}
|
|
162
600
|
// The local decision is read from the token's policy fields, so the token
|
|
163
601
|
// must be cryptographically authentic first - otherwise a forged token
|
|
164
|
-
// could assert an arbitrary allowlist.
|
|
165
|
-
|
|
166
|
-
|
|
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;
|
|
167
611
|
}
|
|
168
612
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
169
613
|
const inPlan = this.declaredTools.has(toolName) ||
|
|
170
614
|
this.declaredTools.has(action) ||
|
|
171
615
|
this.declaredTools.has(`${mcp}__${action}`);
|
|
172
616
|
if (!inPlan) {
|
|
173
|
-
|
|
617
|
+
const r = {
|
|
174
618
|
allowed: false,
|
|
175
619
|
action: 'block',
|
|
176
620
|
reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
|
|
177
621
|
};
|
|
622
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp }, spanId);
|
|
623
|
+
return r;
|
|
178
624
|
}
|
|
179
625
|
const pv = (this.currentToken.policyValidation ?? {});
|
|
180
626
|
const snapshot = (this.currentToken.policySnapshot ?? []);
|
|
@@ -209,7 +655,10 @@ class ArmorIQSession {
|
|
|
209
655
|
}
|
|
210
656
|
const governingRule = governingEntry ? ruleOf(governingEntry) : undefined;
|
|
211
657
|
const governingPolicyName = governingEntry?.policyName;
|
|
212
|
-
|
|
658
|
+
// Normalize the policy default action into the terminal allow|block|hold set.
|
|
659
|
+
// Previously any non-'hold' value was coerced to 'block', which wrongly
|
|
660
|
+
// blocked 'allow' and 'allow_log' defaults.
|
|
661
|
+
const defaultAction = normalizeDefaultAction(governingEntry?.defaultEnforcementAction ?? pv.default_enforcement_action);
|
|
213
662
|
const deniedTools = pv.denied_tools;
|
|
214
663
|
if (Array.isArray(deniedTools)) {
|
|
215
664
|
if (deniedTools.includes(toolName) || deniedTools.includes(action)) {
|
|
@@ -219,14 +668,31 @@ class ArmorIQSession {
|
|
|
219
668
|
(governingPolicyName
|
|
220
669
|
? `Tool '${action}' is denied by policy '${governingPolicyName}'`
|
|
221
670
|
: `Tool '${action}' is denied by policy`);
|
|
222
|
-
|
|
671
|
+
// Deny-precedence: an explicitly denied tool is never allowed, even when
|
|
672
|
+
// the default action is 'allow'. hold only if the policy default is hold.
|
|
673
|
+
const r = {
|
|
223
674
|
allowed: false,
|
|
224
|
-
action:
|
|
675
|
+
action: defaultAction === 'hold' ? 'hold' : 'block',
|
|
225
676
|
reason,
|
|
226
677
|
matchedPolicy: governingPolicyName,
|
|
227
678
|
};
|
|
679
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
680
|
+
return r;
|
|
228
681
|
}
|
|
229
682
|
}
|
|
683
|
+
// Honor the backend-baked resolved allow set: an explicit *empty*
|
|
684
|
+
// allowed_tools means the same-priority intersection removed every tool
|
|
685
|
+
// ⇒ deny-all, regardless of what any individual snapshot rule allows.
|
|
686
|
+
const bakedAllowedTools = pv.allowed_tools;
|
|
687
|
+
if (Array.isArray(bakedAllowedTools) && bakedAllowedTools.length === 0) {
|
|
688
|
+
const r = {
|
|
689
|
+
allowed: false,
|
|
690
|
+
action: 'block',
|
|
691
|
+
reason: `Tool '${action}' is not allowed by any policy in scope (empty resolved allow-list)`,
|
|
692
|
+
};
|
|
693
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
694
|
+
return r;
|
|
695
|
+
}
|
|
230
696
|
if (governingRule) {
|
|
231
697
|
const allowed = governingRule.allowedTools ?? [];
|
|
232
698
|
if (Array.isArray(allowed) && allowed.length > 0) {
|
|
@@ -234,40 +700,109 @@ class ArmorIQSession {
|
|
|
234
700
|
allowed.includes(action) ||
|
|
235
701
|
allowed.includes(toolName);
|
|
236
702
|
if (!ok) {
|
|
237
|
-
|
|
703
|
+
// Tool is outside this rule's explicit allow set. Fall back to the
|
|
704
|
+
// policy default action: an 'allow' default permits it, hold/block
|
|
705
|
+
// otherwise.
|
|
706
|
+
if (defaultAction === 'allow') {
|
|
707
|
+
const r = {
|
|
708
|
+
allowed: true,
|
|
709
|
+
action: 'allow',
|
|
710
|
+
reason: `Allowed by default enforcement action for policy '${governingPolicyName}'`,
|
|
711
|
+
matchedPolicy: governingPolicyName,
|
|
712
|
+
};
|
|
713
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
714
|
+
return r;
|
|
715
|
+
}
|
|
716
|
+
const r = {
|
|
238
717
|
allowed: false,
|
|
239
|
-
action:
|
|
718
|
+
action: defaultAction,
|
|
240
719
|
reason: `Tool '${action}' is not in the allowed tools for policy '${governingPolicyName}'`,
|
|
241
720
|
matchedPolicy: governingPolicyName,
|
|
242
721
|
};
|
|
722
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
723
|
+
return r;
|
|
243
724
|
}
|
|
244
725
|
}
|
|
245
726
|
}
|
|
246
|
-
else if (Array.isArray(snapshot) && snapshot.length > 0) {
|
|
247
|
-
const allowedTools = pv.allowed_tools;
|
|
248
|
-
if (Array.isArray(allowedTools) && allowedTools.length === 0) {
|
|
249
|
-
return {
|
|
250
|
-
allowed: false,
|
|
251
|
-
action: 'block',
|
|
252
|
-
reason: `Tool '${action}' is not allowed by any policy in scope`,
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
727
|
if (governingRule) {
|
|
257
728
|
const thresholdDecision = this.evaluateAmountThreshold(governingRule, toolArgs, action, mcp);
|
|
258
729
|
if (thresholdDecision) {
|
|
259
730
|
thresholdDecision.matchedPolicy = governingPolicyName;
|
|
731
|
+
this._emitEnforcePolicyCall(ctx, toolName, thresholdDecision, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
260
732
|
return thresholdDecision;
|
|
261
733
|
}
|
|
262
734
|
}
|
|
263
|
-
|
|
735
|
+
const r = {
|
|
264
736
|
allowed: true,
|
|
265
737
|
action: 'allow',
|
|
266
738
|
reason: 'Allowed by local policy evaluation',
|
|
267
739
|
matchedPolicy: governingPolicyName,
|
|
268
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
|
+
}
|
|
269
804
|
}
|
|
270
|
-
async
|
|
805
|
+
async _enforceSdkImpl(toolName, toolArgs, userEmail, ctx, spanId) {
|
|
271
806
|
if (!this.currentToken) {
|
|
272
807
|
throw new Error(`enforceSdk("${toolName}") called before startPlan().`);
|
|
273
808
|
}
|
|
@@ -277,11 +812,13 @@ class ArmorIQSession {
|
|
|
277
812
|
this.declaredTools.has(action) ||
|
|
278
813
|
this.declaredTools.has(`${resolvedMcp}__${action}`);
|
|
279
814
|
if (!inPlan) {
|
|
280
|
-
|
|
815
|
+
const r = {
|
|
281
816
|
allowed: false,
|
|
282
817
|
action: 'block',
|
|
283
818
|
reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
|
|
284
819
|
};
|
|
820
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
|
|
821
|
+
return r;
|
|
285
822
|
}
|
|
286
823
|
const internals = this.client._sessionInternals();
|
|
287
824
|
try {
|
|
@@ -302,31 +839,74 @@ class ArmorIQSession {
|
|
|
302
839
|
const matched = typeof data.matchedPolicy === 'object'
|
|
303
840
|
? data.matchedPolicy?.name
|
|
304
841
|
: data.matchedPolicy;
|
|
842
|
+
// Surface gating obligations metadata (additive). The backend has already
|
|
843
|
+
// collapsed gating into allowed/action; obligations explain *why*.
|
|
844
|
+
const obligations = Array.isArray(data.obligations)
|
|
845
|
+
? data.obligations
|
|
846
|
+
: [];
|
|
305
847
|
if (actionDecision === 'hold') {
|
|
306
|
-
|
|
848
|
+
const r = {
|
|
307
849
|
allowed: false,
|
|
308
850
|
action: 'hold',
|
|
309
851
|
reason: data.reason ?? data.message,
|
|
310
852
|
matchedPolicy: matched,
|
|
311
|
-
|
|
853
|
+
obligations,
|
|
854
|
+
};
|
|
855
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
|
|
856
|
+
return this.handleHold(toolName, toolArgs, r, userEmail);
|
|
312
857
|
}
|
|
313
|
-
|
|
858
|
+
const r = {
|
|
314
859
|
allowed,
|
|
315
860
|
action: actionDecision,
|
|
316
861
|
reason: data.reason ?? data.message,
|
|
317
862
|
matchedPolicy: matched,
|
|
863
|
+
obligations,
|
|
318
864
|
};
|
|
865
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
|
|
866
|
+
return r;
|
|
319
867
|
}
|
|
320
868
|
catch (e) {
|
|
321
869
|
console.error(`enforceSdk() failed: ${e.message}. Blocking tool call (fail-closed).`);
|
|
322
|
-
|
|
870
|
+
const r = {
|
|
323
871
|
allowed: false,
|
|
324
872
|
action: 'block',
|
|
325
873
|
reason: `enforce-unavailable: ${e.message}`,
|
|
326
874
|
};
|
|
875
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
|
|
876
|
+
return r;
|
|
327
877
|
}
|
|
328
878
|
}
|
|
329
|
-
|
|
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
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
async _enforceImpl(toolName, toolArgs, ctx, spanId) {
|
|
330
910
|
if (!this.currentToken) {
|
|
331
911
|
throw new Error(`enforce("${toolName}") called before startPlan().`);
|
|
332
912
|
}
|
|
@@ -336,11 +916,13 @@ class ArmorIQSession {
|
|
|
336
916
|
this.declaredTools.has(action) ||
|
|
337
917
|
this.declaredTools.has(`${resolvedMcp}__${action}`);
|
|
338
918
|
if (!inPlan) {
|
|
339
|
-
|
|
919
|
+
const r = {
|
|
340
920
|
allowed: false,
|
|
341
921
|
action: 'block',
|
|
342
922
|
reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
|
|
343
923
|
};
|
|
924
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
925
|
+
return r;
|
|
344
926
|
}
|
|
345
927
|
const internals = this.client._sessionInternals();
|
|
346
928
|
try {
|
|
@@ -367,175 +949,344 @@ class ArmorIQSession {
|
|
|
367
949
|
// Fail closed: only an explicit allow from a 2xx response permits the call.
|
|
368
950
|
// Any error status (403 or otherwise) or an ambiguous body blocks.
|
|
369
951
|
if (response.status >= 400) {
|
|
370
|
-
|
|
952
|
+
const r = {
|
|
371
953
|
allowed: false,
|
|
372
954
|
action: data.action ?? 'block',
|
|
373
955
|
reason: data.reason ?? data.message ?? `enforce-rejected: HTTP ${response.status}`,
|
|
374
956
|
matchedPolicy: policyName,
|
|
375
957
|
};
|
|
958
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
959
|
+
return r;
|
|
376
960
|
}
|
|
377
961
|
const allowedFlag = data.allowed === true;
|
|
378
962
|
const actionDecision = data.enforcementAction ?? data.action ?? (allowedFlag ? 'allow' : 'block');
|
|
379
|
-
|
|
963
|
+
const r = {
|
|
380
964
|
allowed: allowedFlag,
|
|
381
965
|
action: actionDecision,
|
|
382
966
|
reason: data.reason,
|
|
383
967
|
delegationId: data.delegation_id,
|
|
384
968
|
matchedPolicy: policyName,
|
|
969
|
+
obligations: Array.isArray(data.obligations) ? data.obligations : [],
|
|
385
970
|
};
|
|
971
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
972
|
+
return r;
|
|
386
973
|
}
|
|
387
974
|
catch (e) {
|
|
388
975
|
console.error(`enforce() failed: ${e.message}. Blocking tool call (fail-closed).`);
|
|
389
|
-
|
|
976
|
+
const r = {
|
|
390
977
|
allowed: false,
|
|
391
978
|
action: 'block',
|
|
392
979
|
reason: `enforce-unavailable: ${e.message}`,
|
|
393
980
|
};
|
|
981
|
+
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
982
|
+
return r;
|
|
394
983
|
}
|
|
395
984
|
}
|
|
396
985
|
async check(toolName, toolArgs, userEmail) {
|
|
397
|
-
|
|
398
|
-
|
|
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);
|
|
399
1027
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
+
}));
|
|
409
1040
|
}
|
|
410
|
-
return decision;
|
|
411
1041
|
}
|
|
412
|
-
const decision = await this.enforce(toolName, toolArgs);
|
|
413
|
-
if (decision.action !== 'hold')
|
|
414
|
-
return decision;
|
|
415
|
-
return this.handleHold(toolName, toolArgs, decision, userEmail);
|
|
416
1042
|
}
|
|
417
1043
|
// ─── Report / dispatch ─────────────────────────────────────────
|
|
418
1044
|
async report(toolName, toolArgs, result, opts) {
|
|
419
|
-
const o =
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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;
|
|
423
1066
|
try {
|
|
424
|
-
const
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
output =
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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;
|
|
452
1104
|
}
|
|
453
|
-
catch (
|
|
454
|
-
|
|
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
|
+
}
|
|
455
1135
|
}
|
|
456
|
-
this.stepIndex += 1;
|
|
457
1136
|
}
|
|
458
1137
|
async dispatch(toolName, toolArgs) {
|
|
459
|
-
|
|
460
|
-
|
|
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
|
+
}
|
|
461
1183
|
}
|
|
462
|
-
const { mcp, action } = this.toolNameParser(toolName);
|
|
463
|
-
const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
|
|
464
|
-
const result = await this.client.invoke(resolvedMcp, action, this.currentToken, toolArgs);
|
|
465
|
-
this.stepIndex += 1;
|
|
466
|
-
return result.result;
|
|
467
1184
|
}
|
|
468
1185
|
// ─── Helpers ────────────────────────────────────────────────────
|
|
469
1186
|
async handleHold(toolName, toolArgs, holdDecision, userEmail) {
|
|
470
|
-
const
|
|
471
|
-
const
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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;
|
|
482
1203
|
try {
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
+
}
|
|
488
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;
|
|
489
1230
|
}
|
|
490
|
-
catch {
|
|
491
|
-
// best-effort
|
|
492
|
-
}
|
|
493
|
-
return {
|
|
494
|
-
allowed: true,
|
|
495
|
-
action: 'allow',
|
|
496
|
-
reason: `Allowed by approved delegation ${approved.delegationId ?? ''}`.trim(),
|
|
497
|
-
delegationId: approved.delegationId,
|
|
498
|
-
matchedPolicy: holdDecision.matchedPolicy,
|
|
499
|
-
};
|
|
500
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;
|
|
501
1265
|
}
|
|
502
|
-
catch {
|
|
503
|
-
|
|
1266
|
+
catch (err) {
|
|
1267
|
+
caught = err;
|
|
1268
|
+
holdStatus = 'error';
|
|
1269
|
+
holdError = err.message;
|
|
1270
|
+
throw err;
|
|
504
1271
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
planId: this.currentToken?.planId,
|
|
523
|
-
intentReference: this.currentToken?.tokenId,
|
|
524
|
-
merkleRoot: (this.currentToken?.rawToken ?? {}).merkle_root,
|
|
525
|
-
reason: holdDecision.reason,
|
|
526
|
-
});
|
|
527
|
-
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
|
+
}
|
|
528
1289
|
}
|
|
529
|
-
catch (e) {
|
|
530
|
-
console.warn(`createDelegationRequest failed: ${e.message}`);
|
|
531
|
-
}
|
|
532
|
-
return {
|
|
533
|
-
allowed: false,
|
|
534
|
-
action: 'hold',
|
|
535
|
-
reason: holdDecision.reason ?? 'Pending approval',
|
|
536
|
-
delegationId,
|
|
537
|
-
matchedPolicy: holdDecision.matchedPolicy,
|
|
538
|
-
};
|
|
539
1290
|
}
|
|
540
1291
|
/**
|
|
541
1292
|
* Poll a held delegation until it is decided; non-blocking (async).
|
|
@@ -545,38 +1296,86 @@ class ArmorIQSession {
|
|
|
545
1296
|
* timeout/interval are in seconds (mirrors the Python SDK await_approval).
|
|
546
1297
|
*/
|
|
547
1298
|
async awaitApproval(delegationId, opts = {}) {
|
|
548
|
-
const
|
|
549
|
-
const
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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';
|
|
569
1352
|
}
|
|
570
|
-
throw e;
|
|
571
1353
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
+
}
|
|
575
1377
|
}
|
|
576
|
-
if (status === 'rejected')
|
|
577
|
-
return 'rejected';
|
|
578
1378
|
}
|
|
579
|
-
return 'timeout';
|
|
580
1379
|
}
|
|
581
1380
|
static extractAmount(args) {
|
|
582
1381
|
if (!args || typeof args !== 'object')
|
|
@@ -640,31 +1439,63 @@ class ArmorIQSession {
|
|
|
640
1439
|
* reanchor is off
|
|
641
1440
|
*/
|
|
642
1441
|
async flushReanchor() {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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;
|
|
657
1456
|
try {
|
|
658
|
-
|
|
659
|
-
|
|
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 };
|
|
660
1474
|
}
|
|
661
1475
|
catch (err) {
|
|
662
|
-
|
|
663
|
-
// session lifecycle.
|
|
1476
|
+
caught = err;
|
|
664
1477
|
const msg = err instanceof Error ? err.message : String(err);
|
|
665
1478
|
console.warn(`[armoriq] flushReanchor failed: ${msg}`);
|
|
666
1479
|
return { fired: false };
|
|
667
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
|
+
}
|
|
668
1499
|
}
|
|
669
1500
|
get currentTokenValue() {
|
|
670
1501
|
return this.currentToken;
|
|
@@ -672,6 +1503,64 @@ class ArmorIQSession {
|
|
|
672
1503
|
get currentMode() {
|
|
673
1504
|
return this.mode;
|
|
674
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
|
+
}
|
|
675
1564
|
}
|
|
676
1565
|
exports.ArmorIQSession = ArmorIQSession;
|
|
677
1566
|
//# sourceMappingURL=session.js.map
|