@armoriq/sdk-dev 0.6.10 → 0.8.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.d.ts.map +1 -1
- package/dist/_version.js +1 -1
- package/dist/_version.js.map +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 +522 -49
- package/dist/cli/commands/auth.js.map +1 -1
- 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 +155 -7
- package/dist/integrations/google_adk.d.ts.map +1 -1
- package/dist/integrations/google_adk.js +727 -46
- package/dist/integrations/google_adk.js.map +1 -1
- package/dist/integrations/langchain.d.ts +48 -2
- package/dist/integrations/langchain.d.ts.map +1 -1
- package/dist/integrations/langchain.js +528 -33
- package/dist/integrations/langchain.js.map +1 -1
- package/dist/integrations/strands.d.ts +65 -1
- package/dist/integrations/strands.d.ts.map +1 -1
- package/dist/integrations/strands.js +456 -36
- 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 +103 -0
- package/dist/observability/content-capture.d.ts.map +1 -0
- package/dist/observability/content-capture.js +423 -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 +271 -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 +680 -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 +630 -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 -804
- 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 +12 -7
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,286 +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 planTraceStatus = 'ok';
|
|
435
|
-
let planTraceError;
|
|
436
|
-
try {
|
|
437
|
-
const result = await this._startPlanImpl(toolCalls, goal, ctx);
|
|
438
|
-
if (ctx) {
|
|
439
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.plan.grew', {
|
|
440
|
-
kind: 'event',
|
|
441
|
-
message: `plan captured: ${toolCalls?.length ?? 0} tool(s)`,
|
|
442
|
-
level: 'info',
|
|
443
|
-
toolCount: toolCalls?.length ?? 0,
|
|
444
|
-
})));
|
|
445
|
-
}
|
|
446
|
-
return result;
|
|
447
|
-
}
|
|
448
|
-
catch (err) {
|
|
449
|
-
planTraceStatus = 'error';
|
|
450
|
-
planTraceError = err.message;
|
|
451
|
-
throw err;
|
|
452
|
-
}
|
|
453
|
-
finally {
|
|
454
|
-
if (ctx && spanId) {
|
|
455
|
-
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
456
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
457
|
-
status: planTraceStatus === 'error' ? 'error' : 'ok',
|
|
458
|
-
durationMs: computedDuration,
|
|
459
|
-
errorMessage: planTraceError,
|
|
460
|
-
}));
|
|
461
|
-
}
|
|
462
|
-
}
|
|
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;
|
|
463
210
|
}
|
|
464
|
-
async _startPlanImpl(toolCalls, goal
|
|
211
|
+
async _startPlanImpl(toolCalls, goal) {
|
|
465
212
|
if (!toolCalls || toolCalls.length === 0) {
|
|
466
213
|
throw new Error('startPlan called with no tool calls.');
|
|
467
214
|
}
|
|
@@ -561,58 +308,21 @@ class ArmorIQSession {
|
|
|
561
308
|
return token;
|
|
562
309
|
}
|
|
563
310
|
// ─── Policy enforcement ────────────────────────────────────────
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
* under `iap.check`'s own container span instead of both sitting as
|
|
569
|
-
* siblings directly under the plan trace (Task 3 risk: "check → enforce
|
|
570
|
-
* double-open" — `iap.check`'s span wraps the dispatched enforce span,
|
|
571
|
-
* it never opens a second redundant plan trace or duplicate container).
|
|
572
|
-
*/
|
|
573
|
-
enforceLocal(toolName, toolArgs, parentContainerSpanId) {
|
|
574
|
-
const startNs = this.obs ? performance.now() : 0;
|
|
575
|
-
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
576
|
-
const spanId = ctx && this.obs
|
|
577
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
578
|
-
name: 'iap.enforce.local',
|
|
579
|
-
attributes: { toolName, toolArgs: truncateForSpan(toolArgs) },
|
|
580
|
-
parentSpanId: parentContainerSpanId ?? null,
|
|
581
|
-
})) ?? undefined
|
|
582
|
-
: undefined;
|
|
583
|
-
let caught;
|
|
584
|
-
try {
|
|
585
|
-
return this._enforceLocalImpl(toolName, toolArgs, ctx, spanId);
|
|
586
|
-
}
|
|
587
|
-
catch (err) {
|
|
588
|
-
caught = err;
|
|
589
|
-
throw err;
|
|
590
|
-
}
|
|
591
|
-
finally {
|
|
592
|
-
if (ctx && spanId) {
|
|
593
|
-
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
594
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
595
|
-
status: caught ? 'error' : 'ok',
|
|
596
|
-
durationMs: computedDuration,
|
|
597
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
598
|
-
}));
|
|
599
|
-
}
|
|
600
|
-
}
|
|
311
|
+
enforceLocal(toolName, toolArgs) {
|
|
312
|
+
const decision = this._enforceLocalImpl(toolName, toolArgs);
|
|
313
|
+
this.trackPolicyTelemetry(this.recordPolicyDecision(toolName, toolArgs, decision));
|
|
314
|
+
return decision;
|
|
601
315
|
}
|
|
602
|
-
_enforceLocalImpl(toolName, toolArgs
|
|
316
|
+
_enforceLocalImpl(toolName, toolArgs) {
|
|
603
317
|
if (!this.currentToken) {
|
|
604
|
-
|
|
318
|
+
return {
|
|
605
319
|
allowed: false,
|
|
606
320
|
action: 'block',
|
|
607
321
|
reason: 'No intent token — call startPlan() first',
|
|
608
322
|
};
|
|
609
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
|
|
610
|
-
return r;
|
|
611
323
|
}
|
|
612
324
|
if (models_1.IntentToken.isExpired(this.currentToken)) {
|
|
613
|
-
|
|
614
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
|
|
615
|
-
return r;
|
|
325
|
+
return { allowed: false, action: 'block', reason: 'token-expired' };
|
|
616
326
|
}
|
|
617
327
|
// The local decision is read from the token's policy fields, so the token
|
|
618
328
|
// must be cryptographically authentic first - otherwise a forged token
|
|
@@ -622,22 +332,18 @@ class ArmorIQSession {
|
|
|
622
332
|
// the token. Fail CLOSED if the key is unavailable or the signature is bad.
|
|
623
333
|
const iapKey = this.client.iapPublicKeySync();
|
|
624
334
|
if (!iapKey || !(0, crypto_verify_1.verifyEdDSAJwt)(this.currentToken.jwtToken ?? '', iapKey)) {
|
|
625
|
-
|
|
626
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
|
|
627
|
-
return r;
|
|
335
|
+
return { allowed: false, action: 'block', reason: 'token-signature-invalid' };
|
|
628
336
|
}
|
|
629
337
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
630
338
|
const inPlan = this.declaredTools.has(toolName) ||
|
|
631
339
|
this.declaredTools.has(action) ||
|
|
632
340
|
this.declaredTools.has(`${mcp}__${action}`);
|
|
633
341
|
if (!inPlan) {
|
|
634
|
-
|
|
342
|
+
return {
|
|
635
343
|
allowed: false,
|
|
636
344
|
action: 'block',
|
|
637
345
|
reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
|
|
638
346
|
};
|
|
639
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp }, spanId);
|
|
640
|
-
return r;
|
|
641
347
|
}
|
|
642
348
|
const pv = (this.currentToken.policyValidation ?? {});
|
|
643
349
|
const snapshot = (this.currentToken.policySnapshot ?? []);
|
|
@@ -682,14 +388,12 @@ class ArmorIQSession {
|
|
|
682
388
|
: `Tool '${action}' is denied by policy`);
|
|
683
389
|
// Deny-precedence: an explicitly denied tool is never allowed, even when
|
|
684
390
|
// the default action is 'allow'. hold only if the policy default is hold.
|
|
685
|
-
|
|
391
|
+
return {
|
|
686
392
|
allowed: false,
|
|
687
393
|
action: defaultAction === 'hold' ? 'hold' : 'block',
|
|
688
394
|
reason,
|
|
689
395
|
matchedPolicy: governingPolicyName,
|
|
690
396
|
};
|
|
691
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
692
|
-
return r;
|
|
693
397
|
}
|
|
694
398
|
}
|
|
695
399
|
// Honor the backend-baked resolved allow set: an explicit *empty*
|
|
@@ -697,42 +401,34 @@ class ArmorIQSession {
|
|
|
697
401
|
// ⇒ deny-all, regardless of what any individual snapshot rule allows.
|
|
698
402
|
const bakedAllowedTools = pv.allowed_tools;
|
|
699
403
|
if (Array.isArray(bakedAllowedTools) && bakedAllowedTools.length === 0) {
|
|
700
|
-
|
|
404
|
+
return {
|
|
701
405
|
allowed: false,
|
|
702
406
|
action: 'block',
|
|
703
407
|
reason: `Tool '${action}' is not allowed by any policy in scope (empty resolved allow-list)`,
|
|
704
408
|
};
|
|
705
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
706
|
-
return r;
|
|
707
409
|
}
|
|
708
410
|
if (governingRule) {
|
|
709
411
|
const allowed = governingRule.allowedTools ?? [];
|
|
710
412
|
if (Array.isArray(allowed) && allowed.length > 0) {
|
|
711
|
-
const ok = allowed.includes('*') ||
|
|
712
|
-
allowed.includes(action) ||
|
|
713
|
-
allowed.includes(toolName);
|
|
413
|
+
const ok = allowed.includes('*') || allowed.includes(action) || allowed.includes(toolName);
|
|
714
414
|
if (!ok) {
|
|
715
415
|
// Tool is outside this rule's explicit allow set. Fall back to the
|
|
716
416
|
// policy default action: an 'allow' default permits it, hold/block
|
|
717
417
|
// otherwise.
|
|
718
418
|
if (defaultAction === 'allow') {
|
|
719
|
-
|
|
419
|
+
return {
|
|
720
420
|
allowed: true,
|
|
721
421
|
action: 'allow',
|
|
722
422
|
reason: `Allowed by default enforcement action for policy '${governingPolicyName}'`,
|
|
723
423
|
matchedPolicy: governingPolicyName,
|
|
724
424
|
};
|
|
725
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
726
|
-
return r;
|
|
727
425
|
}
|
|
728
|
-
|
|
426
|
+
return {
|
|
729
427
|
allowed: false,
|
|
730
428
|
action: defaultAction,
|
|
731
429
|
reason: `Tool '${action}' is not in the allowed tools for policy '${governingPolicyName}'`,
|
|
732
430
|
matchedPolicy: governingPolicyName,
|
|
733
431
|
};
|
|
734
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
735
|
-
return r;
|
|
736
432
|
}
|
|
737
433
|
}
|
|
738
434
|
}
|
|
@@ -740,102 +436,83 @@ class ArmorIQSession {
|
|
|
740
436
|
const thresholdDecision = this.evaluateAmountThreshold(governingRule, toolArgs, action, mcp);
|
|
741
437
|
if (thresholdDecision) {
|
|
742
438
|
thresholdDecision.matchedPolicy = governingPolicyName;
|
|
743
|
-
this._emitEnforcePolicyCall(ctx, toolName, thresholdDecision, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
744
439
|
return thresholdDecision;
|
|
745
440
|
}
|
|
746
441
|
}
|
|
747
|
-
|
|
442
|
+
return {
|
|
748
443
|
allowed: true,
|
|
749
444
|
action: 'allow',
|
|
750
445
|
reason: 'Allowed by local policy evaluation',
|
|
751
446
|
matchedPolicy: governingPolicyName,
|
|
752
447
|
};
|
|
753
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
|
|
754
|
-
return r;
|
|
755
448
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
policyId: null,
|
|
767
|
-
policyName: result.matchedPolicy ?? null,
|
|
768
|
-
decision,
|
|
769
|
-
reason: result.reason ?? null,
|
|
770
|
-
source,
|
|
771
|
-
input: { toolName },
|
|
772
|
-
output: {
|
|
773
|
-
allowed: result.allowed,
|
|
774
|
-
action: result.action,
|
|
775
|
-
matchedPolicy: result.matchedPolicy ?? null,
|
|
776
|
-
delegationId: result.delegationId ?? null,
|
|
777
|
-
},
|
|
778
|
-
policyHash: null,
|
|
779
|
-
policyVersion: null,
|
|
780
|
-
matchedRuleId: null,
|
|
781
|
-
dataClasses: [],
|
|
782
|
-
enforcementAction,
|
|
783
|
-
obligations: extras.obligations ?? result.obligations ?? [],
|
|
784
|
-
delegationId: result.delegationId ?? null,
|
|
785
|
-
}, 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;
|
|
786
459
|
}
|
|
787
|
-
/**
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
})) ?? undefined
|
|
797
|
-
: undefined;
|
|
798
|
-
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() {
|
|
799
469
|
try {
|
|
800
|
-
|
|
470
|
+
const key = (0, tool_registry_1.agentKeyFor)(this.client);
|
|
471
|
+
return key ? { toolsSnapshotHash: (0, tool_registry_1.defaultToolRegistry)().snapshotHash(key) } : {};
|
|
801
472
|
}
|
|
802
|
-
catch
|
|
803
|
-
|
|
804
|
-
throw err;
|
|
473
|
+
catch {
|
|
474
|
+
return {};
|
|
805
475
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
813
|
-
}));
|
|
814
|
-
}
|
|
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;
|
|
815
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;
|
|
816
489
|
}
|
|
817
|
-
async _enforceSdkImpl(toolName, toolArgs, userEmail
|
|
490
|
+
async _enforceSdkImpl(toolName, toolArgs, userEmail) {
|
|
818
491
|
if (!this.currentToken) {
|
|
819
492
|
throw new Error(`enforceSdk("${toolName}") called before startPlan().`);
|
|
820
493
|
}
|
|
821
494
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
822
|
-
const resolvedMcp = this.
|
|
495
|
+
const resolvedMcp = this.resolveMcp(toolName, mcp, action);
|
|
823
496
|
const inPlan = this.declaredTools.has(toolName) ||
|
|
824
497
|
this.declaredTools.has(action) ||
|
|
825
498
|
this.declaredTools.has(`${resolvedMcp}__${action}`);
|
|
826
499
|
if (!inPlan) {
|
|
827
|
-
|
|
500
|
+
return {
|
|
828
501
|
allowed: false,
|
|
829
502
|
action: 'block',
|
|
830
503
|
reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
|
|
831
504
|
};
|
|
832
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
|
|
833
|
-
return r;
|
|
834
505
|
}
|
|
835
506
|
const internals = this.client._sessionInternals();
|
|
836
507
|
try {
|
|
837
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,
|
|
838
513
|
tool: action,
|
|
514
|
+
...this.inventoryFields(toolName, action, resolvedMcp),
|
|
515
|
+
...this.snapshotHashField(),
|
|
839
516
|
arguments: toolArgs,
|
|
840
517
|
intent_token: this.currentToken.rawToken,
|
|
841
518
|
policy_snapshot: this.currentToken.policySnapshot,
|
|
@@ -846,11 +523,11 @@ class ArmorIQSession {
|
|
|
846
523
|
timeout: 10000,
|
|
847
524
|
});
|
|
848
525
|
const data = response.data ?? {};
|
|
526
|
+
if (data.inventoryUnknown === true)
|
|
527
|
+
(0, tool_push_1.requestPush)(this.client);
|
|
849
528
|
const allowed = data.allowed === true;
|
|
850
529
|
const actionDecision = data.enforcementAction ?? (allowed ? 'allow' : 'block');
|
|
851
|
-
const matched = typeof data.matchedPolicy === 'object'
|
|
852
|
-
? data.matchedPolicy?.name
|
|
853
|
-
: data.matchedPolicy;
|
|
530
|
+
const matched = typeof data.matchedPolicy === 'object' ? data.matchedPolicy?.name : data.matchedPolicy;
|
|
854
531
|
// Surface gating obligations metadata (additive). The backend has already
|
|
855
532
|
// collapsed gating into allowed/action; obligations explain *why*.
|
|
856
533
|
const obligations = Array.isArray(data.obligations)
|
|
@@ -864,77 +541,45 @@ class ArmorIQSession {
|
|
|
864
541
|
matchedPolicy: matched,
|
|
865
542
|
obligations,
|
|
866
543
|
};
|
|
867
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
|
|
868
544
|
return this.handleHold(toolName, toolArgs, r, userEmail);
|
|
869
545
|
}
|
|
870
|
-
|
|
546
|
+
return {
|
|
871
547
|
allowed,
|
|
872
548
|
action: actionDecision,
|
|
873
549
|
reason: data.reason ?? data.message,
|
|
874
550
|
matchedPolicy: matched,
|
|
875
551
|
obligations,
|
|
876
552
|
};
|
|
877
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
|
|
878
|
-
return r;
|
|
879
553
|
}
|
|
880
554
|
catch (e) {
|
|
881
555
|
console.error(`enforceSdk() failed: ${e.message}. Blocking tool call (fail-closed).`);
|
|
882
|
-
|
|
556
|
+
return {
|
|
883
557
|
allowed: false,
|
|
884
558
|
action: 'block',
|
|
885
559
|
reason: `enforce-unavailable: ${e.message}`,
|
|
886
560
|
};
|
|
887
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
|
|
888
|
-
return r;
|
|
889
561
|
}
|
|
890
562
|
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
const spanId = ctx && this.obs
|
|
896
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
897
|
-
name: 'iap.enforce.proxy',
|
|
898
|
-
attributes: { toolName, toolArgs: truncateForSpan(toolArgs) },
|
|
899
|
-
parentSpanId: parentContainerSpanId ?? null,
|
|
900
|
-
})) ?? undefined
|
|
901
|
-
: undefined;
|
|
902
|
-
let caught;
|
|
903
|
-
try {
|
|
904
|
-
return await this._enforceImpl(toolName, toolArgs, ctx, spanId);
|
|
905
|
-
}
|
|
906
|
-
catch (err) {
|
|
907
|
-
caught = err;
|
|
908
|
-
throw err;
|
|
909
|
-
}
|
|
910
|
-
finally {
|
|
911
|
-
if (ctx && spanId) {
|
|
912
|
-
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
913
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
914
|
-
status: caught ? 'error' : 'ok',
|
|
915
|
-
durationMs: computedDuration,
|
|
916
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
917
|
-
}));
|
|
918
|
-
}
|
|
919
|
-
}
|
|
563
|
+
async enforce(toolName, toolArgs) {
|
|
564
|
+
const decision = await this._enforceImpl(toolName, toolArgs);
|
|
565
|
+
await this.recordPolicyDecision(toolName, toolArgs, decision);
|
|
566
|
+
return decision;
|
|
920
567
|
}
|
|
921
|
-
async _enforceImpl(toolName, toolArgs
|
|
568
|
+
async _enforceImpl(toolName, toolArgs) {
|
|
922
569
|
if (!this.currentToken) {
|
|
923
570
|
throw new Error(`enforce("${toolName}") called before startPlan().`);
|
|
924
571
|
}
|
|
925
572
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
926
|
-
const resolvedMcp = this.
|
|
573
|
+
const resolvedMcp = this.resolveMcp(toolName, mcp, action);
|
|
927
574
|
const inPlan = this.declaredTools.has(toolName) ||
|
|
928
575
|
this.declaredTools.has(action) ||
|
|
929
576
|
this.declaredTools.has(`${resolvedMcp}__${action}`);
|
|
930
577
|
if (!inPlan) {
|
|
931
|
-
|
|
578
|
+
return {
|
|
932
579
|
allowed: false,
|
|
933
580
|
action: 'block',
|
|
934
581
|
reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
|
|
935
582
|
};
|
|
936
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
937
|
-
return r;
|
|
938
583
|
}
|
|
939
584
|
const internals = this.client._sessionInternals();
|
|
940
585
|
try {
|
|
@@ -961,18 +606,16 @@ class ArmorIQSession {
|
|
|
961
606
|
// Fail closed: only an explicit allow from a 2xx response permits the call.
|
|
962
607
|
// Any error status (403 or otherwise) or an ambiguous body blocks.
|
|
963
608
|
if (response.status >= 400) {
|
|
964
|
-
|
|
609
|
+
return {
|
|
965
610
|
allowed: false,
|
|
966
611
|
action: data.action ?? 'block',
|
|
967
612
|
reason: data.reason ?? data.message ?? `enforce-rejected: HTTP ${response.status}`,
|
|
968
613
|
matchedPolicy: policyName,
|
|
969
614
|
};
|
|
970
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
971
|
-
return r;
|
|
972
615
|
}
|
|
973
616
|
const allowedFlag = data.allowed === true;
|
|
974
617
|
const actionDecision = data.enforcementAction ?? data.action ?? (allowedFlag ? 'allow' : 'block');
|
|
975
|
-
|
|
618
|
+
return {
|
|
976
619
|
allowed: allowedFlag,
|
|
977
620
|
action: actionDecision,
|
|
978
621
|
reason: data.reason,
|
|
@@ -980,78 +623,82 @@ class ArmorIQSession {
|
|
|
980
623
|
matchedPolicy: policyName,
|
|
981
624
|
obligations: Array.isArray(data.obligations) ? data.obligations : [],
|
|
982
625
|
};
|
|
983
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
984
|
-
return r;
|
|
985
626
|
}
|
|
986
627
|
catch (e) {
|
|
987
628
|
console.error(`enforce() failed: ${e.message}. Blocking tool call (fail-closed).`);
|
|
988
|
-
|
|
629
|
+
return {
|
|
989
630
|
allowed: false,
|
|
990
631
|
action: 'block',
|
|
991
632
|
reason: `enforce-unavailable: ${e.message}`,
|
|
992
633
|
};
|
|
993
|
-
this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
|
|
994
|
-
return r;
|
|
995
634
|
}
|
|
996
635
|
}
|
|
997
|
-
async check(toolName, toolArgs, userEmail) {
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
toolArgs: truncateForSpan(toolArgs),
|
|
1013
|
-
mode: this.mode,
|
|
1014
|
-
userEmail: userEmail ?? null,
|
|
1015
|
-
},
|
|
1016
|
-
})) ?? undefined
|
|
1017
|
-
: undefined;
|
|
1018
|
-
let caught;
|
|
1019
|
-
try {
|
|
1020
|
-
if (this.mode === 'sdk') {
|
|
1021
|
-
return await this.enforceSdk(toolName, toolArgs, userEmail, spanId);
|
|
1022
|
-
}
|
|
1023
|
-
if (this.mode === 'local') {
|
|
1024
|
-
await this.warmToolMetadata(toolName);
|
|
1025
|
-
const decision = this.enforceLocal(toolName, toolArgs, spanId);
|
|
1026
|
-
if (decision.action === 'hold') {
|
|
1027
|
-
return {
|
|
1028
|
-
...decision,
|
|
1029
|
-
action: 'block',
|
|
1030
|
-
reason: (decision.reason ?? 'requires approval') +
|
|
1031
|
-
' — switch ARMORIQ_MODE=proxy to enable approval workflows for this action.',
|
|
1032
|
-
};
|
|
1033
|
-
}
|
|
1034
|
-
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
|
+
};
|
|
1035
651
|
}
|
|
1036
|
-
const decision = await this.enforce(toolName, toolArgs, spanId);
|
|
1037
|
-
if (decision.action !== 'hold')
|
|
1038
|
-
return decision;
|
|
1039
|
-
return this.handleHold(toolName, toolArgs, decision, userEmail);
|
|
1040
|
-
}
|
|
1041
|
-
catch (err) {
|
|
1042
|
-
caught = err;
|
|
1043
|
-
throw err;
|
|
1044
652
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
status: caught ? 'error' : 'ok',
|
|
1050
|
-
durationMs: computedDuration,
|
|
1051
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
1052
|
-
}));
|
|
653
|
+
else {
|
|
654
|
+
decision = await this._enforceImpl(toolName, toolArgs);
|
|
655
|
+
if (decision.action === 'hold') {
|
|
656
|
+
decision = await this.handleHold(toolName, toolArgs, decision, userEmail);
|
|
1053
657
|
}
|
|
1054
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]);
|
|
1055
702
|
}
|
|
1056
703
|
// ─── Report / dispatch ─────────────────────────────────────────
|
|
1057
704
|
async report(toolName, toolArgs, result, opts) {
|
|
@@ -1061,50 +708,28 @@ class ArmorIQSession {
|
|
|
1061
708
|
? Math.round(opts.durationMs)
|
|
1062
709
|
: opts?.durationMs,
|
|
1063
710
|
};
|
|
1064
|
-
const startNs = this.obs ? performance.now() : 0;
|
|
1065
|
-
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
1066
|
-
const spanId = ctx && this.obs
|
|
1067
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
1068
|
-
name: 'tool.report',
|
|
1069
|
-
attributes: {
|
|
1070
|
-
toolName,
|
|
1071
|
-
toolInput: truncateForSpan(toolArgs),
|
|
1072
|
-
status: o.status ?? 'success',
|
|
1073
|
-
},
|
|
1074
|
-
})) ?? undefined
|
|
1075
|
-
: undefined;
|
|
1076
711
|
let reportStatus = 'ok';
|
|
1077
712
|
let reportError;
|
|
713
|
+
const reportItemOrdinal = this.stepIndex;
|
|
714
|
+
let reportOperation = this.operationForTool(toolName, undefined, reportItemOrdinal, o.operation);
|
|
1078
715
|
try {
|
|
1079
716
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
1080
|
-
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
|
+
}
|
|
1081
721
|
const internals = this.client._sessionInternals();
|
|
1082
722
|
try {
|
|
1083
723
|
const token = this.currentToken;
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
output = { text: result };
|
|
1088
|
-
else if (result === null || result === undefined)
|
|
1089
|
-
output = {};
|
|
1090
|
-
await internals.httpClient.post(`${internals.backendEndpoint}/iap/audit`, {
|
|
1091
|
-
token: token?.jwtToken ?? token?.tokenId ?? 'unknown',
|
|
1092
|
-
plan_id: token?.planId ?? token?.tokenId ?? 'unknown',
|
|
1093
|
-
step_index: this.stepIndex,
|
|
724
|
+
await internals.httpClient.post(`${internals.backendEndpoint}/iap/audit`, auditPersistencePayload({
|
|
725
|
+
token,
|
|
726
|
+
stepIndex: this.stepIndex,
|
|
1094
727
|
action,
|
|
1095
|
-
tool: action,
|
|
1096
728
|
mcp: resolvedMcp,
|
|
1097
|
-
input: toolArgs,
|
|
1098
|
-
output,
|
|
1099
729
|
status: o.status ?? 'success',
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
delegated_by: o.delegatedBy,
|
|
1104
|
-
user_email: userEmail,
|
|
1105
|
-
delegated_to: o.delegatedTo,
|
|
1106
|
-
executed_at: new Date().toISOString(),
|
|
1107
|
-
}, {
|
|
730
|
+
durationMs: o.durationMs,
|
|
731
|
+
isDelegated: o.isDelegated,
|
|
732
|
+
}), {
|
|
1108
733
|
headers: { 'X-API-Key': internals.apiKey, 'Content-Type': 'application/json' },
|
|
1109
734
|
timeout: 5000,
|
|
1110
735
|
});
|
|
@@ -1120,49 +745,43 @@ class ArmorIQSession {
|
|
|
1120
745
|
throw err;
|
|
1121
746
|
}
|
|
1122
747
|
finally {
|
|
1123
|
-
if (
|
|
1124
|
-
|
|
1125
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'tool.report', {
|
|
1126
|
-
kind: 'span',
|
|
748
|
+
if (o.emitOtel !== false) {
|
|
749
|
+
await this.otel?.recordTool({
|
|
1127
750
|
toolName,
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
}, reportStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
1138
|
-
if (spanId) {
|
|
1139
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1140
|
-
status: reportStatus === 'error' ? 'error' : 'ok',
|
|
1141
|
-
durationMs: computedDuration,
|
|
1142
|
-
errorMessage: reportError,
|
|
1143
|
-
}));
|
|
1144
|
-
}
|
|
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
|
+
});
|
|
1145
760
|
}
|
|
1146
761
|
}
|
|
1147
762
|
}
|
|
1148
763
|
async dispatch(toolName, toolArgs) {
|
|
1149
|
-
const startNs = this.obs ? performance.now() : 0;
|
|
1150
|
-
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
1151
|
-
const spanId = ctx && this.obs
|
|
1152
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
1153
|
-
name: 'tool.dispatch',
|
|
1154
|
-
attributes: { toolName, toolInput: truncateForSpan(toolArgs) },
|
|
1155
|
-
})) ?? undefined
|
|
1156
|
-
: undefined;
|
|
1157
764
|
let dispatchResult;
|
|
1158
765
|
let dispatchStatus = 'ok';
|
|
1159
766
|
let dispatchError;
|
|
767
|
+
let nativeTool;
|
|
1160
768
|
try {
|
|
1161
769
|
if (!this.currentToken) {
|
|
1162
770
|
throw new Error(`dispatch("${toolName}") called before startPlan().`);
|
|
1163
771
|
}
|
|
1164
772
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
1165
|
-
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
|
+
});
|
|
1166
785
|
dispatchResult = await this.client.invoke(resolvedMcp, action, this.currentToken, toolArgs);
|
|
1167
786
|
this.stepIndex += 1;
|
|
1168
787
|
return dispatchResult?.result;
|
|
@@ -1173,46 +792,23 @@ class ArmorIQSession {
|
|
|
1173
792
|
throw err;
|
|
1174
793
|
}
|
|
1175
794
|
finally {
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
}, dispatchStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
1184
|
-
if (spanId) {
|
|
1185
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1186
|
-
status: dispatchStatus === 'error' ? 'error' : 'ok',
|
|
1187
|
-
durationMs: computedDuration,
|
|
1188
|
-
errorMessage: dispatchError,
|
|
1189
|
-
}));
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
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
|
+
});
|
|
1192
802
|
}
|
|
1193
803
|
}
|
|
1194
804
|
// ─── Helpers ────────────────────────────────────────────────────
|
|
1195
805
|
async handleHold(toolName, toolArgs, holdDecision, userEmail) {
|
|
1196
|
-
const startNs = this.obs ? performance.now() : 0;
|
|
1197
|
-
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
1198
|
-
const spanId = ctx && this.obs
|
|
1199
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
1200
|
-
name: 'delegation.hold',
|
|
1201
|
-
attributes: {
|
|
1202
|
-
toolName,
|
|
1203
|
-
toolArgs: truncateForSpan(toolArgs),
|
|
1204
|
-
matchedPolicy: holdDecision.matchedPolicy ?? null,
|
|
1205
|
-
},
|
|
1206
|
-
})) ?? undefined
|
|
1207
|
-
: undefined;
|
|
1208
806
|
let result;
|
|
1209
|
-
|
|
1210
|
-
let holdError;
|
|
1211
|
-
try {
|
|
807
|
+
{
|
|
1212
808
|
const internals = this.client._sessionInternals();
|
|
1213
809
|
const email = userEmail ?? internals.userId ?? 'unknown@armoriq';
|
|
1214
810
|
const { mcp, action } = this.toolNameParser(toolName);
|
|
1215
|
-
const resolvedMcp = this.
|
|
811
|
+
const resolvedMcp = this.resolveMcp(toolName, mcp, action);
|
|
1216
812
|
const rawAmount = ArmorIQSession.extractAmount(toolArgs) ?? 0;
|
|
1217
813
|
const safeAmount = typeof rawAmount === 'number' && rawAmount >= 0.01 ? rawAmount : 0.01;
|
|
1218
814
|
try {
|
|
@@ -1263,6 +859,16 @@ class ArmorIQSession {
|
|
|
1263
859
|
catch (e) {
|
|
1264
860
|
console.warn(`createDelegationRequest failed: ${e.message}`);
|
|
1265
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
|
+
}
|
|
1266
872
|
result = {
|
|
1267
873
|
allowed: false,
|
|
1268
874
|
action: 'hold',
|
|
@@ -1273,29 +879,6 @@ class ArmorIQSession {
|
|
|
1273
879
|
};
|
|
1274
880
|
return result;
|
|
1275
881
|
}
|
|
1276
|
-
catch (err) {
|
|
1277
|
-
holdStatus = 'error';
|
|
1278
|
-
holdError = err.message;
|
|
1279
|
-
throw err;
|
|
1280
|
-
}
|
|
1281
|
-
finally {
|
|
1282
|
-
if (ctx) {
|
|
1283
|
-
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
1284
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'delegation.hold', {
|
|
1285
|
-
kind: 'span',
|
|
1286
|
-
delegationId: result?.delegationId ?? null,
|
|
1287
|
-
decision: result?.action ?? 'hold',
|
|
1288
|
-
errorMessage: holdError,
|
|
1289
|
-
}, holdStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
|
|
1290
|
-
if (spanId) {
|
|
1291
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1292
|
-
status: holdStatus === 'error' ? 'error' : 'ok',
|
|
1293
|
-
durationMs: computedDuration,
|
|
1294
|
-
errorMessage: holdError,
|
|
1295
|
-
}));
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1299
882
|
}
|
|
1300
883
|
/**
|
|
1301
884
|
* Poll a held delegation until it is decided; non-blocking (async).
|
|
@@ -1305,16 +888,13 @@ class ArmorIQSession {
|
|
|
1305
888
|
* timeout/interval are in seconds (mirrors the Python SDK await_approval).
|
|
1306
889
|
*/
|
|
1307
890
|
async awaitApproval(delegationId, opts = {}) {
|
|
1308
|
-
|
|
1309
|
-
const
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
: undefined;
|
|
1316
|
-
let caught;
|
|
1317
|
-
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
|
+
});
|
|
1318
898
|
try {
|
|
1319
899
|
const timeout = opts.timeout ?? 300;
|
|
1320
900
|
const interval = opts.interval ?? 5;
|
|
@@ -1322,19 +902,15 @@ class ArmorIQSession {
|
|
|
1322
902
|
const email = opts.userEmail || this.client.userId || 'unknown@armoriq';
|
|
1323
903
|
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
|
|
1324
904
|
let waited = 0;
|
|
1325
|
-
let pollCount = 0;
|
|
1326
905
|
while (waited < timeout) {
|
|
1327
906
|
await sleep(interval);
|
|
1328
907
|
waited += interval;
|
|
1329
|
-
pollCount += 1;
|
|
1330
908
|
let status;
|
|
1331
909
|
try {
|
|
1332
910
|
status = await this.client.getDelegationStatus(delegationId);
|
|
1333
911
|
}
|
|
1334
912
|
catch (e) {
|
|
1335
|
-
if (e instanceof exceptions_1.DelegationException &&
|
|
1336
|
-
e.statusCode &&
|
|
1337
|
-
transient.has(e.statusCode)) {
|
|
913
|
+
if (e instanceof exceptions_1.DelegationException && e.statusCode && transient.has(e.statusCode)) {
|
|
1338
914
|
const backoff = Math.min(interval * 2, 15);
|
|
1339
915
|
await sleep(backoff);
|
|
1340
916
|
waited += backoff;
|
|
@@ -1342,49 +918,108 @@ class ArmorIQSession {
|
|
|
1342
918
|
}
|
|
1343
919
|
throw e;
|
|
1344
920
|
}
|
|
1345
|
-
if (ctx) {
|
|
1346
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'delegation.poll.tick', {
|
|
1347
|
-
kind: 'event',
|
|
1348
|
-
message: `poll #${pollCount} status=${status}`,
|
|
1349
|
-
level: 'info',
|
|
1350
|
-
delegationId,
|
|
1351
|
-
}, spanId)));
|
|
1352
|
-
}
|
|
1353
921
|
if (status === 'approved') {
|
|
1354
922
|
await this.client.markDelegationExecuted(email, delegationId, this.currentToken?.planId);
|
|
1355
|
-
|
|
923
|
+
delegationOutcome = 'approved';
|
|
1356
924
|
return 'approved';
|
|
1357
925
|
}
|
|
1358
926
|
if (status === 'rejected') {
|
|
1359
|
-
|
|
927
|
+
delegationOutcome = 'denied';
|
|
1360
928
|
return 'rejected';
|
|
1361
929
|
}
|
|
930
|
+
if (status === 'cancelled') {
|
|
931
|
+
delegationOutcome = 'cancelled';
|
|
932
|
+
return 'timeout';
|
|
933
|
+
}
|
|
934
|
+
if (status === 'expired') {
|
|
935
|
+
delegationOutcome = 'expired';
|
|
936
|
+
return 'timeout';
|
|
937
|
+
}
|
|
1362
938
|
}
|
|
1363
|
-
|
|
939
|
+
delegationOutcome = 'expired';
|
|
1364
940
|
return 'timeout';
|
|
1365
941
|
}
|
|
1366
942
|
catch (err) {
|
|
1367
|
-
|
|
943
|
+
delegationOutcome = isCancellation(err) ? 'cancelled' : 'failed';
|
|
1368
944
|
throw err;
|
|
1369
945
|
}
|
|
1370
946
|
finally {
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
+
});
|
|
1385
1002
|
}
|
|
1386
1003
|
}
|
|
1387
|
-
|
|
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;
|
|
1388
1023
|
}
|
|
1389
1024
|
static extractAmount(args) {
|
|
1390
1025
|
if (!args || typeof args !== 'object')
|
|
@@ -1509,20 +1144,6 @@ class ArmorIQSession {
|
|
|
1509
1144
|
* reanchor is off
|
|
1510
1145
|
*/
|
|
1511
1146
|
async flushReanchor() {
|
|
1512
|
-
const startNs = this.obs ? performance.now() : 0;
|
|
1513
|
-
const ctx = this.obs ? this.ensurePlanTrace() : null;
|
|
1514
|
-
const spanId = ctx && this.obs
|
|
1515
|
-
? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
|
|
1516
|
-
name: 'iap.reanchor.flush',
|
|
1517
|
-
attributes: {
|
|
1518
|
-
hasPendingPlan: !!this.pendingReanchorPlan,
|
|
1519
|
-
hasToken: !!this.currentToken,
|
|
1520
|
-
},
|
|
1521
|
-
})) ?? undefined
|
|
1522
|
-
: undefined;
|
|
1523
|
-
let caught;
|
|
1524
|
-
let fired = false;
|
|
1525
|
-
let trustId;
|
|
1526
1147
|
try {
|
|
1527
1148
|
if (!this.pendingReanchorPlan) {
|
|
1528
1149
|
return { fired: false };
|
|
@@ -1538,34 +1159,13 @@ class ArmorIQSession {
|
|
|
1538
1159
|
const planToFlush = this.pendingReanchorPlan;
|
|
1539
1160
|
this.pendingReanchorPlan = undefined;
|
|
1540
1161
|
const result = await this.reanchorCall(this.currentToken, planToFlush, 'flush at request boundary');
|
|
1541
|
-
fired
|
|
1542
|
-
trustId = result.trustId;
|
|
1543
|
-
return { fired: true, trustId };
|
|
1162
|
+
return { fired: true, trustId: result.trustId };
|
|
1544
1163
|
}
|
|
1545
1164
|
catch (err) {
|
|
1546
|
-
caught = err;
|
|
1547
1165
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1548
1166
|
console.warn(`[armoriq] flushReanchor failed: ${msg}`);
|
|
1549
1167
|
return { fired: false };
|
|
1550
1168
|
}
|
|
1551
|
-
finally {
|
|
1552
|
-
if (ctx) {
|
|
1553
|
-
const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
|
|
1554
|
-
safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.reanchor.flush.result', {
|
|
1555
|
-
kind: 'span',
|
|
1556
|
-
fired,
|
|
1557
|
-
trustId: trustId ?? null,
|
|
1558
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
1559
|
-
}, caught ? 'error' : 'ok', null, spanId)));
|
|
1560
|
-
if (spanId) {
|
|
1561
|
-
safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
|
|
1562
|
-
status: caught ? 'error' : 'ok',
|
|
1563
|
-
durationMs: computedDuration,
|
|
1564
|
-
errorMessage: caught instanceof Error ? caught.message : undefined,
|
|
1565
|
-
}));
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1569
1169
|
}
|
|
1570
1170
|
get currentTokenValue() {
|
|
1571
1171
|
return this.currentToken;
|
|
@@ -1574,57 +1174,41 @@ class ArmorIQSession {
|
|
|
1574
1174
|
return this.mode;
|
|
1575
1175
|
}
|
|
1576
1176
|
/**
|
|
1577
|
-
*
|
|
1578
|
-
*
|
|
1177
|
+
* End the active native OTel plan span and force-flush its exporter.
|
|
1178
|
+
* No-op when native observability is disabled.
|
|
1579
1179
|
*
|
|
1580
1180
|
* Call this at the request boundary (e.g. Stop hook in armorClaude, end of
|
|
1581
|
-
* turn in ADK consumers) to
|
|
1582
|
-
*
|
|
1583
|
-
* exponential backoff and **never throws** into the SDK consumer — a flush
|
|
1584
|
-
* 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.
|
|
1585
1183
|
*/
|
|
1586
1184
|
async flushObservability() {
|
|
1587
|
-
if (!this.
|
|
1185
|
+
if (!this.otel)
|
|
1588
1186
|
return;
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
// trace (no endTime yet) would be left behind in the ring buffer and
|
|
1592
|
-
// never ship (see ObservabilityRecorder.flush()'s in-flight retention).
|
|
1593
|
-
safeObs(() => this.endPlanTrace('ok'));
|
|
1594
|
-
await (0, observability_1.flushObservability)(this.obs);
|
|
1187
|
+
await this.drainPolicyTelemetry();
|
|
1188
|
+
await this.otel.flush('ok');
|
|
1595
1189
|
}
|
|
1596
1190
|
/**
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
* 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.
|
|
1601
1194
|
*
|
|
1602
|
-
*
|
|
1603
|
-
*
|
|
1604
|
-
* processes this is a resource leak, and in tests it is why processes
|
|
1605
|
-
* (and jest) can hang or emit "cannot log after tests done" / stray
|
|
1606
|
-
* network activity after teardown. `flushObservability()` alone does NOT
|
|
1607
|
-
* stop the interval — it only flushes; call `close()` (or `dispose()`) at
|
|
1608
|
-
* the natural end of the session's lifetime (e.g. Stop hook in armorClaude,
|
|
1609
|
-
* end of process, or end of a request/turn in ADK consumers) instead of
|
|
1610
|
-
* (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.
|
|
1611
1197
|
*
|
|
1612
1198
|
* NEVER throws — observability teardown failures must not propagate into
|
|
1613
1199
|
* the SDK consumer, matching every other observability code path.
|
|
1614
1200
|
*/
|
|
1615
|
-
async close(status = 'ok') {
|
|
1616
|
-
if (!this.
|
|
1201
|
+
async close(status = 'ok', taskOutcome, content = {}) {
|
|
1202
|
+
if (!this.otel)
|
|
1617
1203
|
return;
|
|
1618
|
-
// Model A: end the active plan trace before stopping the shipper so its
|
|
1619
|
-
// final flush (inside obs.stop()) ships the completed plan trace rather
|
|
1620
|
-
// than leaving it stranded in-flight.
|
|
1621
|
-
safeObs(() => this.endPlanTrace(status));
|
|
1622
1204
|
try {
|
|
1623
|
-
await this.
|
|
1205
|
+
await this.drainPolicyTelemetry();
|
|
1206
|
+
await this.otel?.close(status, taskOutcome, content);
|
|
1624
1207
|
}
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1208
|
+
finally {
|
|
1209
|
+
this.pendingDelegationOperations.clear();
|
|
1210
|
+
this.delegationTerminalOperations.clear();
|
|
1211
|
+
this.completedDelegations.clear();
|
|
1628
1212
|
}
|
|
1629
1213
|
}
|
|
1630
1214
|
/** Alias for `close()`. Use whichever reads better at the call site. */
|