@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,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 };