@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.
- package/LICENSE +202 -0
- package/NOTICE +42 -0
- package/README.md +341 -0
- package/action/README.md +100 -0
- package/action/action.yml +134 -0
- package/action/report.mjs +144 -0
- package/bin/cirvix.mjs +1073 -0
- package/package.json +60 -0
- package/src/commands/demo.mjs +315 -0
- package/src/commands/init.mjs +558 -0
- package/src/commands/policy.mjs +345 -0
- package/src/commands/sarif.mjs +176 -0
- package/src/commands/scan.mjs +210 -0
- package/src/commands/status.mjs +208 -0
- package/src/commands/upgrade.mjs +162 -0
- package/src/core/approvals.mjs +388 -0
- package/src/core/audit.mjs +181 -0
- package/src/core/canonical.mjs +316 -0
- package/src/core/daemon.mjs +352 -0
- package/src/core/decisions.mjs +253 -0
- package/src/core/delegation.mjs +658 -0
- package/src/core/detect.mjs +337 -0
- package/src/core/entitlement-gate.mjs +100 -0
- package/src/core/entitlements.mjs +285 -0
- package/src/core/format.mjs +33 -0
- package/src/core/gateway.mjs +959 -0
- package/src/core/guard.mjs +568 -0
- package/src/core/http-transport.mjs +505 -0
- package/src/core/journal.mjs +419 -0
- package/src/core/jsonrpc.mjs +152 -0
- package/src/core/meter.mjs +225 -0
- package/src/core/normalize.mjs +516 -0
- package/src/core/notices.mjs +80 -0
- package/src/core/pipeline.mjs +629 -0
- package/src/core/policy-dsl.mjs +611 -0
- package/src/core/policy.mjs +710 -0
- package/src/core/prompts.mjs +146 -0
- package/src/core/risk.mjs +509 -0
- package/src/core/sanitize.mjs +279 -0
- package/src/core/secret-detect.mjs +533 -0
- package/src/core/secrets.mjs +312 -0
- package/src/core/uds.mjs +383 -0
- package/src/core/vault.mjs +530 -0
- package/src/index.mjs +143 -0
- 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
|
+
}
|