@armoriq/sdk-dev 0.6.8 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -1
- package/dist/_version.d.ts +1 -1
- package/dist/_version.js +1 -1
- package/dist/cli/commands/auth.d.ts +12 -0
- package/dist/cli/commands/auth.d.ts.map +1 -1
- package/dist/cli/commands/auth.js +460 -42
- package/dist/cli/commands/auth.js.map +1 -1
- package/dist/cli/index.js +0 -0
- package/dist/client.d.ts +8 -15
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +20 -18
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +0 -17
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -19
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -14
- package/dist/index.js.map +1 -1
- package/dist/integrations/google_adk.d.ts +168 -6
- package/dist/integrations/google_adk.d.ts.map +1 -1
- package/dist/integrations/google_adk.js +797 -103
- package/dist/integrations/google_adk.js.map +1 -1
- package/dist/integrations/langchain.d.ts +51 -1
- package/dist/integrations/langchain.d.ts.map +1 -1
- package/dist/integrations/langchain.js +563 -30
- package/dist/integrations/langchain.js.map +1 -1
- package/dist/integrations/strands.d.ts +48 -0
- package/dist/integrations/strands.d.ts.map +1 -1
- package/dist/integrations/strands.js +441 -27
- package/dist/integrations/strands.js.map +1 -1
- package/dist/models.d.ts +2 -2
- package/dist/models.d.ts.map +1 -1
- package/dist/observability/content-capture.d.ts +75 -0
- package/dist/observability/content-capture.d.ts.map +1 -0
- package/dist/observability/content-capture.js +339 -0
- package/dist/observability/content-capture.js.map +1 -0
- package/dist/observability/index.d.ts +6 -7
- package/dist/observability/index.d.ts.map +1 -1
- package/dist/observability/index.js +18 -27
- package/dist/observability/index.js.map +1 -1
- package/dist/observability/otel-config.d.ts +47 -0
- package/dist/observability/otel-config.d.ts.map +1 -0
- package/dist/observability/otel-config.js +268 -0
- package/dist/observability/otel-config.js.map +1 -0
- package/dist/observability/otel-export-ceiling.d.ts +96 -0
- package/dist/observability/otel-export-ceiling.d.ts.map +1 -0
- package/dist/observability/otel-export-ceiling.js +264 -0
- package/dist/observability/otel-export-ceiling.js.map +1 -0
- package/dist/observability/otel-runtime.d.ts +103 -0
- package/dist/observability/otel-runtime.d.ts.map +1 -0
- package/dist/observability/otel-runtime.js +668 -0
- package/dist/observability/otel-runtime.js.map +1 -0
- package/dist/observability/otel-session.d.ts +168 -0
- package/dist/observability/otel-session.d.ts.map +1 -0
- package/dist/observability/otel-session.js +621 -0
- package/dist/observability/otel-session.js.map +1 -0
- package/dist/observability/otel-shutdown.d.ts +17 -0
- package/dist/observability/otel-shutdown.d.ts.map +1 -0
- package/dist/observability/otel-shutdown.js +54 -0
- package/dist/observability/otel-shutdown.js.map +1 -0
- package/dist/observability/policy-lease.d.ts +22 -0
- package/dist/observability/policy-lease.d.ts.map +1 -0
- package/dist/observability/policy-lease.js +102 -0
- package/dist/observability/policy-lease.js.map +1 -0
- package/dist/plan_builder.d.ts +5 -4
- package/dist/plan_builder.d.ts.map +1 -1
- package/dist/plan_builder.js +14 -15
- package/dist/plan_builder.js.map +1 -1
- package/dist/session.d.ts +61 -93
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +388 -812
- package/dist/session.js.map +1 -1
- package/dist/token_usage.d.ts +11 -18
- package/dist/token_usage.d.ts.map +1 -1
- package/dist/token_usage.js +29 -94
- package/dist/token_usage.js.map +1 -1
- package/dist/tool_name.d.ts +18 -0
- package/dist/tool_name.d.ts.map +1 -0
- package/dist/tool_name.js +29 -0
- package/dist/tool_name.js.map +1 -0
- package/dist/tool_push.d.ts +28 -0
- package/dist/tool_push.d.ts.map +1 -0
- package/dist/tool_push.js +151 -0
- package/dist/tool_push.js.map +1 -0
- package/dist/tool_registry.d.ts +100 -0
- package/dist/tool_registry.d.ts.map +1 -0
- package/dist/tool_registry.js +440 -0
- package/dist/tool_registry.js.map +1 -0
- package/dist/tool_schema.d.ts +22 -0
- package/dist/tool_schema.d.ts.map +1 -0
- package/dist/tool_schema.js +163 -0
- package/dist/tool_schema.js.map +1 -0
- package/package.json +13 -7
- package/dist/integrations/microsoft_copilot.d.ts +0 -84
- package/dist/integrations/microsoft_copilot.d.ts.map +0 -1
- package/dist/integrations/microsoft_copilot.js +0 -126
- package/dist/integrations/microsoft_copilot.js.map +0 -1
package/dist/session.js
CHANGED
|
@@ -18,12 +18,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
18
18
|
exports.ArmorIQSession = void 0;
|
|
19
19
|
exports.normalizeDefaultAction = normalizeDefaultAction;
|
|
20
20
|
const crypto_1 = require("crypto");
|
|
21
|
-
const
|
|
21
|
+
const tool_push_1 = require("./tool_push");
|
|
22
|
+
const tool_registry_1 = require("./tool_registry");
|
|
22
23
|
const crypto_verify_1 = require("./crypto_verify");
|
|
23
24
|
const exceptions_1 = require("./exceptions");
|
|
24
25
|
const models_1 = require("./models");
|
|
25
26
|
const plan_builder_1 = require("./plan_builder");
|
|
26
27
|
const observability_1 = require("./observability");
|
|
28
|
+
const _version_1 = require("./_version");
|
|
29
|
+
function isValidUuid(value) {
|
|
30
|
+
return (typeof value === 'string' &&
|
|
31
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value));
|
|
32
|
+
}
|
|
27
33
|
/**
|
|
28
34
|
* Normalize a policy-authoring default-enforcement-action value into the
|
|
29
35
|
* SDK-facing terminal enum. Mirrors the backend's enforce-boundary normalization
|
|
@@ -54,72 +60,48 @@ const MULTIUSER_PLACEHOLDER = '__sdk_multiuser__';
|
|
|
54
60
|
function realIdentity(value) {
|
|
55
61
|
return !value || value === MULTIUSER_PLACEHOLDER ? undefined : value;
|
|
56
62
|
}
|
|
63
|
+
const AUDIT_LABEL = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,127}$/;
|
|
64
|
+
const AUDIT_SECRET = /(?:\beyJ[A-Za-z0-9_-]{5,}\.|\b(?:ak|sk|pk|rk|ghp|github_pat)_[A-Za-z0-9_-]{8,}\b|bearer|secret|password|credential|private[_-]?key)/i;
|
|
65
|
+
function safeAuditLabel(value) {
|
|
66
|
+
return typeof value === 'string' && AUDIT_LABEL.test(value) && !AUDIT_SECRET.test(value)
|
|
67
|
+
? value
|
|
68
|
+
: 'unknown';
|
|
69
|
+
}
|
|
70
|
+
function auditPersistencePayload(input) {
|
|
71
|
+
const durationMs = typeof input.durationMs === 'number' &&
|
|
72
|
+
Number.isFinite(input.durationMs) &&
|
|
73
|
+
input.durationMs >= 0
|
|
74
|
+
? input.durationMs
|
|
75
|
+
: null;
|
|
76
|
+
const action = safeAuditLabel(input.action);
|
|
77
|
+
return {
|
|
78
|
+
plan_id: safeAuditLabel(input.token?.planId),
|
|
79
|
+
step_index: Number.isFinite(input.stepIndex) && input.stepIndex >= 0 ? Math.floor(input.stepIndex) : 0,
|
|
80
|
+
action,
|
|
81
|
+
tool: action,
|
|
82
|
+
mcp: safeAuditLabel(input.mcp),
|
|
83
|
+
input: {},
|
|
84
|
+
status: input.status === 'success' || input.status === 'failed' || input.status === 'error'
|
|
85
|
+
? input.status
|
|
86
|
+
: 'error',
|
|
87
|
+
duration_ms: durationMs,
|
|
88
|
+
is_delegated: typeof input.isDelegated === 'boolean' ? input.isDelegated : null,
|
|
89
|
+
executed_at: new Date().toISOString(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
57
92
|
/** The rule body of a policy snapshot entry, whichever shape it arrived in. */
|
|
58
93
|
function ruleOf(entry) {
|
|
59
94
|
if (!entry || typeof entry !== 'object')
|
|
60
95
|
return entry;
|
|
61
96
|
return entry.memberRule ?? entry.clientRule ?? entry.rules ?? entry;
|
|
62
97
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (value === undefined || value === null)
|
|
66
|
-
return value;
|
|
67
|
-
let json;
|
|
68
|
-
try {
|
|
69
|
-
json = typeof value === 'string' ? value : JSON.stringify(value);
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
json = String(value);
|
|
73
|
-
}
|
|
74
|
-
const bytes = Buffer.byteLength(json, 'utf8');
|
|
75
|
-
if (bytes <= TRUNCATE_BYTES)
|
|
76
|
-
return value;
|
|
77
|
-
const sliced = json.slice(0, TRUNCATE_BYTES);
|
|
78
|
-
return `${sliced}…(truncated, original ${bytes} bytes)`;
|
|
79
|
-
}
|
|
80
|
-
function safeObs(fn) {
|
|
81
|
-
try {
|
|
82
|
-
return fn();
|
|
83
|
-
}
|
|
84
|
-
catch (err) {
|
|
85
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
86
|
-
console.warn(`[armoriq] observability emit failed (continuing): ${msg}`);
|
|
87
|
-
return undefined;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
function makeSpanRecord(ctx, name, attributes, status = 'ok', durationMs = null, 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: 'span',
|
|
97
|
-
name,
|
|
98
|
-
startTime: now,
|
|
99
|
-
endTime: now,
|
|
100
|
-
durationMs,
|
|
101
|
-
status,
|
|
102
|
-
attributes,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
function makeEventRecord(ctx, name, attributes, parentSpanId) {
|
|
106
|
-
const now = new Date().toISOString();
|
|
107
|
-
return {
|
|
108
|
-
id: (0, crypto_1.randomUUID)(),
|
|
109
|
-
parentSpanId: parentSpanId ?? null,
|
|
110
|
-
sessionId: ctx.sessionId,
|
|
111
|
-
kind: 'event',
|
|
112
|
-
name,
|
|
113
|
-
startTime: now,
|
|
114
|
-
endTime: now,
|
|
115
|
-
durationMs: 0,
|
|
116
|
-
status: 'ok',
|
|
117
|
-
attributes,
|
|
118
|
-
};
|
|
98
|
+
function isCancellation(error) {
|
|
99
|
+
return (error instanceof Error && (error.name === 'AbortError' || /cancelled/i.test(error.message)));
|
|
119
100
|
}
|
|
120
101
|
class ArmorIQSession {
|
|
121
102
|
userEmail;
|
|
122
103
|
client;
|
|
104
|
+
pendingPolicyTelemetry = new Set();
|
|
123
105
|
toolNameParser;
|
|
124
106
|
defaultMcpName;
|
|
125
107
|
validitySeconds;
|
|
@@ -142,11 +124,15 @@ class ArmorIQSession {
|
|
|
142
124
|
// MCPs we have already tried to load semantic metadata for (see
|
|
143
125
|
// warmToolMetadata) — one attempt each, whether it succeeded or not.
|
|
144
126
|
metadataWarmed = new Set();
|
|
145
|
-
|
|
127
|
+
/** Native OTel bridge. It never changes the application's global provider. */
|
|
128
|
+
otel = null;
|
|
129
|
+
/** Pending delegation spans are ended only by the approval terminal path. */
|
|
130
|
+
pendingDelegationOperations = new Map();
|
|
131
|
+
delegationTerminalOperations = new Map();
|
|
132
|
+
completedDelegations = new Set();
|
|
146
133
|
/**
|
|
147
134
|
* This session's one stable observability session id — stamped onto every
|
|
148
|
-
* trace this session emits
|
|
149
|
-
* `ObservabilityConfig.sessionId`). Always a valid UUID; the backend's
|
|
135
|
+
* trace this session emits. Always a valid UUID; the backend's
|
|
150
136
|
* `obs_sessions.id` column is UUID-typed, so a non-UUID id (e.g. the
|
|
151
137
|
* legacy `contextId` default of the literal string `'default'`) can never
|
|
152
138
|
* form a session and traces stamped with it can never be grouped.
|
|
@@ -157,19 +143,6 @@ class ArmorIQSession {
|
|
|
157
143
|
* 3. A freshly minted `crypto.randomUUID()` (default — no arg needed).
|
|
158
144
|
*/
|
|
159
145
|
sessionId;
|
|
160
|
-
/**
|
|
161
|
-
* Model A (trace-per-plan): the ONE active observability trace for the
|
|
162
|
-
* current plan. Every chokepoint (enforce/check/report/dispatch/...)
|
|
163
|
-
* records a container span under this trace via `ensurePlanTrace()`
|
|
164
|
-
* instead of opening its own trace — matching Langfuse/OTel's
|
|
165
|
-
* Session → Trace → Observation hierarchy (one IAP plan = one trace).
|
|
166
|
-
*
|
|
167
|
-
* Lifecycle: opened by `ensurePlanTrace()` (called from `startPlan()` or
|
|
168
|
-
* lazily by any chokepoint invoked before `startPlan()` — back-compat);
|
|
169
|
-
* ended by `endPlanTrace()`, called on the NEXT `startPlan()`, on
|
|
170
|
-
* `close()`/`dispose()`, and on `flushObservability()`.
|
|
171
|
-
*/
|
|
172
|
-
activePlanTrace = null;
|
|
173
146
|
constructor(client, opts) {
|
|
174
147
|
this.client = client;
|
|
175
148
|
const o = opts ?? {};
|
|
@@ -182,288 +155,60 @@ class ArmorIQSession {
|
|
|
182
155
|
const granEnv = (typeof process !== 'undefined' && process.env?.ARMORIQ_REANCHOR_GRANULARITY) || '';
|
|
183
156
|
this.reanchorGranularity =
|
|
184
157
|
o.reanchorGranularity ?? (granEnv === 'deferred' ? 'deferred' : 'eager');
|
|
185
|
-
this.sessionId =
|
|
158
|
+
this.sessionId = isValidUuid(o.sessionId)
|
|
186
159
|
? o.sessionId
|
|
187
|
-
:
|
|
160
|
+
: isValidUuid(o.contextId)
|
|
188
161
|
? o.contextId
|
|
189
162
|
: (0, crypto_1.randomUUID)();
|
|
190
163
|
const obsCfg = o.observability;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
else {
|
|
164
|
+
const nativeConfig = (0, observability_1.resolveOtelConfig)(obsCfg ?? {});
|
|
165
|
+
if (nativeConfig.enabled) {
|
|
195
166
|
const internals = this.client._sessionInternals();
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
// reads as genuinely absent on the trace rather than shipping an empty
|
|
211
|
-
// string or a fake agent id.
|
|
212
|
-
const nonEmpty = (v) => realIdentity(v) ?? null;
|
|
213
|
-
// `internals.userId` is `client.userEmailOverride || client.userId`
|
|
214
|
-
// (client.ts) — an arbitrary identity string (often an email address,
|
|
215
|
-
// or the multi-user placeholder `'__sdk_multiuser__'`), NOT guaranteed
|
|
216
|
-
// to be a UUID. The observability trace's `userId` column/wire schema
|
|
217
|
-
// IS UUID-typed (`z.uuid()` on the backend) — forwarding a non-UUID
|
|
218
|
-
// value 400s the entire ingest batch at the shipper. Validate before
|
|
219
|
-
// forwarding; a non-UUID userId is dropped to `null` rather than
|
|
220
|
-
// breaking ingestion (`agentId` has no such constraint — see below).
|
|
221
|
-
const validUserId = (v) => (0, observability_1.isValidUuid)(v) ? v : null;
|
|
222
|
-
const merged = {
|
|
223
|
-
...(0, config_1.defaultObservabilityConfig)(),
|
|
224
|
-
...(obsCfg ?? {}),
|
|
225
|
-
enabled: true,
|
|
226
|
-
endpoint,
|
|
227
|
-
apiKey,
|
|
228
|
-
product,
|
|
229
|
-
httpClient,
|
|
230
|
-
// The session's one stable UUID — applied to every trace this
|
|
231
|
-
// recorder starts unless a chokepoint explicitly overrides it (none
|
|
232
|
-
// do; see the `startTrace(...)` call sites below, all of which now
|
|
233
|
-
// pass `this.sessionId` directly for clarity/back-compat).
|
|
234
|
-
sessionId: obsCfg?.sessionId ?? this.sessionId,
|
|
235
|
-
// Denormalize the client's configured user/agent identity onto every
|
|
236
|
-
// trace this recorder starts, unless the caller explicitly overrides
|
|
237
|
-
// via `observability.userId`/`observability.agentId`. `internals`
|
|
238
|
-
// already carries both (see `ArmorIQClient._sessionInternals()`);
|
|
239
|
-
// previously neither was forwarded here, so every SDK-emitted trace
|
|
240
|
-
// shipped with `agentId: null` even when the client was constructed
|
|
241
|
-
// with a real agent id. `agentId` is a free-form string on the wire
|
|
242
|
-
// (backend column is `text`, not a UUID FK) — no UUID validation
|
|
243
|
-
// required, unlike `sessionId`/`userId`.
|
|
244
|
-
userId: obsCfg?.userId ?? validUserId(internals.userId),
|
|
245
|
-
agentId: obsCfg?.agentId ?? nonEmpty(internals.agentId),
|
|
246
|
-
};
|
|
247
|
-
this.obs = new observability_1.ObservabilityRecorder(merged);
|
|
167
|
+
const runtime = new observability_1.ArmorIQTelemetryRuntime({
|
|
168
|
+
backendEndpoint: internals.backendEndpoint,
|
|
169
|
+
apiKey: internals.apiKey,
|
|
170
|
+
sdkVersion: _version_1.VERSION,
|
|
171
|
+
options: obsCfg,
|
|
172
|
+
});
|
|
173
|
+
this.otel = new observability_1.OtelSession(runtime, {
|
|
174
|
+
sessionId: this.sessionId,
|
|
175
|
+
traceName: 'armoriq.agent.run',
|
|
176
|
+
agentId: realIdentity(internals.agentId) ?? null,
|
|
177
|
+
// Never use a user email as an external Langfuse user id implicitly.
|
|
178
|
+
// Callers may opt in with an explicit safe `observability.userId`.
|
|
179
|
+
userId: obsCfg?.userId ?? null,
|
|
180
|
+
});
|
|
248
181
|
}
|
|
249
182
|
}
|
|
250
|
-
/**
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
* `summarizeTranscriptUsage(path, recorder, ctx)` / `client.captureTranscriptTokens({
|
|
254
|
-
* ..., observabilityRecorder, planTraceCtx })` so transcript-derived generation spans are
|
|
255
|
-
* attributed to the correct session — the SDK no longer keeps a
|
|
256
|
-
* process-global "active recorder" (see #4: concurrent sessions would
|
|
257
|
-
* otherwise misattribute usage to whichever session constructed last).
|
|
258
|
-
*/
|
|
259
|
-
get observabilityRecorder() {
|
|
260
|
-
return this.obs;
|
|
183
|
+
/** Native request-scoped OTel bridge for framework adapters. */
|
|
184
|
+
get otelSession() {
|
|
185
|
+
return this.otel;
|
|
261
186
|
}
|
|
262
187
|
/**
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
* session.activePlanTraceContext)` (or via
|
|
267
|
-
* `client.captureTranscriptTokens({ ..., planTraceCtx: session.activePlanTraceContext })`)
|
|
268
|
-
* so transcript-derived generation spans nest under the SAME plan trace as
|
|
269
|
-
* every other chokepoint this session recorded, instead of opening a
|
|
270
|
-
* standalone `llm.usage.report` trace.
|
|
188
|
+
* Explicitly await the observability policy control-plane refresh. Request
|
|
189
|
+
* hooks must not call this: their native telemetry remains fail-open and
|
|
190
|
+
* latency-free while the SDK refreshes in the background.
|
|
271
191
|
*/
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
// ─── Model A: active-plan-trace lifecycle ──────────────────────
|
|
276
|
-
/**
|
|
277
|
-
* Return the session's active plan trace, lazily opening one if none is
|
|
278
|
-
* active. This is the ONE trace every chokepoint's container span nests
|
|
279
|
-
* under (Model A: trace-per-plan). Returns `null` when observability is
|
|
280
|
-
* disabled — every call site already treats a null ctx as "skip emit".
|
|
281
|
-
*
|
|
282
|
-
* Back-compat (plan §"Global Constraints"): a chokepoint invoked with no
|
|
283
|
-
* prior `startPlan()` (e.g. a standalone `enforceLocal()`/`report()` call)
|
|
284
|
-
* still needs to emit — so this opens a plan trace lazily on first use
|
|
285
|
-
* rather than requiring `startPlan()` to have run first.
|
|
286
|
-
*/
|
|
287
|
-
ensurePlanTrace(name = 'iap.plan', attrs = {}) {
|
|
288
|
-
if (!this.obs)
|
|
289
|
-
return null;
|
|
290
|
-
if (this.activePlanTrace)
|
|
291
|
-
return this.activePlanTrace;
|
|
292
|
-
const ctx = safeObs(() => (0, observability_1.startTrace)(this.obs, name, attrs, this.sessionId)) ?? null;
|
|
293
|
-
this.activePlanTrace = ctx;
|
|
294
|
-
return ctx;
|
|
295
|
-
}
|
|
296
|
-
/**
|
|
297
|
-
* End the active plan trace (if any) and clear it. Called on the NEXT
|
|
298
|
-
* `startPlan()` (closing the PRIOR plan before opening a new one),
|
|
299
|
-
* `close()`/`dispose()`, and `flushObservability()` — so the in-flight
|
|
300
|
-
* plan trace always ships before the process exits or a new plan begins.
|
|
301
|
-
*/
|
|
302
|
-
endPlanTrace(status = 'ok') {
|
|
303
|
-
if (!this.obs || !this.activePlanTrace)
|
|
304
|
-
return;
|
|
305
|
-
const ctx = this.activePlanTrace;
|
|
306
|
-
this.activePlanTrace = null;
|
|
307
|
-
safeObs(() => (0, observability_1.endTrace)(this.obs, ctx, { status }));
|
|
192
|
+
async refreshObservabilityPolicy() {
|
|
193
|
+
await this.otel?.refreshPolicy();
|
|
308
194
|
}
|
|
309
195
|
// ─── Plan capture ──────────────────────────────────────────────
|
|
310
196
|
async reanchorCall(intentToken, plan, reason) {
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
const spanId = ctx && this.obs
|
|
314
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
315
|
-
name: 'iap.reanchor',
|
|
316
|
-
attributes: { reason, planStepCount: plan?.steps?.length ?? null },
|
|
317
|
-
})) ?? undefined
|
|
318
|
-
: undefined;
|
|
319
|
-
let caught;
|
|
320
|
-
let trustId;
|
|
321
|
-
try {
|
|
322
|
-
const result = await this.client.reanchor(intentToken, plan, reason);
|
|
323
|
-
trustId = result?.trustId;
|
|
324
|
-
if (ctx) {
|
|
325
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.reanchor.delta', {
|
|
326
|
-
kind: 'event',
|
|
327
|
-
message: 'delta recorded',
|
|
328
|
-
level: 'info',
|
|
329
|
-
trustId: trustId ?? null,
|
|
330
|
-
}, spanId)));
|
|
331
|
-
}
|
|
332
|
-
return { trustId };
|
|
333
|
-
}
|
|
334
|
-
catch (err) {
|
|
335
|
-
caught = err;
|
|
336
|
-
if (ctx) {
|
|
337
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.reanchor.delta_failed', {
|
|
338
|
-
kind: 'event',
|
|
339
|
-
message: `delta failed: ${err.message}`,
|
|
340
|
-
level: 'error',
|
|
341
|
-
}, spanId)));
|
|
342
|
-
}
|
|
343
|
-
throw err;
|
|
344
|
-
}
|
|
345
|
-
finally {
|
|
346
|
-
if (ctx) {
|
|
347
|
-
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
348
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.reanchor.http', {
|
|
349
|
-
kind: 'span',
|
|
350
|
-
trustId: trustId ?? null,
|
|
351
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
352
|
-
}, caught ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
353
|
-
if (spanId) {
|
|
354
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
355
|
-
status: caught ? 'error' : 'ok',
|
|
356
|
-
durationMs: computedDuration,
|
|
357
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
358
|
-
}));
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
}
|
|
197
|
+
const result = await this.client.reanchor(intentToken, plan, reason);
|
|
198
|
+
return { trustId: result?.trustId };
|
|
362
199
|
}
|
|
363
200
|
async getIntentTokenCall(planCapture, validitySeconds) {
|
|
364
|
-
|
|
365
|
-
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
366
|
-
const spanId = ctx && this.obs
|
|
367
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
368
|
-
name: 'iap.intent_token.mint',
|
|
369
|
-
attributes: { planStepCount: planCapture?.plan?.steps?.length ?? null },
|
|
370
|
-
})) ?? undefined
|
|
371
|
-
: undefined;
|
|
372
|
-
let caught;
|
|
373
|
-
let token;
|
|
374
|
-
try {
|
|
375
|
-
token = await this.client.getIntentToken(planCapture, undefined, validitySeconds);
|
|
376
|
-
if (ctx) {
|
|
377
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.intent_token.minted', {
|
|
378
|
-
kind: 'event',
|
|
379
|
-
message: 'new intent token minted',
|
|
380
|
-
level: 'info',
|
|
381
|
-
tokenId: token?.tokenId ?? null,
|
|
382
|
-
planId: token?.planId ?? null,
|
|
383
|
-
}, spanId)));
|
|
384
|
-
}
|
|
385
|
-
return token;
|
|
386
|
-
}
|
|
387
|
-
catch (err) {
|
|
388
|
-
caught = err;
|
|
389
|
-
throw err;
|
|
390
|
-
}
|
|
391
|
-
finally {
|
|
392
|
-
if (ctx) {
|
|
393
|
-
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
394
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.intent_token.mint.http', {
|
|
395
|
-
kind: 'span',
|
|
396
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
397
|
-
}, caught ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
398
|
-
if (spanId) {
|
|
399
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
400
|
-
status: caught ? 'error' : 'ok',
|
|
401
|
-
durationMs: computedDuration,
|
|
402
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
403
|
-
}));
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
}
|
|
201
|
+
return this.client.getIntentToken(planCapture, undefined, validitySeconds);
|
|
407
202
|
}
|
|
408
203
|
async startPlan(toolCalls, goal) {
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (this.obs)
|
|
416
|
-
safeObs(() => this.endPlanTrace('ok'));
|
|
417
|
-
const ctx = this.obs
|
|
418
|
-
? this.ensurePlanTrace('iap.plan', {
|
|
419
|
-
toolCount: toolCalls?.length ?? 0,
|
|
420
|
-
goal: goal ?? null,
|
|
421
|
-
mode: this.mode,
|
|
422
|
-
})
|
|
423
|
-
: null;
|
|
424
|
-
const spanId = ctx && this.obs
|
|
425
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
426
|
-
name: 'iap.plan.start',
|
|
427
|
-
attributes: {
|
|
428
|
-
toolCount: toolCalls?.length ?? 0,
|
|
429
|
-
goal: goal ?? null,
|
|
430
|
-
mode: this.mode,
|
|
431
|
-
},
|
|
432
|
-
})) ?? undefined
|
|
433
|
-
: undefined;
|
|
434
|
-
let caught;
|
|
435
|
-
let planTraceStatus = 'ok';
|
|
436
|
-
let planTraceError;
|
|
437
|
-
try {
|
|
438
|
-
const result = await this._startPlanImpl(toolCalls, goal, ctx);
|
|
439
|
-
if (ctx) {
|
|
440
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.plan.grew', {
|
|
441
|
-
kind: 'event',
|
|
442
|
-
message: `plan captured: ${toolCalls?.length ?? 0} tool(s)`,
|
|
443
|
-
level: 'info',
|
|
444
|
-
toolCount: toolCalls?.length ?? 0,
|
|
445
|
-
})));
|
|
446
|
-
}
|
|
447
|
-
return result;
|
|
448
|
-
}
|
|
449
|
-
catch (err) {
|
|
450
|
-
caught = err;
|
|
451
|
-
planTraceStatus = 'error';
|
|
452
|
-
planTraceError = err.message;
|
|
453
|
-
throw err;
|
|
454
|
-
}
|
|
455
|
-
finally {
|
|
456
|
-
if (ctx && spanId) {
|
|
457
|
-
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
458
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
459
|
-
status: planTraceStatus === 'error' ? 'error' : 'ok',
|
|
460
|
-
durationMs: computedDuration,
|
|
461
|
-
errorMessage: planTraceError,
|
|
462
|
-
}));
|
|
463
|
-
}
|
|
464
|
-
}
|
|
204
|
+
const result = await this._startPlanImpl(toolCalls, goal);
|
|
205
|
+
await this.otel?.startPlan({
|
|
206
|
+
itemCount: toolCalls.length,
|
|
207
|
+
planId: result.planId ?? result.tokenId,
|
|
208
|
+
});
|
|
209
|
+
return result;
|
|
465
210
|
}
|
|
466
|
-
async _startPlanImpl(toolCalls, goal
|
|
211
|
+
async _startPlanImpl(toolCalls, goal) {
|
|
467
212
|
if (!toolCalls || toolCalls.length === 0) {
|
|
468
213
|
throw new Error('startPlan called with no tool calls.');
|
|
469
214
|
}
|
|
@@ -563,58 +308,21 @@ class ArmorIQSession {
|
|
|
563
308
|
return token;
|
|
564
309
|
}
|
|
565
310
|
// ─── Policy enforcement ────────────────────────────────────────
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
* under `iap.check`'s own container span instead of both sitting as
|
|
571
|
-
* siblings directly under the plan trace (Task 3 risk: "check → enforce
|
|
572
|
-
* double-open" — `iap.check`'s span wraps the dispatched enforce span,
|
|
573
|
-
* it never opens a second redundant plan trace or duplicate container).
|
|
574
|
-
*/
|
|
575
|
-
enforceLocal(toolName, toolArgs, parentContainerSpanId) {
|
|
576
|
-
const startNs = this.obs ? performance.now() : 0;
|
|
577
|
-
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
578
|
-
const spanId = ctx && this.obs
|
|
579
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
580
|
-
name: 'iap.enforce.local',
|
|
581
|
-
attributes: { toolName, toolArgs: truncateForSpan(toolArgs) },
|
|
582
|
-
parentSpanId: parentContainerSpanId ?? null,
|
|
583
|
-
})) ?? undefined
|
|
584
|
-
: undefined;
|
|
585
|
-
let caught;
|
|
586
|
-
try {
|
|
587
|
-
return this._enforceLocalImpl(toolName, toolArgs, ctx, spanId);
|
|
588
|
-
}
|
|
589
|
-
catch (err) {
|
|
590
|
-
caught = err;
|
|
591
|
-
throw err;
|
|
592
|
-
}
|
|
593
|
-
finally {
|
|
594
|
-
if (ctx && spanId) {
|
|
595
|
-
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
596
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
597
|
-
status: caught ? 'error' : 'ok',
|
|
598
|
-
durationMs: computedDuration,
|
|
599
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
600
|
-
}));
|
|
601
|
-
}
|
|
602
|
-
}
|
|
311
|
+
enforceLocal(toolName, toolArgs) {
|
|
312
|
+
const decision = this._enforceLocalImpl(toolName, toolArgs);
|
|
313
|
+
this.trackPolicyTelemetry(this.recordPolicyDecision(toolName, toolArgs, decision));
|
|
314
|
+
return decision;
|
|
603
315
|
}
|
|
604
|
-
_enforceLocalImpl(toolName, toolArgs
|
|
316
|
+
_enforceLocalImpl(toolName, toolArgs) {
|
|
605
317
|
if (!this.currentToken) {
|
|
606
|
-
|
|
318
|
+
return {
|
|
607
319
|
allowed: false,
|
|
608
320
|
action: 'block',
|
|
609
321
|
reason: 'No intent token — call startPlan() first',
|
|
610
322
|
};
|
|
611
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
|
|
612
|
-
return r;
|
|
613
323
|
}
|
|
614
324
|
if (models_1.IntentToken.isExpired(this.currentToken)) {
|
|
615
|
-
|
|
616
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
|
|
617
|
-
return r;
|
|
325
|
+
return { allowed: false, action: 'block', reason: 'token-expired' };
|
|
618
326
|
}
|
|
619
327
|
// The local decision is read from the token's policy fields, so the token
|
|
620
328
|
// must be cryptographically authentic first - otherwise a forged token
|
|
@@ -624,22 +332,18 @@ class ArmorIQSession {
|
|
|
624
332
|
// the token. Fail CLOSED if the key is unavailable or the signature is bad.
|
|
625
333
|
const iapKey = this.client.iapPublicKeySync();
|
|
626
334
|
if (!iapKey || !(0, crypto_verify_1.verifyEdDSAJwt)(this.currentToken.jwtToken ?? '', iapKey)) {
|
|
627
|
-
|
|
628
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
|
|
629
|
-
return r;
|
|
335
|
+
return { allowed: false, action: 'block', reason: 'token-signature-invalid' };
|
|
630
336
|
}
|
|
631
337
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
632
338
|
const inPlan = this.declaredTools.has(toolName) ||
|
|
633
339
|
this.declaredTools.has(action) ||
|
|
634
340
|
this.declaredTools.has(`${mcp}__${action}`);
|
|
635
341
|
if (!inPlan) {
|
|
636
|
-
|
|
342
|
+
return {
|
|
637
343
|
allowed: false,
|
|
638
344
|
action: 'block',
|
|
639
345
|
reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
|
|
640
346
|
};
|
|
641
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp }, spanId);
|
|
642
|
-
return r;
|
|
643
347
|
}
|
|
644
348
|
const pv = (this.currentToken.policyValidation ?? {});
|
|
645
349
|
const snapshot = (this.currentToken.policySnapshot ?? []);
|
|
@@ -684,14 +388,12 @@ class ArmorIQSession {
|
|
|
684
388
|
: `Tool '${action}' is denied by policy`);
|
|
685
389
|
// Deny-precedence: an explicitly denied tool is never allowed, even when
|
|
686
390
|
// the default action is 'allow'. hold only if the policy default is hold.
|
|
687
|
-
|
|
391
|
+
return {
|
|
688
392
|
allowed: false,
|
|
689
393
|
action: defaultAction === 'hold' ? 'hold' : 'block',
|
|
690
394
|
reason,
|
|
691
395
|
matchedPolicy: governingPolicyName,
|
|
692
396
|
};
|
|
693
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
694
|
-
return r;
|
|
695
397
|
}
|
|
696
398
|
}
|
|
697
399
|
// Honor the backend-baked resolved allow set: an explicit *empty*
|
|
@@ -699,42 +401,34 @@ class ArmorIQSession {
|
|
|
699
401
|
// ⇒ deny-all, regardless of what any individual snapshot rule allows.
|
|
700
402
|
const bakedAllowedTools = pv.allowed_tools;
|
|
701
403
|
if (Array.isArray(bakedAllowedTools) && bakedAllowedTools.length === 0) {
|
|
702
|
-
|
|
404
|
+
return {
|
|
703
405
|
allowed: false,
|
|
704
406
|
action: 'block',
|
|
705
407
|
reason: `Tool '${action}' is not allowed by any policy in scope (empty resolved allow-list)`,
|
|
706
408
|
};
|
|
707
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
708
|
-
return r;
|
|
709
409
|
}
|
|
710
410
|
if (governingRule) {
|
|
711
411
|
const allowed = governingRule.allowedTools ?? [];
|
|
712
412
|
if (Array.isArray(allowed) && allowed.length > 0) {
|
|
713
|
-
const ok = allowed.includes('*') ||
|
|
714
|
-
allowed.includes(action) ||
|
|
715
|
-
allowed.includes(toolName);
|
|
413
|
+
const ok = allowed.includes('*') || allowed.includes(action) || allowed.includes(toolName);
|
|
716
414
|
if (!ok) {
|
|
717
415
|
// Tool is outside this rule's explicit allow set. Fall back to the
|
|
718
416
|
// policy default action: an 'allow' default permits it, hold/block
|
|
719
417
|
// otherwise.
|
|
720
418
|
if (defaultAction === 'allow') {
|
|
721
|
-
|
|
419
|
+
return {
|
|
722
420
|
allowed: true,
|
|
723
421
|
action: 'allow',
|
|
724
422
|
reason: `Allowed by default enforcement action for policy '${governingPolicyName}'`,
|
|
725
423
|
matchedPolicy: governingPolicyName,
|
|
726
424
|
};
|
|
727
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
728
|
-
return r;
|
|
729
425
|
}
|
|
730
|
-
|
|
426
|
+
return {
|
|
731
427
|
allowed: false,
|
|
732
428
|
action: defaultAction,
|
|
733
429
|
reason: `Tool '${action}' is not in the allowed tools for policy '${governingPolicyName}'`,
|
|
734
430
|
matchedPolicy: governingPolicyName,
|
|
735
431
|
};
|
|
736
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
737
|
-
return r;
|
|
738
432
|
}
|
|
739
433
|
}
|
|
740
434
|
}
|
|
@@ -742,102 +436,83 @@ class ArmorIQSession {
|
|
|
742
436
|
const thresholdDecision = this.evaluateAmountThreshold(governingRule, toolArgs, action, mcp);
|
|
743
437
|
if (thresholdDecision) {
|
|
744
438
|
thresholdDecision.matchedPolicy = governingPolicyName;
|
|
745
|
-
this._emitEnforcePolicyCall(ctx, toolName, thresholdDecision, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
746
439
|
return thresholdDecision;
|
|
747
440
|
}
|
|
748
441
|
}
|
|
749
|
-
|
|
442
|
+
return {
|
|
750
443
|
allowed: true,
|
|
751
444
|
action: 'allow',
|
|
752
445
|
reason: 'Allowed by local policy evaluation',
|
|
753
446
|
matchedPolicy: governingPolicyName,
|
|
754
447
|
};
|
|
755
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
756
|
-
return r;
|
|
757
448
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
policyId: null,
|
|
769
|
-
policyName: result.matchedPolicy ?? null,
|
|
770
|
-
decision,
|
|
771
|
-
reason: result.reason ?? null,
|
|
772
|
-
source,
|
|
773
|
-
input: { toolName },
|
|
774
|
-
output: {
|
|
775
|
-
allowed: result.allowed,
|
|
776
|
-
action: result.action,
|
|
777
|
-
matchedPolicy: result.matchedPolicy ?? null,
|
|
778
|
-
delegationId: result.delegationId ?? null,
|
|
779
|
-
},
|
|
780
|
-
policyHash: null,
|
|
781
|
-
policyVersion: null,
|
|
782
|
-
matchedRuleId: null,
|
|
783
|
-
dataClasses: [],
|
|
784
|
-
enforcementAction,
|
|
785
|
-
obligations: extras.obligations ?? result.obligations ?? [],
|
|
786
|
-
delegationId: result.delegationId ?? null,
|
|
787
|
-
}, parentSpanId));
|
|
449
|
+
/**
|
|
450
|
+
* A server named in the tool itself wins; the plan's action map only answers
|
|
451
|
+
* for unqualified names. The map keeps one server per action, so with
|
|
452
|
+
* `stripe__search` and `github__search` both planned it would otherwise send
|
|
453
|
+
* the last one for either call.
|
|
454
|
+
*/
|
|
455
|
+
resolveMcp(toolName, mcp, action) {
|
|
456
|
+
if (toolName !== action)
|
|
457
|
+
return mcp;
|
|
458
|
+
return this.mcpByAction.get(action) ?? mcp;
|
|
788
459
|
}
|
|
789
|
-
/**
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
})) ?? undefined
|
|
799
|
-
: undefined;
|
|
800
|
-
let caught;
|
|
460
|
+
/**
|
|
461
|
+
* The two fields the tool inventory attributes a call with. `wireName` is the
|
|
462
|
+
* name the framework dispatched, untouched. `mcpServer` is sent only when the
|
|
463
|
+
* SDK actually knows the server (the name carried it, or the bootstrap toolMap
|
|
464
|
+
* named it); a default fallback is a guess and is left out so the backend
|
|
465
|
+
* attributes from its own declarations.
|
|
466
|
+
*/
|
|
467
|
+
/** `toolsSnapshotHash` when this client has an inventory; conmap answers `inventoryUnknown` if it holds no snapshot under it. */
|
|
468
|
+
snapshotHashField() {
|
|
801
469
|
try {
|
|
802
|
-
|
|
470
|
+
const key = (0, tool_registry_1.agentKeyFor)(this.client);
|
|
471
|
+
return key ? { toolsSnapshotHash: (0, tool_registry_1.defaultToolRegistry)().snapshotHash(key) } : {};
|
|
803
472
|
}
|
|
804
|
-
catch
|
|
805
|
-
|
|
806
|
-
throw err;
|
|
473
|
+
catch {
|
|
474
|
+
return {};
|
|
807
475
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
815
|
-
}));
|
|
816
|
-
}
|
|
476
|
+
}
|
|
477
|
+
inventoryFields(toolName, action, resolvedMcp) {
|
|
478
|
+
const fields = { wireName: toolName };
|
|
479
|
+
if (toolName !== action ||
|
|
480
|
+
(resolvedMcp && resolvedMcp !== this.defaultMcpName && resolvedMcp !== 'unknown')) {
|
|
481
|
+
fields.mcpServer = resolvedMcp;
|
|
817
482
|
}
|
|
483
|
+
return fields;
|
|
484
|
+
}
|
|
485
|
+
async enforceSdk(toolName, toolArgs, userEmail) {
|
|
486
|
+
const decision = await this._enforceSdkImpl(toolName, toolArgs, userEmail);
|
|
487
|
+
await this.recordPolicyDecision(toolName, toolArgs, decision);
|
|
488
|
+
return decision;
|
|
818
489
|
}
|
|
819
|
-
async _enforceSdkImpl(toolName, toolArgs, userEmail
|
|
490
|
+
async _enforceSdkImpl(toolName, toolArgs, userEmail) {
|
|
820
491
|
if (!this.currentToken) {
|
|
821
492
|
throw new Error(`enforceSdk("${toolName}") called before startPlan().`);
|
|
822
493
|
}
|
|
823
494
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
824
|
-
const resolvedMcp = this.
|
|
495
|
+
const resolvedMcp = this.resolveMcp(toolName, mcp, action);
|
|
825
496
|
const inPlan = this.declaredTools.has(toolName) ||
|
|
826
497
|
this.declaredTools.has(action) ||
|
|
827
498
|
this.declaredTools.has(`${resolvedMcp}__${action}`);
|
|
828
499
|
if (!inPlan) {
|
|
829
|
-
|
|
500
|
+
return {
|
|
830
501
|
allowed: false,
|
|
831
502
|
action: 'block',
|
|
832
503
|
reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
|
|
833
504
|
};
|
|
834
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
|
|
835
|
-
return r;
|
|
836
505
|
}
|
|
837
506
|
const internals = this.client._sessionInternals();
|
|
838
507
|
try {
|
|
839
508
|
const response = await internals.httpClient.post(`${internals.backendEndpoint}/iap/sdk/enforce`, {
|
|
509
|
+
// The server the call resolved to. Without it a policy rule bound to
|
|
510
|
+
// a server never matches, and shared tool names stay ambiguous to the
|
|
511
|
+
// inventory. Matches the Python SDK (armoriq-sdk-customer#139).
|
|
512
|
+
mcp: resolvedMcp,
|
|
840
513
|
tool: action,
|
|
514
|
+
...this.inventoryFields(toolName, action, resolvedMcp),
|
|
515
|
+
...this.snapshotHashField(),
|
|
841
516
|
arguments: toolArgs,
|
|
842
517
|
intent_token: this.currentToken.rawToken,
|
|
843
518
|
policy_snapshot: this.currentToken.policySnapshot,
|
|
@@ -848,11 +523,11 @@ class ArmorIQSession {
|
|
|
848
523
|
timeout: 10000,
|
|
849
524
|
});
|
|
850
525
|
const data = response.data ?? {};
|
|
526
|
+
if (data.inventoryUnknown === true)
|
|
527
|
+
(0, tool_push_1.requestPush)(this.client);
|
|
851
528
|
const allowed = data.allowed === true;
|
|
852
529
|
const actionDecision = data.enforcementAction ?? (allowed ? 'allow' : 'block');
|
|
853
|
-
const matched = typeof data.matchedPolicy === 'object'
|
|
854
|
-
? data.matchedPolicy?.name
|
|
855
|
-
: data.matchedPolicy;
|
|
530
|
+
const matched = typeof data.matchedPolicy === 'object' ? data.matchedPolicy?.name : data.matchedPolicy;
|
|
856
531
|
// Surface gating obligations metadata (additive). The backend has already
|
|
857
532
|
// collapsed gating into allowed/action; obligations explain *why*.
|
|
858
533
|
const obligations = Array.isArray(data.obligations)
|
|
@@ -866,77 +541,45 @@ class ArmorIQSession {
|
|
|
866
541
|
matchedPolicy: matched,
|
|
867
542
|
obligations,
|
|
868
543
|
};
|
|
869
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
|
|
870
544
|
return this.handleHold(toolName, toolArgs, r, userEmail);
|
|
871
545
|
}
|
|
872
|
-
|
|
546
|
+
return {
|
|
873
547
|
allowed,
|
|
874
548
|
action: actionDecision,
|
|
875
549
|
reason: data.reason ?? data.message,
|
|
876
550
|
matchedPolicy: matched,
|
|
877
551
|
obligations,
|
|
878
552
|
};
|
|
879
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
|
|
880
|
-
return r;
|
|
881
553
|
}
|
|
882
554
|
catch (e) {
|
|
883
555
|
console.error(`enforceSdk() failed: ${e.message}. Blocking tool call (fail-closed).`);
|
|
884
|
-
|
|
556
|
+
return {
|
|
885
557
|
allowed: false,
|
|
886
558
|
action: 'block',
|
|
887
559
|
reason: `enforce-unavailable: ${e.message}`,
|
|
888
560
|
};
|
|
889
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
|
|
890
|
-
return r;
|
|
891
561
|
}
|
|
892
562
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
const spanId = ctx && this.obs
|
|
898
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
899
|
-
name: 'iap.enforce.proxy',
|
|
900
|
-
attributes: { toolName, toolArgs: truncateForSpan(toolArgs) },
|
|
901
|
-
parentSpanId: parentContainerSpanId ?? null,
|
|
902
|
-
})) ?? undefined
|
|
903
|
-
: undefined;
|
|
904
|
-
let caught;
|
|
905
|
-
try {
|
|
906
|
-
return await this._enforceImpl(toolName, toolArgs, ctx, spanId);
|
|
907
|
-
}
|
|
908
|
-
catch (err) {
|
|
909
|
-
caught = err;
|
|
910
|
-
throw err;
|
|
911
|
-
}
|
|
912
|
-
finally {
|
|
913
|
-
if (ctx && spanId) {
|
|
914
|
-
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
915
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
916
|
-
status: caught ? 'error' : 'ok',
|
|
917
|
-
durationMs: computedDuration,
|
|
918
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
919
|
-
}));
|
|
920
|
-
}
|
|
921
|
-
}
|
|
563
|
+
async enforce(toolName, toolArgs) {
|
|
564
|
+
const decision = await this._enforceImpl(toolName, toolArgs);
|
|
565
|
+
await this.recordPolicyDecision(toolName, toolArgs, decision);
|
|
566
|
+
return decision;
|
|
922
567
|
}
|
|
923
|
-
async _enforceImpl(toolName, toolArgs
|
|
568
|
+
async _enforceImpl(toolName, toolArgs) {
|
|
924
569
|
if (!this.currentToken) {
|
|
925
570
|
throw new Error(`enforce("${toolName}") called before startPlan().`);
|
|
926
571
|
}
|
|
927
572
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
928
|
-
const resolvedMcp = this.
|
|
573
|
+
const resolvedMcp = this.resolveMcp(toolName, mcp, action);
|
|
929
574
|
const inPlan = this.declaredTools.has(toolName) ||
|
|
930
575
|
this.declaredTools.has(action) ||
|
|
931
576
|
this.declaredTools.has(`${resolvedMcp}__${action}`);
|
|
932
577
|
if (!inPlan) {
|
|
933
|
-
|
|
578
|
+
return {
|
|
934
579
|
allowed: false,
|
|
935
580
|
action: 'block',
|
|
936
581
|
reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
|
|
937
582
|
};
|
|
938
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
939
|
-
return r;
|
|
940
583
|
}
|
|
941
584
|
const internals = this.client._sessionInternals();
|
|
942
585
|
try {
|
|
@@ -963,18 +606,16 @@ class ArmorIQSession {
|
|
|
963
606
|
// Fail closed: only an explicit allow from a 2xx response permits the call.
|
|
964
607
|
// Any error status (403 or otherwise) or an ambiguous body blocks.
|
|
965
608
|
if (response.status >= 400) {
|
|
966
|
-
|
|
609
|
+
return {
|
|
967
610
|
allowed: false,
|
|
968
611
|
action: data.action ?? 'block',
|
|
969
612
|
reason: data.reason ?? data.message ?? `enforce-rejected: HTTP ${response.status}`,
|
|
970
613
|
matchedPolicy: policyName,
|
|
971
614
|
};
|
|
972
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
973
|
-
return r;
|
|
974
615
|
}
|
|
975
616
|
const allowedFlag = data.allowed === true;
|
|
976
617
|
const actionDecision = data.enforcementAction ?? data.action ?? (allowedFlag ? 'allow' : 'block');
|
|
977
|
-
|
|
618
|
+
return {
|
|
978
619
|
allowed: allowedFlag,
|
|
979
620
|
action: actionDecision,
|
|
980
621
|
reason: data.reason,
|
|
@@ -982,78 +623,82 @@ class ArmorIQSession {
|
|
|
982
623
|
matchedPolicy: policyName,
|
|
983
624
|
obligations: Array.isArray(data.obligations) ? data.obligations : [],
|
|
984
625
|
};
|
|
985
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
986
|
-
return r;
|
|
987
626
|
}
|
|
988
627
|
catch (e) {
|
|
989
628
|
console.error(`enforce() failed: ${e.message}. Blocking tool call (fail-closed).`);
|
|
990
|
-
|
|
629
|
+
return {
|
|
991
630
|
allowed: false,
|
|
992
631
|
action: 'block',
|
|
993
632
|
reason: `enforce-unavailable: ${e.message}`,
|
|
994
633
|
};
|
|
995
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
996
|
-
return r;
|
|
997
634
|
}
|
|
998
635
|
}
|
|
999
|
-
async check(toolName, toolArgs, userEmail) {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
toolArgs: truncateForSpan(toolArgs),
|
|
1015
|
-
mode: this.mode,
|
|
1016
|
-
userEmail: userEmail ?? null,
|
|
1017
|
-
},
|
|
1018
|
-
})) ?? undefined
|
|
1019
|
-
: undefined;
|
|
1020
|
-
let caught;
|
|
1021
|
-
try {
|
|
1022
|
-
if (this.mode === 'sdk') {
|
|
1023
|
-
return await this.enforceSdk(toolName, toolArgs, userEmail, spanId);
|
|
1024
|
-
}
|
|
1025
|
-
if (this.mode === 'local') {
|
|
1026
|
-
await this.warmToolMetadata(toolName);
|
|
1027
|
-
const decision = this.enforceLocal(toolName, toolArgs, spanId);
|
|
1028
|
-
if (decision.action === 'hold') {
|
|
1029
|
-
return {
|
|
1030
|
-
...decision,
|
|
1031
|
-
action: 'block',
|
|
1032
|
-
reason: (decision.reason ?? 'requires approval') +
|
|
1033
|
-
' — switch ARMORIQ_MODE=proxy to enable approval workflows for this action.',
|
|
1034
|
-
};
|
|
1035
|
-
}
|
|
1036
|
-
return decision;
|
|
636
|
+
async check(toolName, toolArgs, userEmail, telemetry = {}) {
|
|
637
|
+
let decision;
|
|
638
|
+
if (this.mode === 'sdk') {
|
|
639
|
+
decision = await this._enforceSdkImpl(toolName, toolArgs, userEmail);
|
|
640
|
+
}
|
|
641
|
+
else if (this.mode === 'local') {
|
|
642
|
+
await this.warmToolMetadata(toolName);
|
|
643
|
+
decision = this._enforceLocalImpl(toolName, toolArgs);
|
|
644
|
+
if (decision.action === 'hold') {
|
|
645
|
+
decision = {
|
|
646
|
+
...decision,
|
|
647
|
+
action: 'block',
|
|
648
|
+
reason: (decision.reason ?? 'requires approval') +
|
|
649
|
+
' — switch ARMORIQ_MODE=proxy to enable approval workflows for this action.',
|
|
650
|
+
};
|
|
1037
651
|
}
|
|
1038
|
-
const decision = await this.enforce(toolName, toolArgs, spanId);
|
|
1039
|
-
if (decision.action !== 'hold')
|
|
1040
|
-
return decision;
|
|
1041
|
-
return this.handleHold(toolName, toolArgs, decision, userEmail);
|
|
1042
|
-
}
|
|
1043
|
-
catch (err) {
|
|
1044
|
-
caught = err;
|
|
1045
|
-
throw err;
|
|
1046
652
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
status: caught ? 'error' : 'ok',
|
|
1052
|
-
durationMs: computedDuration,
|
|
1053
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
1054
|
-
}));
|
|
653
|
+
else {
|
|
654
|
+
decision = await this._enforceImpl(toolName, toolArgs);
|
|
655
|
+
if (decision.action === 'hold') {
|
|
656
|
+
decision = await this.handleHold(toolName, toolArgs, decision, userEmail);
|
|
1055
657
|
}
|
|
1056
658
|
}
|
|
659
|
+
if (telemetry.emitOtel !== false) {
|
|
660
|
+
await this.recordPolicyDecision(toolName, toolArgs, decision);
|
|
661
|
+
}
|
|
662
|
+
return decision;
|
|
663
|
+
}
|
|
664
|
+
async recordPolicyDecision(toolName, toolArgs, decision) {
|
|
665
|
+
if (!this.otel)
|
|
666
|
+
return;
|
|
667
|
+
const policy = decision;
|
|
668
|
+
const failed = typeof decision.reason === 'string' && decision.reason.startsWith('enforce-unavailable:');
|
|
669
|
+
const policyDecision = decision.allowed
|
|
670
|
+
? 'allow'
|
|
671
|
+
: decision.action === 'hold'
|
|
672
|
+
? 'hold'
|
|
673
|
+
: decision.action === 'block'
|
|
674
|
+
? 'block'
|
|
675
|
+
: undefined;
|
|
676
|
+
await this.otel.recordPolicy({ toolName, arguments: toolArgs }, {
|
|
677
|
+
...(!failed && policyDecision ? { decision: policyDecision } : {}),
|
|
678
|
+
policyName: decision.matchedPolicy,
|
|
679
|
+
...(typeof policy.policyId === 'string' ? { policyId: policy.policyId } : {}),
|
|
680
|
+
...(typeof policy.policyVersion === 'string'
|
|
681
|
+
? { policyVersion: policy.policyVersion }
|
|
682
|
+
: {}),
|
|
683
|
+
...(typeof policy.policySource === 'string' ? { policySource: policy.policySource } : {}),
|
|
684
|
+
...(typeof policy.matchedRuleId === 'string'
|
|
685
|
+
? { matchedRuleId: policy.matchedRuleId }
|
|
686
|
+
: {}),
|
|
687
|
+
policyReasonCode: decision.reason,
|
|
688
|
+
...(failed ? { error: new Error('policy evaluation failed') } : {}),
|
|
689
|
+
...(typeof policy.defaultAction === 'string'
|
|
690
|
+
? { defaultAction: policy.defaultAction }
|
|
691
|
+
: {}),
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
trackPolicyTelemetry(operation) {
|
|
695
|
+
const tracked = operation
|
|
696
|
+
.catch(() => undefined)
|
|
697
|
+
.finally(() => this.pendingPolicyTelemetry.delete(tracked));
|
|
698
|
+
this.pendingPolicyTelemetry.add(tracked);
|
|
699
|
+
}
|
|
700
|
+
async drainPolicyTelemetry() {
|
|
701
|
+
await Promise.all([...this.pendingPolicyTelemetry]);
|
|
1057
702
|
}
|
|
1058
703
|
// ─── Report / dispatch ─────────────────────────────────────────
|
|
1059
704
|
async report(toolName, toolArgs, result, opts) {
|
|
@@ -1063,51 +708,28 @@ class ArmorIQSession {
|
|
|
1063
708
|
? Math.round(opts.durationMs)
|
|
1064
709
|
: opts?.durationMs,
|
|
1065
710
|
};
|
|
1066
|
-
const startNs = this.obs ? performance.now() : 0;
|
|
1067
|
-
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
1068
|
-
const spanId = ctx && this.obs
|
|
1069
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
1070
|
-
name: 'tool.report',
|
|
1071
|
-
attributes: {
|
|
1072
|
-
toolName,
|
|
1073
|
-
toolInput: truncateForSpan(toolArgs),
|
|
1074
|
-
status: o.status ?? 'success',
|
|
1075
|
-
},
|
|
1076
|
-
})) ?? undefined
|
|
1077
|
-
: undefined;
|
|
1078
|
-
let caught;
|
|
1079
711
|
let reportStatus = 'ok';
|
|
1080
712
|
let reportError;
|
|
713
|
+
const reportItemOrdinal = this.stepIndex;
|
|
714
|
+
let reportOperation = this.operationForTool(toolName, undefined, reportItemOrdinal, o.operation);
|
|
1081
715
|
try {
|
|
1082
716
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
1083
|
-
const resolvedMcp = this.
|
|
717
|
+
const resolvedMcp = this.resolveMcp(toolName, mcp, action);
|
|
718
|
+
if (o.emitOtel !== false) {
|
|
719
|
+
reportOperation = this.operationForTool(toolName, resolvedMcp, reportItemOrdinal, o.operation);
|
|
720
|
+
}
|
|
1084
721
|
const internals = this.client._sessionInternals();
|
|
1085
722
|
try {
|
|
1086
723
|
const token = this.currentToken;
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
output = { text: result };
|
|
1091
|
-
else if (result === null || result === undefined)
|
|
1092
|
-
output = {};
|
|
1093
|
-
await internals.httpClient.post(`${internals.backendEndpoint}/iap/audit`, {
|
|
1094
|
-
token: token?.jwtToken ?? token?.tokenId ?? 'unknown',
|
|
1095
|
-
plan_id: token?.planId ?? token?.tokenId ?? 'unknown',
|
|
1096
|
-
step_index: this.stepIndex,
|
|
724
|
+
await internals.httpClient.post(`${internals.backendEndpoint}/iap/audit`, auditPersistencePayload({
|
|
725
|
+
token,
|
|
726
|
+
stepIndex: this.stepIndex,
|
|
1097
727
|
action,
|
|
1098
|
-
tool: action,
|
|
1099
728
|
mcp: resolvedMcp,
|
|
1100
|
-
input: toolArgs,
|
|
1101
|
-
output,
|
|
1102
729
|
status: o.status ?? 'success',
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
delegated_by: o.delegatedBy,
|
|
1107
|
-
user_email: userEmail,
|
|
1108
|
-
delegated_to: o.delegatedTo,
|
|
1109
|
-
executed_at: new Date().toISOString(),
|
|
1110
|
-
}, {
|
|
730
|
+
durationMs: o.durationMs,
|
|
731
|
+
isDelegated: o.isDelegated,
|
|
732
|
+
}), {
|
|
1111
733
|
headers: { 'X-API-Key': internals.apiKey, 'Content-Type': 'application/json' },
|
|
1112
734
|
timeout: 5000,
|
|
1113
735
|
});
|
|
@@ -1118,108 +740,75 @@ class ArmorIQSession {
|
|
|
1118
740
|
this.stepIndex += 1;
|
|
1119
741
|
}
|
|
1120
742
|
catch (err) {
|
|
1121
|
-
caught = err;
|
|
1122
743
|
reportStatus = 'error';
|
|
1123
744
|
reportError = err.message;
|
|
1124
745
|
throw err;
|
|
1125
746
|
}
|
|
1126
747
|
finally {
|
|
1127
|
-
if (
|
|
1128
|
-
|
|
1129
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'tool.report', {
|
|
1130
|
-
kind: 'span',
|
|
748
|
+
if (o.emitOtel !== false) {
|
|
749
|
+
await this.otel?.recordTool({
|
|
1131
750
|
toolName,
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
}, reportStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
1142
|
-
if (spanId) {
|
|
1143
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1144
|
-
status: reportStatus === 'error' ? 'error' : 'ok',
|
|
1145
|
-
durationMs: computedDuration,
|
|
1146
|
-
errorMessage: reportError,
|
|
1147
|
-
}));
|
|
1148
|
-
}
|
|
751
|
+
itemOrdinal: reportItemOrdinal,
|
|
752
|
+
arguments: toolArgs,
|
|
753
|
+
operation: reportOperation,
|
|
754
|
+
}, {
|
|
755
|
+
outcome: reportStatus === 'ok' && (o.status ?? 'success') === 'success' ? 'success' : 'error',
|
|
756
|
+
durationMs: o.durationMs,
|
|
757
|
+
error: reportError,
|
|
758
|
+
result,
|
|
759
|
+
});
|
|
1149
760
|
}
|
|
1150
761
|
}
|
|
1151
762
|
}
|
|
1152
763
|
async dispatch(toolName, toolArgs) {
|
|
1153
|
-
const startNs = this.obs ? performance.now() : 0;
|
|
1154
|
-
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
1155
|
-
const spanId = ctx && this.obs
|
|
1156
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
1157
|
-
name: 'tool.dispatch',
|
|
1158
|
-
attributes: { toolName, toolInput: truncateForSpan(toolArgs) },
|
|
1159
|
-
})) ?? undefined
|
|
1160
|
-
: undefined;
|
|
1161
|
-
let caught;
|
|
1162
764
|
let dispatchResult;
|
|
1163
765
|
let dispatchStatus = 'ok';
|
|
1164
766
|
let dispatchError;
|
|
767
|
+
let nativeTool;
|
|
1165
768
|
try {
|
|
1166
769
|
if (!this.currentToken) {
|
|
1167
770
|
throw new Error(`dispatch("${toolName}") called before startPlan().`);
|
|
1168
771
|
}
|
|
1169
772
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
1170
|
-
const resolvedMcp = this.
|
|
773
|
+
const resolvedMcp = this.resolveMcp(toolName, mcp, action);
|
|
774
|
+
nativeTool = await this.otel?.beginTool({
|
|
775
|
+
toolName: action,
|
|
776
|
+
itemOrdinal: this.stepIndex,
|
|
777
|
+
arguments: toolArgs,
|
|
778
|
+
operation: {
|
|
779
|
+
category: 'mcp',
|
|
780
|
+
name: 'mcp.execute',
|
|
781
|
+
toolType: 'mcp',
|
|
782
|
+
mcpServer: resolvedMcp,
|
|
783
|
+
},
|
|
784
|
+
});
|
|
1171
785
|
dispatchResult = await this.client.invoke(resolvedMcp, action, this.currentToken, toolArgs);
|
|
1172
786
|
this.stepIndex += 1;
|
|
1173
787
|
return dispatchResult?.result;
|
|
1174
788
|
}
|
|
1175
789
|
catch (err) {
|
|
1176
|
-
caught = err;
|
|
1177
790
|
dispatchStatus = 'error';
|
|
1178
791
|
dispatchError = err.message;
|
|
1179
792
|
throw err;
|
|
1180
793
|
}
|
|
1181
794
|
finally {
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
}, dispatchStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
1190
|
-
if (spanId) {
|
|
1191
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1192
|
-
status: dispatchStatus === 'error' ? 'error' : 'ok',
|
|
1193
|
-
durationMs: computedDuration,
|
|
1194
|
-
errorMessage: dispatchError,
|
|
1195
|
-
}));
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
795
|
+
await this.otel?.endTool(nativeTool ?? { span: null, name: 'mcp.execute' }, {
|
|
796
|
+
outcome: dispatchStatus === 'ok' ? 'success' : 'error',
|
|
797
|
+
error: dispatchError,
|
|
798
|
+
...(dispatchStatus === 'ok'
|
|
799
|
+
? { result: dispatchResult?.result }
|
|
800
|
+
: {}),
|
|
801
|
+
});
|
|
1198
802
|
}
|
|
1199
803
|
}
|
|
1200
804
|
// ─── Helpers ────────────────────────────────────────────────────
|
|
1201
805
|
async handleHold(toolName, toolArgs, holdDecision, userEmail) {
|
|
1202
|
-
const startNs = this.obs ? performance.now() : 0;
|
|
1203
|
-
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
1204
|
-
const spanId = ctx && this.obs
|
|
1205
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
1206
|
-
name: 'delegation.hold',
|
|
1207
|
-
attributes: {
|
|
1208
|
-
toolName,
|
|
1209
|
-
toolArgs: truncateForSpan(toolArgs),
|
|
1210
|
-
matchedPolicy: holdDecision.matchedPolicy ?? null,
|
|
1211
|
-
},
|
|
1212
|
-
})) ?? undefined
|
|
1213
|
-
: undefined;
|
|
1214
|
-
let caught;
|
|
1215
806
|
let result;
|
|
1216
|
-
|
|
1217
|
-
let holdError;
|
|
1218
|
-
try {
|
|
807
|
+
{
|
|
1219
808
|
const internals = this.client._sessionInternals();
|
|
1220
809
|
const email = userEmail ?? internals.userId ?? 'unknown@armoriq';
|
|
1221
810
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
1222
|
-
const resolvedMcp = this.
|
|
811
|
+
const resolvedMcp = this.resolveMcp(toolName, mcp, action);
|
|
1223
812
|
const rawAmount = ArmorIQSession.extractAmount(toolArgs) ?? 0;
|
|
1224
813
|
const safeAmount = typeof rawAmount === 'number' && rawAmount >= 0.01 ? rawAmount : 0.01;
|
|
1225
814
|
try {
|
|
@@ -1270,6 +859,16 @@ class ArmorIQSession {
|
|
|
1270
859
|
catch (e) {
|
|
1271
860
|
console.warn(`createDelegationRequest failed: ${e.message}`);
|
|
1272
861
|
}
|
|
862
|
+
if (delegationId) {
|
|
863
|
+
const operation = await this.otel?.beginOperation({
|
|
864
|
+
category: 'delegation',
|
|
865
|
+
name: 'delegation.wait',
|
|
866
|
+
delegationId,
|
|
867
|
+
delegationOutcome: 'pending',
|
|
868
|
+
});
|
|
869
|
+
if (operation?.span)
|
|
870
|
+
this.pendingDelegationOperations.set(delegationId, operation);
|
|
871
|
+
}
|
|
1273
872
|
result = {
|
|
1274
873
|
allowed: false,
|
|
1275
874
|
action: 'hold',
|
|
@@ -1280,30 +879,6 @@ class ArmorIQSession {
|
|
|
1280
879
|
};
|
|
1281
880
|
return result;
|
|
1282
881
|
}
|
|
1283
|
-
catch (err) {
|
|
1284
|
-
caught = err;
|
|
1285
|
-
holdStatus = 'error';
|
|
1286
|
-
holdError = err.message;
|
|
1287
|
-
throw err;
|
|
1288
|
-
}
|
|
1289
|
-
finally {
|
|
1290
|
-
if (ctx) {
|
|
1291
|
-
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
1292
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'delegation.hold', {
|
|
1293
|
-
kind: 'span',
|
|
1294
|
-
delegationId: result?.delegationId ?? null,
|
|
1295
|
-
decision: result?.action ?? 'hold',
|
|
1296
|
-
errorMessage: holdError,
|
|
1297
|
-
}, holdStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
1298
|
-
if (spanId) {
|
|
1299
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1300
|
-
status: holdStatus === 'error' ? 'error' : 'ok',
|
|
1301
|
-
durationMs: computedDuration,
|
|
1302
|
-
errorMessage: holdError,
|
|
1303
|
-
}));
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
882
|
}
|
|
1308
883
|
/**
|
|
1309
884
|
* Poll a held delegation until it is decided; non-blocking (async).
|
|
@@ -1313,16 +888,13 @@ class ArmorIQSession {
|
|
|
1313
888
|
* timeout/interval are in seconds (mirrors the Python SDK await_approval).
|
|
1314
889
|
*/
|
|
1315
890
|
async awaitApproval(delegationId, opts = {}) {
|
|
1316
|
-
|
|
1317
|
-
const
|
|
1318
|
-
const
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
: undefined;
|
|
1324
|
-
let caught;
|
|
1325
|
-
let pollOutcome = 'timeout';
|
|
891
|
+
let delegationOutcome = 'expired';
|
|
892
|
+
const approvalStarted = performance.now();
|
|
893
|
+
const nativeApproval = await this.otel?.beginOperation({
|
|
894
|
+
category: 'approval',
|
|
895
|
+
name: 'approval.wait',
|
|
896
|
+
delegationId,
|
|
897
|
+
});
|
|
1326
898
|
try {
|
|
1327
899
|
const timeout = opts.timeout ?? 300;
|
|
1328
900
|
const interval = opts.interval ?? 5;
|
|
@@ -1330,19 +902,15 @@ class ArmorIQSession {
|
|
|
1330
902
|
const email = opts.userEmail || this.client.userId || 'unknown@armoriq';
|
|
1331
903
|
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
|
|
1332
904
|
let waited = 0;
|
|
1333
|
-
let pollCount = 0;
|
|
1334
905
|
while (waited < timeout) {
|
|
1335
906
|
await sleep(interval);
|
|
1336
907
|
waited += interval;
|
|
1337
|
-
pollCount += 1;
|
|
1338
908
|
let status;
|
|
1339
909
|
try {
|
|
1340
910
|
status = await this.client.getDelegationStatus(delegationId);
|
|
1341
911
|
}
|
|
1342
912
|
catch (e) {
|
|
1343
|
-
if (e instanceof exceptions_1.DelegationException &&
|
|
1344
|
-
e.statusCode &&
|
|
1345
|
-
transient.has(e.statusCode)) {
|
|
913
|
+
if (e instanceof exceptions_1.DelegationException && e.statusCode && transient.has(e.statusCode)) {
|
|
1346
914
|
const backoff = Math.min(interval * 2, 15);
|
|
1347
915
|
await sleep(backoff);
|
|
1348
916
|
waited += backoff;
|
|
@@ -1350,49 +918,108 @@ class ArmorIQSession {
|
|
|
1350
918
|
}
|
|
1351
919
|
throw e;
|
|
1352
920
|
}
|
|
1353
|
-
if (ctx) {
|
|
1354
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'delegation.poll.tick', {
|
|
1355
|
-
kind: 'event',
|
|
1356
|
-
message: `poll #${pollCount} status=${status}`,
|
|
1357
|
-
level: 'info',
|
|
1358
|
-
delegationId,
|
|
1359
|
-
}, spanId)));
|
|
1360
|
-
}
|
|
1361
921
|
if (status === 'approved') {
|
|
1362
922
|
await this.client.markDelegationExecuted(email, delegationId, this.currentToken?.planId);
|
|
1363
|
-
|
|
923
|
+
delegationOutcome = 'approved';
|
|
1364
924
|
return 'approved';
|
|
1365
925
|
}
|
|
1366
926
|
if (status === 'rejected') {
|
|
1367
|
-
|
|
927
|
+
delegationOutcome = 'denied';
|
|
1368
928
|
return 'rejected';
|
|
1369
929
|
}
|
|
930
|
+
if (status === 'cancelled') {
|
|
931
|
+
delegationOutcome = 'cancelled';
|
|
932
|
+
return 'timeout';
|
|
933
|
+
}
|
|
934
|
+
if (status === 'expired') {
|
|
935
|
+
delegationOutcome = 'expired';
|
|
936
|
+
return 'timeout';
|
|
937
|
+
}
|
|
1370
938
|
}
|
|
1371
|
-
|
|
939
|
+
delegationOutcome = 'expired';
|
|
1372
940
|
return 'timeout';
|
|
1373
941
|
}
|
|
1374
942
|
catch (err) {
|
|
1375
|
-
|
|
943
|
+
delegationOutcome = isCancellation(err) ? 'cancelled' : 'failed';
|
|
1376
944
|
throw err;
|
|
1377
945
|
}
|
|
1378
946
|
finally {
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
947
|
+
const approvalOutcome = delegationOutcome;
|
|
948
|
+
await this.otel?.endOperation(nativeApproval ?? { span: null, name: 'approval.wait' }, {
|
|
949
|
+
category: 'approval',
|
|
950
|
+
name: 'approval.wait',
|
|
951
|
+
delegationId,
|
|
952
|
+
approvalOutcome,
|
|
953
|
+
approvalWaitMs: Math.max(0, Math.round(performance.now() - approvalStarted)),
|
|
954
|
+
});
|
|
955
|
+
await this.finalizeDelegationOperation(delegationId, delegationOutcome);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
operationForTool(toolName, mcpServer, planItemOrdinal, override) {
|
|
959
|
+
if (override) {
|
|
960
|
+
return {
|
|
961
|
+
...override,
|
|
962
|
+
mcpServer: override.mcpServer ?? mcpServer,
|
|
963
|
+
toolName,
|
|
964
|
+
planItemOrdinal,
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
return {
|
|
968
|
+
category: 'tool',
|
|
969
|
+
name: 'tool.execute',
|
|
970
|
+
toolType: 'function',
|
|
971
|
+
toolName,
|
|
972
|
+
planItemOrdinal,
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
async finalizeDelegationOperation(delegationId, delegationOutcome) {
|
|
976
|
+
if (this.completedDelegations.has(delegationId))
|
|
977
|
+
return;
|
|
978
|
+
const terminal = this.delegationTerminalOperations.get(delegationId);
|
|
979
|
+
if (terminal)
|
|
980
|
+
return terminal;
|
|
981
|
+
const finalization = (async () => {
|
|
982
|
+
const pending = this.pendingDelegationOperations.get(delegationId);
|
|
983
|
+
this.pendingDelegationOperations.delete(delegationId);
|
|
984
|
+
if (pending) {
|
|
985
|
+
try {
|
|
986
|
+
// Span names are immutable: finish the measured wait as a wait, then
|
|
987
|
+
// record the canonical terminal operation separately.
|
|
988
|
+
await this.otel?.endOperation(pending, {
|
|
989
|
+
category: 'delegation',
|
|
990
|
+
name: 'delegation.wait',
|
|
991
|
+
delegationId,
|
|
992
|
+
delegationOutcome,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
finally {
|
|
996
|
+
await this.otel?.recordOperation({
|
|
997
|
+
category: 'delegation',
|
|
998
|
+
name: 'delegation.complete',
|
|
999
|
+
delegationId,
|
|
1000
|
+
delegationOutcome,
|
|
1001
|
+
});
|
|
1393
1002
|
}
|
|
1394
1003
|
}
|
|
1395
|
-
|
|
1004
|
+
else {
|
|
1005
|
+
await this.otel?.recordOperation({
|
|
1006
|
+
category: 'delegation',
|
|
1007
|
+
name: 'delegation.complete',
|
|
1008
|
+
delegationId,
|
|
1009
|
+
delegationOutcome,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
this.completedDelegations.add(delegationId);
|
|
1014
|
+
if (this.completedDelegations.size > 256)
|
|
1015
|
+
this.completedDelegations.delete(this.completedDelegations.values().next().value);
|
|
1016
|
+
}
|
|
1017
|
+
finally {
|
|
1018
|
+
this.delegationTerminalOperations.delete(delegationId);
|
|
1019
|
+
}
|
|
1020
|
+
})();
|
|
1021
|
+
this.delegationTerminalOperations.set(delegationId, finalization);
|
|
1022
|
+
return finalization;
|
|
1396
1023
|
}
|
|
1397
1024
|
static extractAmount(args) {
|
|
1398
1025
|
if (!args || typeof args !== 'object')
|
|
@@ -1517,20 +1144,6 @@ class ArmorIQSession {
|
|
|
1517
1144
|
* reanchor is off
|
|
1518
1145
|
*/
|
|
1519
1146
|
async flushReanchor() {
|
|
1520
|
-
const startNs = this.obs ? performance.now() : 0;
|
|
1521
|
-
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
1522
|
-
const spanId = ctx && this.obs
|
|
1523
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
1524
|
-
name: 'iap.reanchor.flush',
|
|
1525
|
-
attributes: {
|
|
1526
|
-
hasPendingPlan: !!this.pendingReanchorPlan,
|
|
1527
|
-
hasToken: !!this.currentToken,
|
|
1528
|
-
},
|
|
1529
|
-
})) ?? undefined
|
|
1530
|
-
: undefined;
|
|
1531
|
-
let caught;
|
|
1532
|
-
let fired = false;
|
|
1533
|
-
let trustId;
|
|
1534
1147
|
try {
|
|
1535
1148
|
if (!this.pendingReanchorPlan) {
|
|
1536
1149
|
return { fired: false };
|
|
@@ -1546,34 +1159,13 @@ class ArmorIQSession {
|
|
|
1546
1159
|
const planToFlush = this.pendingReanchorPlan;
|
|
1547
1160
|
this.pendingReanchorPlan = undefined;
|
|
1548
1161
|
const result = await this.reanchorCall(this.currentToken, planToFlush, 'flush at request boundary');
|
|
1549
|
-
fired
|
|
1550
|
-
trustId = result.trustId;
|
|
1551
|
-
return { fired: true, trustId };
|
|
1162
|
+
return { fired: true, trustId: result.trustId };
|
|
1552
1163
|
}
|
|
1553
1164
|
catch (err) {
|
|
1554
|
-
caught = err;
|
|
1555
1165
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1556
1166
|
console.warn(`[armoriq] flushReanchor failed: ${msg}`);
|
|
1557
1167
|
return { fired: false };
|
|
1558
1168
|
}
|
|
1559
|
-
finally {
|
|
1560
|
-
if (ctx) {
|
|
1561
|
-
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
1562
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.reanchor.flush.result', {
|
|
1563
|
-
kind: 'span',
|
|
1564
|
-
fired,
|
|
1565
|
-
trustId: trustId ?? null,
|
|
1566
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
1567
|
-
}, caught ? 'error' : 'ok', null, spanId)));
|
|
1568
|
-
if (spanId) {
|
|
1569
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1570
|
-
status: caught ? 'error' : 'ok',
|
|
1571
|
-
durationMs: computedDuration,
|
|
1572
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
1573
|
-
}));
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
1169
|
}
|
|
1578
1170
|
get currentTokenValue() {
|
|
1579
1171
|
return this.currentToken;
|
|
@@ -1582,57 +1174,41 @@ class ArmorIQSession {
|
|
|
1582
1174
|
return this.mode;
|
|
1583
1175
|
}
|
|
1584
1176
|
/**
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1177
|
+
* End the active native OTel plan span and force-flush its exporter.
|
|
1178
|
+
* No-op when native observability is disabled.
|
|
1587
1179
|
*
|
|
1588
1180
|
* Call this at the request boundary (e.g. Stop hook in armorClaude, end of
|
|
1589
|
-
* turn in ADK consumers) to
|
|
1590
|
-
*
|
|
1591
|
-
* exponential backoff and **never throws** into the SDK consumer — a flush
|
|
1592
|
-
* failure is logged and the spans are dropped.
|
|
1181
|
+
* turn in ADK consumers) to make completed native spans available before the
|
|
1182
|
+
* process exits. Export remains fail-open and does not change tool execution.
|
|
1593
1183
|
*/
|
|
1594
1184
|
async flushObservability() {
|
|
1595
|
-
if (!this.
|
|
1185
|
+
if (!this.otel)
|
|
1596
1186
|
return;
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
// trace (no endTime yet) would be left behind in the ring buffer and
|
|
1600
|
-
// never ship (see ObservabilityRecorder.flush()'s in-flight retention).
|
|
1601
|
-
safeObs(() => this.endPlanTrace('ok'));
|
|
1602
|
-
await (0, observability_1.flushObservability)(this.obs);
|
|
1187
|
+
await this.drainPolicyTelemetry();
|
|
1188
|
+
await this.otel.flush('ok');
|
|
1603
1189
|
}
|
|
1604
1190
|
/**
|
|
1605
|
-
*
|
|
1606
|
-
*
|
|
1607
|
-
*
|
|
1608
|
-
* multiple times; a no-op when observability is disabled.
|
|
1191
|
+
* Close the native OTel session: end any active plan and root spans, then
|
|
1192
|
+
* perform the final exporter shutdown. Safe to call multiple times; a no-op
|
|
1193
|
+
* when native observability is disabled.
|
|
1609
1194
|
*
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
* processes this is a resource leak, and in tests it is why processes
|
|
1613
|
-
* (and jest) can hang or emit "cannot log after tests done" / stray
|
|
1614
|
-
* network activity after teardown. `flushObservability()` alone does NOT
|
|
1615
|
-
* stop the interval — it only flushes; call `close()` (or `dispose()`) at
|
|
1616
|
-
* the natural end of the session's lifetime (e.g. Stop hook in armorClaude,
|
|
1617
|
-
* end of process, or end of a request/turn in ADK consumers) instead of
|
|
1618
|
-
* (or in addition to) `flushObservability()`.
|
|
1195
|
+
* Call `close()` (or `dispose()`) at the natural end of the session's
|
|
1196
|
+
* lifetime, such as an adapter Stop hook or the end of a request/turn.
|
|
1619
1197
|
*
|
|
1620
1198
|
* NEVER throws — observability teardown failures must not propagate into
|
|
1621
1199
|
* the SDK consumer, matching every other observability code path.
|
|
1622
1200
|
*/
|
|
1623
|
-
async close() {
|
|
1624
|
-
if (!this.
|
|
1201
|
+
async close(status = 'ok', taskOutcome, content = {}) {
|
|
1202
|
+
if (!this.otel)
|
|
1625
1203
|
return;
|
|
1626
|
-
// Model A: end the active plan trace before stopping the shipper so its
|
|
1627
|
-
// final flush (inside obs.stop()) ships the completed plan trace rather
|
|
1628
|
-
// than leaving it stranded in-flight.
|
|
1629
|
-
safeObs(() => this.endPlanTrace('ok'));
|
|
1630
1204
|
try {
|
|
1631
|
-
await this.
|
|
1205
|
+
await this.drainPolicyTelemetry();
|
|
1206
|
+
await this.otel?.close(status, taskOutcome, content);
|
|
1632
1207
|
}
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1208
|
+
finally {
|
|
1209
|
+
this.pendingDelegationOperations.clear();
|
|
1210
|
+
this.delegationTerminalOperations.clear();
|
|
1211
|
+
this.completedDelegations.clear();
|
|
1636
1212
|
}
|
|
1637
1213
|
}
|
|
1638
1214
|
/** Alias for `close()`. Use whichever reads better at the call site. */
|