@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,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision core, and the in-process SDK built on it.
|
|
3
|
+
*
|
|
4
|
+
* The MCP gateway and `guard.wrap()` are two transports for one question: may
|
|
5
|
+
* this agent make this tool call. They MUST NOT be two implementations of the
|
|
6
|
+
* answer. A guard that permits what the gateway denies is worse than having no
|
|
7
|
+
* SDK at all — it is a governance product with a documented bypass — so the
|
|
8
|
+
* decision path lives here once and both call it.
|
|
9
|
+
*
|
|
10
|
+
* WHAT `wrap` IS FOR
|
|
11
|
+
*
|
|
12
|
+
* The gateway governs everything an agent does, including tools added after
|
|
13
|
+
* you deployed it, because it sits on the wire. It also requires the agent to
|
|
14
|
+
* speak MCP. `wrap` is for the case where it does not: a LangChain executor, a
|
|
15
|
+
* CrewAI crew, a hand-rolled loop over some functions. You give up the
|
|
16
|
+
* "governs tools you did not know about" property — you are wrapping a list —
|
|
17
|
+
* and you keep every other one: same engine, same rules, same decision record,
|
|
18
|
+
* same secret brokering, same audit chain.
|
|
19
|
+
*
|
|
20
|
+
* That trade is stated in the docs rather than glossed, because an operator
|
|
21
|
+
* who believes `wrap` is equivalent to the gateway will not understand why a
|
|
22
|
+
* tool the agent reached directly was never evaluated.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { canonicalizeResource, evaluate } from "./policy.mjs";
|
|
26
|
+
import { canonicalUrl } from "./canonical.mjs";
|
|
27
|
+
import { escalateForRisk, toDecision } from "./decisions.mjs";
|
|
28
|
+
import { classify } from "./risk.mjs";
|
|
29
|
+
import { classifyTool, extractCommand, publicToolName } from "./normalize.mjs";
|
|
30
|
+
import { scan as scanSecrets } from "./secret-detect.mjs";
|
|
31
|
+
import { applyDelegation } from "./delegation.mjs";
|
|
32
|
+
import { applyEntitlements } from "./entitlement-gate.mjs";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A refusal the agent can read and plan around.
|
|
36
|
+
*
|
|
37
|
+
* Thrown rather than returned because a wrapped tool has to interrupt the
|
|
38
|
+
* call, and carrying structure rather than a string is what lets an agent
|
|
39
|
+
* re-plan instead of retrying the same thing: `remediation` frequently names
|
|
40
|
+
* the legitimate path ("request it as a handle").
|
|
41
|
+
*/
|
|
42
|
+
export class CirvixDenied extends Error {
|
|
43
|
+
constructor({ policy, decisionId, reason, remediation, appealable = false, resource, action }) {
|
|
44
|
+
super(reason ?? `Denied by ${policy ?? "policy"}.`);
|
|
45
|
+
this.name = "CirvixDenied";
|
|
46
|
+
/** The rule that decided it. */
|
|
47
|
+
this.policy = policy ?? null;
|
|
48
|
+
/** Hand to `cirvix why` for the full record. */
|
|
49
|
+
this.decisionId = decisionId ?? null;
|
|
50
|
+
this.reason = reason ?? null;
|
|
51
|
+
this.remediation = remediation ?? null;
|
|
52
|
+
/** Whether re-requesting with an approval could succeed. */
|
|
53
|
+
this.appealable = appealable;
|
|
54
|
+
this.resource = resource ?? null;
|
|
55
|
+
this.action = action ?? null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A call suspended for a person.
|
|
61
|
+
*
|
|
62
|
+
* A distinct type from a denial, because they call for different behaviour: a
|
|
63
|
+
* denial means re-plan, a hold means this exact call may still happen once
|
|
64
|
+
* somebody says yes. Collapsing them teaches agents to treat both as failure.
|
|
65
|
+
*/
|
|
66
|
+
export class CirvixHeld extends CirvixDenied {
|
|
67
|
+
constructor(fields) {
|
|
68
|
+
super({ ...fields, appealable: true });
|
|
69
|
+
this.name = "CirvixHeld";
|
|
70
|
+
this.approvers = fields.approvers ?? [];
|
|
71
|
+
this.approvalId = fields.approvalId ?? null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Maps a tool name to the action vocabulary policy is written against.
|
|
77
|
+
*
|
|
78
|
+
* DELEGATES TO `classifyTool`. THERE IS ONE CLASSIFIER.
|
|
79
|
+
*
|
|
80
|
+
* This used to be a second, independent set of patterns, and the two disagreed.
|
|
81
|
+
* `fetch_url` was `http.request` to the pipeline and `fs.read` here — so the
|
|
82
|
+
* gateway evaluated a network fetch against the filesystem rules, and a policy
|
|
83
|
+
* that read correctly governed a different thing depending on which door the
|
|
84
|
+
* call came through. `fetch_file` had the mirror-image bug in the other
|
|
85
|
+
* direction.
|
|
86
|
+
*
|
|
87
|
+
* That is the same class of defect as two policy engines, one level further
|
|
88
|
+
* up: what a tool *is* has to be decided once, or every rule below it is
|
|
89
|
+
* conditional on the transport. The consistency oracle found it.
|
|
90
|
+
*
|
|
91
|
+
* The MCP resource operations keep their explicit mapping, because they are
|
|
92
|
+
* protocol methods rather than tool names and `classifyTool` has no reason to
|
|
93
|
+
* know about them.
|
|
94
|
+
*/
|
|
95
|
+
export function actionForTool(server, tool) {
|
|
96
|
+
const t = String(tool).toLowerCase();
|
|
97
|
+
|
|
98
|
+
/*
|
|
99
|
+
* A resource URI is overwhelmingly a file, and a subscription is a standing
|
|
100
|
+
* read of one. Mapped explicitly because `resources.subscribe` matches no
|
|
101
|
+
* pattern and would fall through to `mcp.<server>.resources.subscribe` —
|
|
102
|
+
* default-denied, so every legitimate subscription breaks, and governed by no
|
|
103
|
+
* filesystem rule, so the rules protecting `~/.aws/**` would not apply if
|
|
104
|
+
* someone later added a permit for it.
|
|
105
|
+
*/
|
|
106
|
+
if (t === "resources.read" || t === "resources.subscribe") return "fs.read";
|
|
107
|
+
if (t === "resources.list" || t === "resources.templates.list") return "fs.list";
|
|
108
|
+
|
|
109
|
+
return classifyTool(tool, server).action;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Extracts the resource a call targets. Best-effort by design: an unrecognised
|
|
114
|
+
* shape yields the empty string, so the call is still evaluated rather than
|
|
115
|
+
* skipped.
|
|
116
|
+
*/
|
|
117
|
+
export function resourceForCall(args) {
|
|
118
|
+
if (!args || typeof args !== "object") return "";
|
|
119
|
+
for (const key of [
|
|
120
|
+
"path",
|
|
121
|
+
"file",
|
|
122
|
+
"filename",
|
|
123
|
+
"filepath",
|
|
124
|
+
"uri",
|
|
125
|
+
"url",
|
|
126
|
+
"resource",
|
|
127
|
+
"target",
|
|
128
|
+
"query",
|
|
129
|
+
"sql",
|
|
130
|
+
]) {
|
|
131
|
+
const v = args[key];
|
|
132
|
+
if (typeof v === "string" && v.length) return v;
|
|
133
|
+
}
|
|
134
|
+
const first = Object.values(args).find((v) => typeof v === "string" && v.length);
|
|
135
|
+
return typeof first === "string" ? first : "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The endpoint a call will reach, or null. Only an absolute http(s) URL counts.
|
|
140
|
+
*
|
|
141
|
+
* Canonical, not raw. A destination rule is a string match, so the raw form let
|
|
142
|
+
* `http://2852039166/` — decimal for 169.254.169.254 — walk past a rule naming
|
|
143
|
+
* the dotted address. Same normalization as `normalize.extractDestination`,
|
|
144
|
+
* because the gateway reaches policy through this function and the socket
|
|
145
|
+
* reaches it through that one; two spellings of the destination is two policies.
|
|
146
|
+
*/
|
|
147
|
+
export function destinationFor(resource, args) {
|
|
148
|
+
const candidates = [resource, args?.url, args?.uri, args?.endpoint, args?.href];
|
|
149
|
+
for (const candidate of candidates) {
|
|
150
|
+
if (typeof candidate === "string" && /^https?:\/\//i.test(candidate)) {
|
|
151
|
+
return canonicalUrl(candidate) ?? candidate;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* -------------------------------------------------------------------------- */
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One decision, made the same way wherever it is made from.
|
|
161
|
+
*
|
|
162
|
+
* Holds the session-scoped state a verdict can depend on — most importantly
|
|
163
|
+
* `touchedSecret`, which is what makes "read a credential, then post it
|
|
164
|
+
* somewhere" fail even when both calls are individually allowed.
|
|
165
|
+
*/
|
|
166
|
+
export class Guard {
|
|
167
|
+
constructor({
|
|
168
|
+
rules,
|
|
169
|
+
agent = "local",
|
|
170
|
+
environment = "local",
|
|
171
|
+
cwd = process.cwd(),
|
|
172
|
+
audit = null,
|
|
173
|
+
secrets = null,
|
|
174
|
+
onDecision = () => {},
|
|
175
|
+
log = () => {},
|
|
176
|
+
runId = null,
|
|
177
|
+
riskFloor = "high",
|
|
178
|
+
delegation = null,
|
|
179
|
+
/* Commercial enforcement. All three default to absent, so a Guard built
|
|
180
|
+
without them behaves exactly as before — which is what keeps the SDK's
|
|
181
|
+
library callers and the shared conformance fixture working unchanged.
|
|
182
|
+
The CLI supplies them. */
|
|
183
|
+
licence = null,
|
|
184
|
+
meter = null,
|
|
185
|
+
agents = null,
|
|
186
|
+
} = {}) {
|
|
187
|
+
this.rules = rules ?? [];
|
|
188
|
+
this.agent = agent;
|
|
189
|
+
this.environment = environment;
|
|
190
|
+
this.cwd = cwd;
|
|
191
|
+
this.audit = audit;
|
|
192
|
+
this.secrets = secrets;
|
|
193
|
+
/** DelegationBroker, when agent-to-agent delegation is in use. */
|
|
194
|
+
this.delegation = delegation;
|
|
195
|
+
this.licence = licence;
|
|
196
|
+
this.meter = meter;
|
|
197
|
+
this.agents = agents;
|
|
198
|
+
this.onDecision = onDecision;
|
|
199
|
+
this.log = log;
|
|
200
|
+
this.runId = runId;
|
|
201
|
+
/** Risk level at or above which an unnamed call is escalated to approval. */
|
|
202
|
+
this.riskFloor = riskFloor;
|
|
203
|
+
/** Set once this session reads secret-shaped material. */
|
|
204
|
+
this.touchedSecret = false;
|
|
205
|
+
this.stats = { calls: 0, permitted: 0, denied: 0, held: 0, leaks: 0, latencyTotal: 0 };
|
|
206
|
+
this.nextId = 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Decides one call, and brokers any secret handles it carries.
|
|
211
|
+
*
|
|
212
|
+
* Returns the decision plus the arguments to forward — which are not
|
|
213
|
+
* necessarily the arguments passed in, because handles are substituted here
|
|
214
|
+
* and nowhere else.
|
|
215
|
+
*
|
|
216
|
+
* @returns {Promise<{decision:object, record:object, args:any}>}
|
|
217
|
+
*/
|
|
218
|
+
async authorize({ tool, server = null, args = {}, delegation = null, agent = null }) {
|
|
219
|
+
const action = actionForTool(server, tool);
|
|
220
|
+
const resource = resourceForCall(args);
|
|
221
|
+
// A caller may act as a specific agent per call — a gateway serving several
|
|
222
|
+
// agents must not evaluate all of them under one configured name.
|
|
223
|
+
const caller = agent ?? this.agent;
|
|
224
|
+
|
|
225
|
+
// Measured around the decision itself, not the tool round trip — the
|
|
226
|
+
// latter is orders of magnitude larger and would flatter us dishonestly.
|
|
227
|
+
const startedAt = process.hrtime.bigint();
|
|
228
|
+
|
|
229
|
+
/*
|
|
230
|
+
* RISK AND SECRET DETECTION RUN HERE, NOT ONLY IN THE PIPELINE.
|
|
231
|
+
*
|
|
232
|
+
* They used to run only in `Pipeline`, and the gateway does not go through
|
|
233
|
+
* `Pipeline` — it goes through this method. The consequence was not a
|
|
234
|
+
* missing feature, it was a silent one: a rule saying `risk >= HIGH` or
|
|
235
|
+
* `command = "rm -rf"` loaded, validated, appeared in `cirvix policy list`,
|
|
236
|
+
* fired correctly over the local socket, and never matched a single call
|
|
237
|
+
* arriving over MCP. The two surfaces enforced different policies from the
|
|
238
|
+
* same file.
|
|
239
|
+
*
|
|
240
|
+
* That is exactly the bypass this file's header warns about, so the fix is
|
|
241
|
+
* the one the header demands: one context builder, used by both. The
|
|
242
|
+
* end-to-end MCP test now asserts it.
|
|
243
|
+
*/
|
|
244
|
+
const scanned = scanSecrets(args);
|
|
245
|
+
const classified = classify({
|
|
246
|
+
action,
|
|
247
|
+
tool,
|
|
248
|
+
resource: canonicalizeResource(resource, this.cwd),
|
|
249
|
+
command: extractCommand(args),
|
|
250
|
+
destination: destinationFor(resource, args),
|
|
251
|
+
environment: this.environment,
|
|
252
|
+
insideWorkspace: this.insideWorkspace(resource),
|
|
253
|
+
touchedSecret: this.touchedSecret,
|
|
254
|
+
secretsDetected: scanned.length,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const context = {
|
|
258
|
+
environment: this.environment,
|
|
259
|
+
path: { insideWorkspace: this.insideWorkspace(resource) },
|
|
260
|
+
egress: {
|
|
261
|
+
external: this.isExternal(resource),
|
|
262
|
+
internal: false,
|
|
263
|
+
allowlisted: false,
|
|
264
|
+
destination: destinationFor(resource, args),
|
|
265
|
+
},
|
|
266
|
+
session: { touchedSecret: this.touchedSecret },
|
|
267
|
+
mcp: { server, tool },
|
|
268
|
+
risk: classified.level,
|
|
269
|
+
tool: publicToolName(action),
|
|
270
|
+
command: extractCommand(args),
|
|
271
|
+
secrets: { detected: scanned.length },
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const decision = evaluate(
|
|
275
|
+
{ agent: caller, action, resource, context },
|
|
276
|
+
this.rules,
|
|
277
|
+
{ cwd: this.cwd },
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
decision.decision = decision.decision ?? toDecision(decision.verdict);
|
|
281
|
+
decision.risk = classified.level;
|
|
282
|
+
decision.riskSignals = classified.signals.map((s) => s.id);
|
|
283
|
+
|
|
284
|
+
// The risk floor is a floor: it can escalate an unnamed decision, and it
|
|
285
|
+
// can never de-escalate one a rule made explicitly.
|
|
286
|
+
const escalated = escalateForRisk(decision, classified, { floor: this.riskFloor });
|
|
287
|
+
Object.assign(decision, escalated);
|
|
288
|
+
|
|
289
|
+
/*
|
|
290
|
+
* DELEGATION NARROWS HERE TOO, NOT ONLY IN THE PIPELINE.
|
|
291
|
+
*
|
|
292
|
+
* It used to narrow only in `Pipeline`, and the gateway does not go through
|
|
293
|
+
* `Pipeline` — it goes through this method. Same shape as the risk-rule
|
|
294
|
+
* bypass documented above, with a worse failure direction: delegation only
|
|
295
|
+
* ever takes authority away, so a surface that ignores it does not lose a
|
|
296
|
+
* feature, it grants everything policy allows. A worker delegated `fs.read`
|
|
297
|
+
* could write the database simply by arriving over MCP instead of the
|
|
298
|
+
* socket.
|
|
299
|
+
*
|
|
300
|
+
* `applyDelegation` is the single implementation both engines call, so
|
|
301
|
+
* there is no second copy to drift.
|
|
302
|
+
*/
|
|
303
|
+
const delegationContext = applyDelegation(decision, {
|
|
304
|
+
broker: this.delegation,
|
|
305
|
+
presented: delegation,
|
|
306
|
+
agent: caller,
|
|
307
|
+
action,
|
|
308
|
+
resource: decision.resource ?? resource,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
/*
|
|
312
|
+
* THE COMMERCIAL GATE RUNS HERE TOO, NOT ONLY IN THE PIPELINE.
|
|
313
|
+
*
|
|
314
|
+
* Same shape as the two bypasses documented above, and the same cause: the
|
|
315
|
+
* quota and concurrent-agent limits existed only in `Pipeline`, and
|
|
316
|
+
* neither `guard.wrap()` nor the MCP gateway goes through `Pipeline`. A
|
|
317
|
+
* Free-tier user on either path was never metered, the published limits
|
|
318
|
+
* were not enforced, and the upgrade prompt the pricing depends on could
|
|
319
|
+
* not fire.
|
|
320
|
+
*
|
|
321
|
+
* `applyEntitlements` is the single implementation both cores call. With
|
|
322
|
+
* no licence and no meter it returns the decision untouched, so library
|
|
323
|
+
* callers and the shared conformance fixture are unaffected.
|
|
324
|
+
*/
|
|
325
|
+
Object.assign(
|
|
326
|
+
decision,
|
|
327
|
+
applyEntitlements(decision, {
|
|
328
|
+
licence: this.licence,
|
|
329
|
+
meter: this.meter,
|
|
330
|
+
agents: this.agents,
|
|
331
|
+
agent: caller,
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const latencyMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
336
|
+
const decisionId = `dec_${Date.now().toString(36)}${(this.nextId++).toString(36)}`;
|
|
337
|
+
decision.decisionId = decisionId;
|
|
338
|
+
this.stats.calls++;
|
|
339
|
+
this.stats.latencyTotal += latencyMs;
|
|
340
|
+
|
|
341
|
+
// Substitution sits between the decision and the record, so one call still
|
|
342
|
+
// produces exactly one decision. A broker refusal turns the permit into a
|
|
343
|
+
// deny carrying its own rule rather than emitting a second decision.
|
|
344
|
+
let outgoing = args;
|
|
345
|
+
let brokered = [];
|
|
346
|
+
if (this.secrets && decision.verdict === "permit") {
|
|
347
|
+
const substitution = await this.secrets.substitute(args, {
|
|
348
|
+
destination: destinationFor(decision.resource, args),
|
|
349
|
+
// See the matching note in `Pipeline`: possession of a handle is not
|
|
350
|
+
// authority to spend it.
|
|
351
|
+
subject: caller,
|
|
352
|
+
});
|
|
353
|
+
if (substitution.ok) {
|
|
354
|
+
outgoing = substitution.value;
|
|
355
|
+
brokered = substitution.substituted;
|
|
356
|
+
} else {
|
|
357
|
+
decision.verdict = "deny";
|
|
358
|
+
decision.rule = "secret-broker";
|
|
359
|
+
decision.reason = substitution.reason;
|
|
360
|
+
decision.remediation =
|
|
361
|
+
"Request a handle scoped to this destination, or add the destination to the secret's allowlist.";
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const record = {
|
|
366
|
+
decision_id: decisionId,
|
|
367
|
+
// Both spellings, deliberately. `cirvix logs`, `replay`, and the control
|
|
368
|
+
// plane read `request_id`; the older records and the SDK read
|
|
369
|
+
// `decision_id`. Emitting one and not the other split the history in two.
|
|
370
|
+
request_id: decisionId.replace(/^dec_/, "req_"),
|
|
371
|
+
runId: this.runId,
|
|
372
|
+
run_id: this.runId,
|
|
373
|
+
agent: caller,
|
|
374
|
+
server,
|
|
375
|
+
tool,
|
|
376
|
+
action,
|
|
377
|
+
resource: decision.resource,
|
|
378
|
+
verdict: decision.verdict,
|
|
379
|
+
decision: decision.decision,
|
|
380
|
+
rule: decision.rule,
|
|
381
|
+
// `policy` is what the journal renders and what the console joins on.
|
|
382
|
+
policy: decision.rule,
|
|
383
|
+
reason: decision.reason,
|
|
384
|
+
risk: decision.risk,
|
|
385
|
+
risk_signals: decision.riskSignals,
|
|
386
|
+
latencyMs: Number(latencyMs.toFixed(3)),
|
|
387
|
+
latency_ms: Number(latencyMs.toFixed(3)),
|
|
388
|
+
context,
|
|
389
|
+
considered: decision.considered?.slice(0, 200),
|
|
390
|
+
...(decision.riskEscalated ? { risk_escalated: true } : {}),
|
|
391
|
+
// Who authorized this must be answerable after the fact, on every surface
|
|
392
|
+
// — not only the one that happened to record it.
|
|
393
|
+
...(delegationContext ? { delegation: delegationContext } : {}),
|
|
394
|
+
...(brokered.length ? { secrets: brokered, secrets_brokered: brokered } : {}),
|
|
395
|
+
// Findings never carry the value — see secret-detect.mjs.
|
|
396
|
+
...(scanned.length
|
|
397
|
+
? {
|
|
398
|
+
secrets_detected: scanned.map((f) => ({
|
|
399
|
+
path: f.path,
|
|
400
|
+
detector: f.detector,
|
|
401
|
+
severity: f.severity,
|
|
402
|
+
masked: f.masked,
|
|
403
|
+
fingerprint: f.fingerprint,
|
|
404
|
+
})),
|
|
405
|
+
}
|
|
406
|
+
: {}),
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
if (this.audit) await this.audit.append(record);
|
|
410
|
+
this.onDecision({ kind: "decision", ...record });
|
|
411
|
+
|
|
412
|
+
if (decision.verdict === "deny") this.stats.denied++;
|
|
413
|
+
else if (decision.verdict === "hold") this.stats.held++;
|
|
414
|
+
else {
|
|
415
|
+
this.stats.permitted++;
|
|
416
|
+
// Any successful read of secret-shaped material taints the session. A
|
|
417
|
+
// brokered substitution deliberately does not: the agent never held the
|
|
418
|
+
// material, which is the entire point of a handle.
|
|
419
|
+
if (/secret|credential|token|password|\.env/i.test(decision.resource)) {
|
|
420
|
+
this.touchedSecret = true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return { decision, record, args: outgoing };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Scans a result for material this session resolved, and puts handles back. */
|
|
428
|
+
scrub(payload) {
|
|
429
|
+
if (!this.secrets) return { payload, findings: [] };
|
|
430
|
+
const result = this.secrets.redact(payload);
|
|
431
|
+
if (result.findings.length) {
|
|
432
|
+
this.stats.leaks += result.findings.length;
|
|
433
|
+
this.log(`leak caught on the return path: ${result.findings.map((f) => f.name).join(", ")}`);
|
|
434
|
+
this.onDecision({
|
|
435
|
+
kind: "leak",
|
|
436
|
+
agent: this.agent,
|
|
437
|
+
secrets: result.findings.map((f) => f.name),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Turns a non-permit verdict into the error a caller should see. */
|
|
444
|
+
toError(decision) {
|
|
445
|
+
const fields = {
|
|
446
|
+
policy: decision.rule,
|
|
447
|
+
decisionId: decision.decisionId,
|
|
448
|
+
reason: decision.reason,
|
|
449
|
+
remediation: decision.remediation,
|
|
450
|
+
resource: decision.resource,
|
|
451
|
+
action: decision.action,
|
|
452
|
+
};
|
|
453
|
+
return decision.verdict === "hold"
|
|
454
|
+
? new CirvixHeld({ ...fields, approvers: decision.approvers, approvalId: decision.approvalId })
|
|
455
|
+
: new CirvixDenied(fields);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
insideWorkspace(resource) {
|
|
459
|
+
if (!resource) return true;
|
|
460
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(resource)) return false;
|
|
461
|
+
const norm = (s) => s.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
462
|
+
const abs = /^([A-Za-z]:|\/)/.test(resource) ? resource : `${this.cwd}/${resource}`;
|
|
463
|
+
const parts = [];
|
|
464
|
+
for (const seg of norm(abs).split("/")) {
|
|
465
|
+
if (seg === "..") parts.pop();
|
|
466
|
+
else if (seg !== ".") parts.push(seg);
|
|
467
|
+
}
|
|
468
|
+
const flat = parts.join("/");
|
|
469
|
+
const root = norm(this.cwd);
|
|
470
|
+
return flat === root || flat.startsWith(root + "/");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
isExternal(resource) {
|
|
474
|
+
if (!/^https?:\/\//i.test(resource)) return false;
|
|
475
|
+
try {
|
|
476
|
+
const host = new URL(resource).hostname;
|
|
477
|
+
return !/^(localhost|127\.|::1|0\.0\.0\.0|.*\.internal|.*\.local)$/i.test(host);
|
|
478
|
+
} catch {
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* -------------------------------------------------------------------------- */
|
|
485
|
+
/* wrap */
|
|
486
|
+
/* -------------------------------------------------------------------------- */
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Governs a collection of tools in place.
|
|
490
|
+
*
|
|
491
|
+
* Accepts the three shapes tool collections actually come in and returns the
|
|
492
|
+
* same shape back, so this is a one-line change at the executor boundary
|
|
493
|
+
* rather than a rewrite of how tools are registered:
|
|
494
|
+
*
|
|
495
|
+
* - a plain object of `name → function`
|
|
496
|
+
* - an array of tool objects carrying a callable (`func`, `invoke`, `call`,
|
|
497
|
+
* `execute`, or `handler`) — LangChain, CrewAI, and AutoGen all land here
|
|
498
|
+
* - a single function, named by `options.name`
|
|
499
|
+
*
|
|
500
|
+
* The returned tools are the originals with the callable replaced. Everything
|
|
501
|
+
* else on them — descriptions, schemas, framework metadata — is preserved by
|
|
502
|
+
* reference, because a framework that reads `tool.schema` after wrapping must
|
|
503
|
+
* still find it.
|
|
504
|
+
*/
|
|
505
|
+
export function wrap(tools, options = {}) {
|
|
506
|
+
const guard = options.guard ?? new Guard(options);
|
|
507
|
+
|
|
508
|
+
if (typeof tools === "function") {
|
|
509
|
+
return wrapCallable(tools, options.name ?? tools.name ?? "tool", guard);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (Array.isArray(tools)) {
|
|
513
|
+
return tools.map((tool) => {
|
|
514
|
+
if (typeof tool === "function") return wrapCallable(tool, tool.name ?? "tool", guard);
|
|
515
|
+
const key = CALLABLE_KEYS.find((k) => typeof tool?.[k] === "function");
|
|
516
|
+
if (!key) return tool;
|
|
517
|
+
const name = tool.name ?? tool.title ?? "tool";
|
|
518
|
+
// A shallow copy with the callable replaced, rather than a mutation:
|
|
519
|
+
// frameworks hold references to the tool objects they were given, and
|
|
520
|
+
// mutating them governs the caller's array as a side effect of reading
|
|
521
|
+
// ours.
|
|
522
|
+
return Object.assign(Object.create(Object.getPrototypeOf(tool) ?? Object.prototype), tool, {
|
|
523
|
+
[key]: wrapCallable(tool[key].bind(tool), name, guard),
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (tools && typeof tools === "object") {
|
|
529
|
+
return Object.fromEntries(
|
|
530
|
+
Object.entries(tools).map(([name, value]) => [
|
|
531
|
+
name,
|
|
532
|
+
typeof value === "function" ? wrapCallable(value, name, guard) : value,
|
|
533
|
+
]),
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
throw new TypeError("guard.wrap expects a function, an array of tools, or an object of tools.");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const CALLABLE_KEYS = ["func", "invoke", "call", "execute", "handler", "_call", "run"];
|
|
541
|
+
|
|
542
|
+
function wrapCallable(fn, name, guard) {
|
|
543
|
+
const governed = async (...callArgs) => {
|
|
544
|
+
// Frameworks call tools with a single argument object, or positionally.
|
|
545
|
+
// Only the first form carries anything a policy can read, and pretending
|
|
546
|
+
// otherwise would evaluate a positional call against an empty resource and
|
|
547
|
+
// report the result as if it meant something.
|
|
548
|
+
const args = callArgs.length === 1 && isPlainObject(callArgs[0]) ? callArgs[0] : { input: callArgs[0] };
|
|
549
|
+
|
|
550
|
+
const { decision, args: outgoing } = await guard.authorize({ tool: name, args });
|
|
551
|
+
if (decision.verdict !== "permit") throw guard.toError(decision);
|
|
552
|
+
|
|
553
|
+
const result = await fn(...(callArgs.length === 1 && isPlainObject(callArgs[0]) ? [outgoing] : callArgs));
|
|
554
|
+
return guard.scrub(result).payload;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
// Frameworks introspect `fn.name` to build their tool registry, and an
|
|
558
|
+
// anonymous arrow would silently rename every governed tool.
|
|
559
|
+
Object.defineProperty(governed, "name", { value: name, configurable: true });
|
|
560
|
+
return governed;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function isPlainObject(value) {
|
|
564
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** The documented entry point: `guard.wrap(tools, { … })`. */
|
|
568
|
+
export const guard = { wrap, Guard };
|