@cirvix_ai/agent-control 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +42 -0
  3. package/README.md +341 -0
  4. package/action/README.md +100 -0
  5. package/action/action.yml +134 -0
  6. package/action/report.mjs +144 -0
  7. package/bin/cirvix.mjs +1073 -0
  8. package/package.json +60 -0
  9. package/src/commands/demo.mjs +315 -0
  10. package/src/commands/init.mjs +558 -0
  11. package/src/commands/policy.mjs +345 -0
  12. package/src/commands/sarif.mjs +176 -0
  13. package/src/commands/scan.mjs +210 -0
  14. package/src/commands/status.mjs +208 -0
  15. package/src/commands/upgrade.mjs +162 -0
  16. package/src/core/approvals.mjs +388 -0
  17. package/src/core/audit.mjs +181 -0
  18. package/src/core/canonical.mjs +316 -0
  19. package/src/core/daemon.mjs +352 -0
  20. package/src/core/decisions.mjs +253 -0
  21. package/src/core/delegation.mjs +658 -0
  22. package/src/core/detect.mjs +337 -0
  23. package/src/core/entitlement-gate.mjs +100 -0
  24. package/src/core/entitlements.mjs +285 -0
  25. package/src/core/format.mjs +33 -0
  26. package/src/core/gateway.mjs +959 -0
  27. package/src/core/guard.mjs +568 -0
  28. package/src/core/http-transport.mjs +505 -0
  29. package/src/core/journal.mjs +419 -0
  30. package/src/core/jsonrpc.mjs +152 -0
  31. package/src/core/meter.mjs +225 -0
  32. package/src/core/normalize.mjs +516 -0
  33. package/src/core/notices.mjs +80 -0
  34. package/src/core/pipeline.mjs +629 -0
  35. package/src/core/policy-dsl.mjs +611 -0
  36. package/src/core/policy.mjs +710 -0
  37. package/src/core/prompts.mjs +146 -0
  38. package/src/core/risk.mjs +509 -0
  39. package/src/core/sanitize.mjs +279 -0
  40. package/src/core/secret-detect.mjs +533 -0
  41. package/src/core/secrets.mjs +312 -0
  42. package/src/core/uds.mjs +383 -0
  43. package/src/core/vault.mjs +530 -0
  44. package/src/index.mjs +143 -0
  45. package/src/testing.mjs +145 -0
@@ -0,0 +1,629 @@
1
+ /**
2
+ * The request pipeline — one tool call, start to finish.
3
+ *
4
+ * AI AGENT
5
+ * │
6
+ * ▼
7
+ * CIRVIX CLI
8
+ * │
9
+ * ▼
10
+ * UDS / Local proxy
11
+ * │
12
+ * ├── Parse
13
+ * ├── Normalize
14
+ * ├── Secret detection
15
+ * ├── Risk classification
16
+ * ├── Policy evaluation
17
+ * ├── Approval check
18
+ * ├── Sanitization
19
+ * └── Audit event
20
+ * │
21
+ * ▼
22
+ * TOOL / MCP SERVER
23
+ *
24
+ * ON THE ORDER OF THOSE STAGES
25
+ *
26
+ * Secret detection and risk classification run BEFORE policy, not after. This
27
+ * is not a preference — it is the only order in which the feature works.
28
+ *
29
+ * A rule may say `risk >= HIGH` or `secrets.detected > 0`. For the engine to
30
+ * evaluate that condition, the values must already exist. Running policy first
31
+ * and classifying afterwards would leave every risk-based rule matching against
32
+ * `undefined`, which the comparators treat as a non-match — so the rules would
33
+ * load, validate, appear in `cirvix policy list`, and silently never fire. That
34
+ * is the worst failure mode available to a security product: a control that
35
+ * reports itself as present and is not.
36
+ *
37
+ * Approval and sanitization run after policy because both are consequences of
38
+ * a decision rather than inputs to one.
39
+ *
40
+ * EVERY STAGE IS TIMED, AND THE TOTAL IS THE HONEST NUMBER
41
+ *
42
+ * `latency_ms` covers parse through audit — everything Cirvix adds. It excludes
43
+ * the upstream tool round trip, which is orders of magnitude larger and would
44
+ * flatter the figure into meaninglessness. `stages` carries the per-stage
45
+ * breakdown so a slow deployment can be diagnosed rather than guessed at.
46
+ */
47
+
48
+ import { evaluate } from "./policy.mjs";
49
+ import {
50
+ DECISION,
51
+ MODE,
52
+ applyMode,
53
+ escalateForRisk,
54
+ isForwarded,
55
+ toDecision,
56
+ } from "./decisions.mjs";
57
+ import { classify } from "./risk.mjs";
58
+ import { applyEntitlements } from "./entitlement-gate.mjs";
59
+ import { normalize, policyRequest, requestId } from "./normalize.mjs";
60
+ import { approvalFingerprint } from "./approvals.mjs";
61
+ import { applyDelegation } from "./delegation.mjs";
62
+ import { redact as redactSecrets, scan as scanSecrets } from "./secret-detect.mjs";
63
+ import { stripInjection } from "./sanitize.mjs";
64
+
65
+ /** Wall-clock for one stage, in fractional milliseconds. */
66
+ function timer() {
67
+ const t0 = process.hrtime.bigint();
68
+ return () => Number(process.hrtime.bigint() - t0) / 1e6;
69
+ }
70
+
71
+ /* -------------------------------------------------------------------------- */
72
+
73
+ export class Pipeline {
74
+ /**
75
+ * @param {object} opts
76
+ * @param {Array} opts.rules the policy rule set
77
+ * @param {string} [opts.agent]
78
+ * @param {string} [opts.environment]
79
+ * @param {string} [opts.cwd]
80
+ * @param {string} [opts.mode] MODE.ENFORCE | MODE.AUDIT
81
+ * @param {object} [opts.audit] AuditChain
82
+ * @param {object} [opts.secrets] Vault or SecretsClient
83
+ * @param {object} [opts.approvals] ApprovalStore
84
+ * @param {string} [opts.riskFloor] risk level that forces approval
85
+ * @param {(e:object)=>void} [opts.onEvent]
86
+ * @param {(m:string)=>void} [opts.log]
87
+ */
88
+ constructor({
89
+ rules = [],
90
+ agent = "local",
91
+ environment = "local",
92
+ cwd = process.cwd(),
93
+ mode = MODE.ENFORCE,
94
+ audit = null,
95
+ secrets = null,
96
+ approvals = null,
97
+ delegation = null,
98
+ /* Commercial enforcement. All three default to absent, so a Pipeline
99
+ built without them behaves exactly as before — which is what keeps the
100
+ existing suite, the shared conformance fixture and every embedding
101
+ caller working unchanged. The CLI and the daemon supply them; a library
102
+ user metering nothing is a supported configuration. */
103
+ licence = null,
104
+ meter = null,
105
+ agents = null,
106
+ riskFloor = "high",
107
+ runId = null,
108
+ onEvent = () => {},
109
+ log = () => {},
110
+ } = {}) {
111
+ this.rules = rules;
112
+ this.agent = agent;
113
+ this.environment = environment;
114
+ this.cwd = cwd;
115
+ this.mode = mode;
116
+ this.audit = audit;
117
+ this.secrets = secrets;
118
+ this.approvals = approvals;
119
+ /** DelegationBroker, when agent-to-agent delegation is in use. */
120
+ this.delegation = delegation;
121
+ this.licence = licence;
122
+ this.meter = meter;
123
+ this.agents = agents;
124
+ this.riskFloor = riskFloor;
125
+ this.runId = runId;
126
+ this.onEvent = onEvent;
127
+ this.log = log;
128
+
129
+ /** Set once this session reads secret-shaped material. */
130
+ this.touchedSecret = false;
131
+
132
+ this.stats = {
133
+ calls: 0,
134
+ allowed: 0,
135
+ denied: 0,
136
+ approvals: 0,
137
+ sanitized: 0,
138
+ auditOnly: 0,
139
+ leaks: 0,
140
+ secretsDetected: 0,
141
+ latencyTotal: 0,
142
+ latencies: [],
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Runs one call through every stage.
148
+ *
149
+ * Never throws for a policy outcome — a denial is a return value, because the
150
+ * transports need to render it as data the agent can read rather than as an
151
+ * exception that aborts the run.
152
+ *
153
+ * @returns {Promise<{event:object, call:object, decision:object, arguments:any}>}
154
+ */
155
+ async submit(raw, ctx = {}) {
156
+ const total = timer();
157
+ const stages = {};
158
+ const id = raw.request_id ?? requestId();
159
+
160
+ /* ------------------------------------------------------------ 1. parse */
161
+ let t = timer();
162
+ const parsed = this.#parse(raw);
163
+ stages.parse = t();
164
+
165
+ /* -------------------------------------------------------- 2. normalize */
166
+ t = timer();
167
+ const call = normalize(
168
+ { ...parsed, request_id: id },
169
+ {
170
+ agent: ctx.agent ?? this.agent,
171
+ source: ctx.source ?? raw.source,
172
+ cwd: this.cwd,
173
+ environment: ctx.environment ?? this.environment,
174
+ touchedSecret: this.touchedSecret,
175
+ runId: this.runId,
176
+ timestamp: ctx.timestamp,
177
+ },
178
+ );
179
+ stages.normalize = t();
180
+
181
+ /* -------------------------------------------- 3. secret detection (in) */
182
+ t = timer();
183
+ const argumentFindings = scanSecrets(call.arguments);
184
+ call.secretsDetected = argumentFindings.length;
185
+ this.stats.secretsDetected += argumentFindings.length;
186
+ stages.secrets = t();
187
+
188
+ /* ------------------------------------------------ 4. risk (re-classify) */
189
+ // Re-run now that `secretsDetected` is known: the `secret-material-in-
190
+ // arguments` rule cannot fire during normalization because the scan had
191
+ // not happened yet.
192
+ t = timer();
193
+ const risk = classify(call);
194
+ call.risk = risk.level;
195
+ call.risk_signals = risk.signals.map((s) => s.id);
196
+ call.risk_reason = risk.reason;
197
+ stages.risk = t();
198
+
199
+ /* ------------------------------------------------------------ 5. policy */
200
+ t = timer();
201
+ let decision = evaluate(policyRequest(call), this.rules, { cwd: this.cwd });
202
+ decision.decision = decision.decision ?? toDecision(decision.verdict);
203
+ decision = escalateForRisk(decision, risk, { floor: this.riskFloor });
204
+
205
+ /*
206
+ * Delegation narrows. It never grants.
207
+ *
208
+ * Applied AFTER policy and only ever as an intersection, so there is no
209
+ * path by which presenting a token turns a denied call into a permitted
210
+ * one. If delegation were evaluated as an alternative source of authority,
211
+ * a leaked grant would be leaked authority — and the confused-deputy
212
+ * problem it exists to solve would be reintroduced by its own solution.
213
+ *
214
+ * A call arriving WITHOUT a delegation is unaffected: an agent acting on
215
+ * its own behalf is governed by policy alone.
216
+ */
217
+ if (ctx.delegation && this.delegation) {
218
+ const context = applyDelegation(decision, {
219
+ broker: this.delegation,
220
+ presented: ctx.delegation,
221
+ agent: call.agent,
222
+ action: call.action,
223
+ resource: call.resource,
224
+ });
225
+ if (context) call.delegation = context;
226
+ }
227
+
228
+ decision = applyMode(decision, this.mode);
229
+ decision.decision_id = `dec_${id.slice(4)}`;
230
+
231
+ /* ------------------------------------------------- entitlement gate ----
232
+ * Quota and concurrent-agent limits, applied as an OVERRIDE of the
233
+ * decision rather than as an early return.
234
+ *
235
+ * The rule itself lives in entitlement-gate.mjs because `Guard` — the core
236
+ * behind guard.wrap() and the MCP gateway — has to apply exactly the same
237
+ * one. It used to live here and only here, which meant every path except
238
+ * this one was unmetered. See that file for the full reasoning. */
239
+ decision = applyEntitlements(decision, {
240
+ licence: this.licence,
241
+ meter: this.meter,
242
+ agents: this.agents,
243
+ agent: call.agent,
244
+ });
245
+
246
+ stages.policy = t();
247
+
248
+ /* ---------------------------------------------------------- 6. approval */
249
+ t = timer();
250
+ if (decision.decision === DECISION.REQUIRE_APPROVAL && this.approvals) {
251
+ /*
252
+ * The release path.
253
+ *
254
+ * A grant is looked up FIRST, by a fingerprint of this exact call. This
255
+ * is what turns "a person said yes" into "the call runs" — without it
256
+ * every submission created a fresh pending request and an approved
257
+ * approval released nothing, so a hold could never become an execution.
258
+ *
259
+ * Bound to the call rather than the tool, so approving a write to
260
+ * `audit_log` cannot be spent on `salaries`, and single-use, so one yes
261
+ * does not authorize an unbounded number of identical calls.
262
+ */
263
+ const fingerprint = approvalFingerprint(call);
264
+
265
+ try {
266
+ const grant = this.approvals.findGrant(fingerprint);
267
+
268
+ if (grant) {
269
+ await this.approvals.consume(grant.id, id);
270
+ decision.decision = DECISION.ALLOW;
271
+ decision.verdict = "permit";
272
+ decision.approvalId = grant.id;
273
+ decision.approvedBy = grant.decidedBy;
274
+ decision.reason = `Approved by ${grant.decidedBy}. ${decision.reason}`;
275
+ } else {
276
+ const approval = await this.approvals.request({
277
+ request_id: id,
278
+ agent: call.agent,
279
+ tool: call.tool,
280
+ resource: call.resource,
281
+ risk: call.risk,
282
+ rule: decision.rule,
283
+ reason: decision.reason,
284
+ approvers: decision.approvers ?? [],
285
+ fingerprint,
286
+ });
287
+ decision.approvalId = approval.id;
288
+ if (approval.state === "denied") {
289
+ decision.decision = DECISION.DENY;
290
+ decision.verdict = "deny";
291
+ decision.reason = `Denied by ${approval.decidedBy}. ${decision.reason}`;
292
+ }
293
+ }
294
+ } catch (err) {
295
+ // The approval store is unreachable or unwritable. A held call whose
296
+ // approval cannot be recorded must not proceed, and must not silently
297
+ // become a pending request nobody will ever see.
298
+ decision.decision = DECISION.DENY;
299
+ decision.verdict = "deny";
300
+ decision.rule = "approval-unavailable";
301
+ decision.reason = `This call needs human approval and the approval store is unavailable (${err.message}). Refused rather than held, because a hold nobody can see is not a hold.`;
302
+ decision.remediation = "Check the approval log is writable, then retry.";
303
+ }
304
+ } else if (decision.decision === DECISION.REQUIRE_APPROVAL) {
305
+ decision.approvalId = `apr_${id.slice(4)}`;
306
+ }
307
+ stages.approval = t();
308
+
309
+ /* ------------------------------------------- 7. substitution + sanitize */
310
+ t = timer();
311
+ let outgoing = call.arguments;
312
+ let brokered = [];
313
+
314
+ if (isForwarded(decision.decision) && this.secrets) {
315
+ /*
316
+ * A broker that throws is a broker that cannot be trusted, so the call
317
+ * does not go out.
318
+ *
319
+ * The failure mode this closes: the vault is unreachable, `substitute`
320
+ * rejects, the exception escapes `submit`, and the transport above sees
321
+ * an unhandled rejection rather than a decision. The agent then hangs
322
+ * with no answer — fail-closed by accident, illegible by design. Worse,
323
+ * a caller that wraps `submit` in a try/catch and continues would forward
324
+ * arguments that were never brokered.
325
+ */
326
+ let substitution;
327
+ try {
328
+ substitution = await this.secrets.substitute(call.arguments, {
329
+ destination: call.destination,
330
+ // Who is spending the handle. A handle is deliberately not secret —
331
+ // it appears in arguments, logs, and results another agent can read —
332
+ // so possession must not be authority. See `Vault#authorize`.
333
+ subject: call.agent,
334
+ });
335
+ } catch (err) {
336
+ substitution = {
337
+ ok: false,
338
+ reason: `The secret broker is unavailable (${err.message}), so this call was refused rather than sent with unresolved arguments.`,
339
+ };
340
+ }
341
+
342
+ /*
343
+ * Success must be positively affirmed, in the expected shape.
344
+ *
345
+ * `substitution.ok` alone was a truthiness check, so a broker returning
346
+ * `{ ok: "yes" }` took the success branch and forwarded
347
+ * `substitution.value` — which was `undefined`. The arguments an agent
348
+ * sent were replaced with nothing and the call went out anyway. `null`
349
+ * was worse: reading `.ok` threw, and the exception escaped into the
350
+ * transport.
351
+ *
352
+ * A component that cannot answer in its own contract has not answered.
353
+ */
354
+ const substituted =
355
+ substitution !== null &&
356
+ typeof substitution === "object" &&
357
+ substitution.ok === true &&
358
+ substitution.value !== undefined;
359
+
360
+ if (substituted) {
361
+ outgoing = substitution.value;
362
+ brokered = Array.isArray(substitution.substituted) ? substitution.substituted : [];
363
+ } else if (substitution === null || typeof substitution !== "object" || substitution.ok !== false) {
364
+ decision.decision = DECISION.DENY;
365
+ decision.verdict = "deny";
366
+ decision.rule = "secret-broker";
367
+ decision.reason =
368
+ "The secret broker returned a response this engine cannot interpret, so the call was refused rather than forwarded with unverified arguments.";
369
+ decision.remediation = "Check the broker version matches the runtime.";
370
+ } else {
371
+ // A broker refusal converts the permit into a deny carrying its own
372
+ // rule, so one call still produces exactly one decision.
373
+ decision.decision = DECISION.DENY;
374
+ decision.verdict = "deny";
375
+ decision.rule = "secret-broker";
376
+ decision.reason = substitution.reason;
377
+ decision.remediation =
378
+ "Request a handle scoped to this destination, or add the destination to the secret's allowlist.";
379
+ }
380
+ }
381
+
382
+ if (decision.decision === DECISION.SANITIZE) {
383
+ const targets = new Set(decision.sanitize?.flatMap((s) => s.targets) ?? ["arguments", "result"]);
384
+ if (targets.has("arguments")) {
385
+ const cleaned = redactSecrets(outgoing);
386
+ if (cleaned.findings.length) {
387
+ outgoing = cleaned.value;
388
+ this.stats.sanitized++;
389
+ decision.sanitized = {
390
+ arguments: cleaned.findings.map((f) => ({
391
+ path: f.path,
392
+ detector: f.detector,
393
+ fingerprint: f.fingerprint,
394
+ })),
395
+ };
396
+ }
397
+ }
398
+ }
399
+ stages.substitute = t();
400
+
401
+ /* ------------------------------------------------------------- 8. audit */
402
+ t = timer();
403
+ const latency = total();
404
+
405
+ const event = {
406
+ request_id: id,
407
+ decision_id: decision.decision_id,
408
+ run_id: this.runId,
409
+ agent: call.agent,
410
+ source: call.source,
411
+ server: call.server,
412
+ tool: call.tool,
413
+ action: call.action,
414
+ resource: call.resource,
415
+ destination: call.destination,
416
+ command: call.command ? call.command.slice(0, 500) : null,
417
+ risk: call.risk,
418
+ risk_signals: call.risk_signals,
419
+ decision: decision.decision,
420
+ verdict: decision.verdict,
421
+ policy: decision.rule,
422
+ reason: decision.reason,
423
+ enforced: decision.enforced !== false,
424
+ mode: decision.mode ?? this.mode,
425
+ timestamp: call.timestamp,
426
+ latency_ms: Number(latency.toFixed(3)),
427
+ stages: Object.fromEntries(Object.entries(stages).map(([k, v]) => [k, Number(v.toFixed(3))])),
428
+ ...(decision.wouldHave ? { would_have: decision.wouldHave } : {}),
429
+ ...(decision.riskEscalated ? { risk_escalated: true } : {}),
430
+ ...(call.delegation ? { delegation: call.delegation } : {}),
431
+ ...(decision.approvalId ? { approval_id: decision.approvalId } : {}),
432
+ ...(brokered.length ? { secrets_brokered: brokered } : {}),
433
+ // Findings never carry the value — see secret-detect.mjs.
434
+ ...(argumentFindings.length
435
+ ? {
436
+ secrets_detected: argumentFindings.map((f) => ({
437
+ path: f.path,
438
+ detector: f.detector,
439
+ severity: f.severity,
440
+ masked: f.masked,
441
+ fingerprint: f.fingerprint,
442
+ })),
443
+ }
444
+ : {}),
445
+ ...(decision.sanitized ? { sanitized: decision.sanitized } : {}),
446
+ ...(decision.observed ? { observed_by: decision.observed.map((o) => o.rule) } : {}),
447
+ considered: decision.considered?.slice(0, 200),
448
+ };
449
+
450
+ /*
451
+ * AN UNRECORDABLE DECISION IS A REFUSED DECISION.
452
+ *
453
+ * If the audit chain cannot be written — disk full, permission denied,
454
+ * directory gone — then a call that proceeds is a call with no history, and
455
+ * the whole tamper-evidence property becomes "true of the records that
456
+ * happened to be writable". So a failed append turns a forwarded decision
457
+ * into a denial.
458
+ *
459
+ * A decision that was ALREADY a denial is left alone: refusing it a second
460
+ * time changes nothing, and reporting the storage failure as though it were
461
+ * the reason would misattribute the refusal in the one record an operator
462
+ * later reads.
463
+ */
464
+ if (this.audit) {
465
+ try {
466
+ await this.audit.append(event);
467
+ } catch (err) {
468
+ this.log(`audit append failed: ${err.message}`);
469
+ if (isForwarded(decision.decision)) {
470
+ decision.decision = DECISION.DENY;
471
+ decision.verdict = "deny";
472
+ decision.rule = "audit-unavailable";
473
+ decision.reason = `The decision could not be recorded (${err.message}), so the call was refused. A call with no audit record is a call nobody can account for.`;
474
+ decision.remediation = "Check the audit log path is writable, then retry.";
475
+
476
+ event.decision = DECISION.DENY;
477
+ event.verdict = "deny";
478
+ event.policy = "audit-unavailable";
479
+ event.reason = decision.reason;
480
+ event.audit_write_failed = true;
481
+ outgoing = call.arguments;
482
+ } else {
483
+ event.audit_write_failed = true;
484
+ }
485
+ }
486
+ }
487
+ stages.audit = t();
488
+ event.stages.audit = Number(stages.audit.toFixed(3));
489
+
490
+ this.#count(decision, latency);
491
+ this.onEvent({ kind: "decision", ...event });
492
+
493
+ // Any permitted read of secret-shaped material taints the session. A
494
+ // brokered substitution deliberately does not: the agent never held the
495
+ // material, which is the entire point of a handle.
496
+ if (isForwarded(decision.decision) && /secret|credential|token|password|\.env/i.test(call.resource)) {
497
+ this.touchedSecret = true;
498
+ }
499
+
500
+ return { event, call, decision, arguments: outgoing };
501
+ }
502
+
503
+ /**
504
+ * The return path. Scrubs a tool result before it reaches the model.
505
+ *
506
+ * Two distinct jobs that are easy to conflate:
507
+ *
508
+ * 1. Credential material — a key the upstream echoed back. Swapped for its
509
+ * handle if the vault knows it, masked if it does not.
510
+ * 2. Injected instructions — text in a fetched page or a tool result that
511
+ * is addressed to the model rather than to the user. Stripped only when
512
+ * the decision asked for sanitization, because rewriting every result by
513
+ * default would corrupt legitimate content that merely discusses
514
+ * prompts.
515
+ */
516
+ scrubResult(payload, decision = {}) {
517
+ let out = payload;
518
+ const findings = [];
519
+
520
+ if (this.secrets) {
521
+ const result = this.secrets.redact(out);
522
+ out = result.payload;
523
+ for (const f of result.findings ?? []) findings.push({ ...f, kind: "credential" });
524
+ for (const d of result.detected ?? []) {
525
+ findings.push({ kind: "credential", detector: d.detector, path: d.path, masked: d.masked });
526
+ }
527
+ } else {
528
+ const result = redactSecrets(out);
529
+ out = result.value;
530
+ for (const f of result.findings) {
531
+ findings.push({ kind: "credential", detector: f.detector, path: f.path, masked: f.masked });
532
+ }
533
+ }
534
+
535
+ const wantsResultSanitize =
536
+ decision.decision === DECISION.SANITIZE &&
537
+ (decision.sanitize ?? []).some((s) => s.targets.includes("result"));
538
+
539
+ if (wantsResultSanitize) {
540
+ const stripped = stripInjection(out);
541
+ out = stripped.value;
542
+ for (const f of stripped.findings) findings.push({ ...f, kind: "injection" });
543
+ }
544
+
545
+ if (findings.length) {
546
+ this.stats.leaks += findings.filter((f) => f.kind === "credential").length;
547
+ this.onEvent({
548
+ kind: "scrub",
549
+ agent: this.agent,
550
+ findings: findings.map((f) => ({ kind: f.kind, detector: f.detector ?? f.rule, path: f.path })),
551
+ });
552
+ }
553
+
554
+ return { payload: out, findings };
555
+ }
556
+
557
+ /* ------------------------------------------------------------------------ */
558
+
559
+ /**
560
+ * Stage 1: turn whatever arrived into `{ tool, server, arguments }`.
561
+ *
562
+ * Accepts a JSON-RPC `tools/call` message, an already-flat call, or the
563
+ * namespaced `server__tool` form the gateway uses. A shape it cannot read
564
+ * becomes a call with an empty tool name, which default-deny then refuses —
565
+ * rather than throwing, which would turn a malformed frame from a hostile
566
+ * upstream into a crash.
567
+ */
568
+ #parse(raw) {
569
+ if (raw?.method === "tools/call") {
570
+ const full = raw.params?.name ?? "";
571
+ const sep = full.indexOf("__");
572
+ return {
573
+ server: sep === -1 ? null : full.slice(0, sep),
574
+ tool: sep === -1 ? full : full.slice(sep + 2),
575
+ arguments: raw.params?.arguments ?? {},
576
+ };
577
+ }
578
+ const full = String(raw?.tool ?? raw?.name ?? "");
579
+ const sep = raw?.server ? -1 : full.indexOf("__");
580
+ return {
581
+ server: raw?.server ?? (sep === -1 ? null : full.slice(0, sep)),
582
+ tool: sep === -1 ? full : full.slice(sep + 2),
583
+ arguments: raw?.arguments ?? raw?.args ?? raw?.params ?? {},
584
+ };
585
+ }
586
+
587
+ #count(decision, latency) {
588
+ this.stats.calls++;
589
+ this.stats.latencyTotal += latency;
590
+ // Bounded so a long-running gateway does not grow without limit; the tail
591
+ // is what percentiles are computed from and 10k samples is plenty for P99.
592
+ this.stats.latencies.push(latency);
593
+ if (this.stats.latencies.length > 10_000) this.stats.latencies.shift();
594
+
595
+ switch (decision.decision) {
596
+ case DECISION.ALLOW:
597
+ this.stats.allowed++;
598
+ break;
599
+ case DECISION.DENY:
600
+ this.stats.denied++;
601
+ break;
602
+ case DECISION.REQUIRE_APPROVAL:
603
+ this.stats.approvals++;
604
+ break;
605
+ case DECISION.SANITIZE:
606
+ this.stats.allowed++;
607
+ break;
608
+ case DECISION.AUDIT_ONLY:
609
+ this.stats.auditOnly++;
610
+ break;
611
+ default:
612
+ break;
613
+ }
614
+ }
615
+
616
+ /** Latency percentiles over the retained window. */
617
+ percentiles() {
618
+ const s = [...this.stats.latencies].sort((a, b) => a - b);
619
+ if (!s.length) return { p50: 0, p95: 0, p99: 0, max: 0, samples: 0 };
620
+ const at = (q) => s[Math.min(s.length - 1, Math.floor(q * s.length))];
621
+ return {
622
+ p50: Number(at(0.5).toFixed(3)),
623
+ p95: Number(at(0.95).toFixed(3)),
624
+ p99: Number(at(0.99).toFixed(3)),
625
+ max: Number(s[s.length - 1].toFixed(3)),
626
+ samples: s.length,
627
+ };
628
+ }
629
+ }