@cirvix_ai/agent-control 0.1.0 → 0.1.2
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/package.json +1 -1
- package/src/core/gateway.mjs +188 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cirvix_ai/agent-control",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Cirvix AgentControl — runtime governance for AI agents. Scan what is ungoverned, evaluate policy, and broker tool calls.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/core/gateway.mjs
CHANGED
|
@@ -42,6 +42,17 @@
|
|
|
42
42
|
* the only point in the process where a credential exists inside a request,
|
|
43
43
|
* and it sits downstream of the decision that authorized it. See
|
|
44
44
|
* `./secrets.mjs`.
|
|
45
|
+
*
|
|
46
|
+
* WHAT THE GATEWAY DOES NOT GOVERN.
|
|
47
|
+
*
|
|
48
|
+
* The gateway governs traffic actually routed through it — every `tools/call`,
|
|
49
|
+
* `resources/read`, `resources/subscribe`, `prompts/get`, `completion/complete`,
|
|
50
|
+
* and unmodeled method crossing this process is evaluated before anything
|
|
51
|
+
* executes, and unknown methods are default-denied. What never enters this
|
|
52
|
+
* process is never evaluated: a direct MCP server entry in the agent's config,
|
|
53
|
+
* the runtime's built-in tools, a subprocess the agent spawns, a socket the
|
|
54
|
+
* agent opens itself. Those are routes around the boundary, not through it,
|
|
55
|
+
* and no userspace gateway can interpose on them.
|
|
45
56
|
*/
|
|
46
57
|
|
|
47
58
|
import { spawn } from "node:child_process";
|
|
@@ -465,10 +476,15 @@ export class Gateway {
|
|
|
465
476
|
/* ---------------------------------------------------------------------- */
|
|
466
477
|
|
|
467
478
|
async handleClientMessage(message) {
|
|
468
|
-
// Notifications
|
|
479
|
+
// Notifications carry no id and expect no answer, but they still reach
|
|
480
|
+
// upstream processes — so they still cross the boundary. A small set of
|
|
481
|
+
// lifecycle notifications is benign plumbing; anything else is evaluated
|
|
482
|
+
// like any other call, and dropped unless permitted. Previously every
|
|
483
|
+
// notification was broadcast to ALL upstreams unevaluated, so a
|
|
484
|
+
// tools/call-shaped action framed as a notification bypassed the engine
|
|
485
|
+
// entirely, with no decision and no audit record.
|
|
469
486
|
if (message.id === undefined && message.method) {
|
|
470
|
-
|
|
471
|
-
return;
|
|
487
|
+
return this.#handleNotification(message);
|
|
472
488
|
}
|
|
473
489
|
|
|
474
490
|
switch (message.method) {
|
|
@@ -525,11 +541,173 @@ export class Gateway {
|
|
|
525
541
|
this.write({ jsonrpc: "2.0", id: message.id, result: {} });
|
|
526
542
|
return;
|
|
527
543
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
544
|
+
/*
|
|
545
|
+
* `prompts/get` returns server-authored text that enters the model's
|
|
546
|
+
* context with instruction-level authority — the same reason tool
|
|
547
|
+
* definitions are pinned. It was falling through to the default branch
|
|
548
|
+
* and reaching the agent with no rule consulted and no decision
|
|
549
|
+
* recorded. It is now evaluated as a read of the named prompt.
|
|
550
|
+
*/
|
|
551
|
+
case "prompts/get":
|
|
552
|
+
return this.#handlePromptsGet(message);
|
|
553
|
+
|
|
554
|
+
/*
|
|
555
|
+
* `completion/complete` asks an upstream to complete an argument value.
|
|
556
|
+
* Low-risk content, but still upstream-influenced text entering the
|
|
557
|
+
* agent loop — evaluated, then forwarded on permit.
|
|
558
|
+
*/
|
|
559
|
+
case "completion/complete":
|
|
560
|
+
return this.#handleCompletion(message);
|
|
561
|
+
|
|
562
|
+
/*
|
|
563
|
+
* `logging/setLevel` carries no content and executes nothing: it asks
|
|
564
|
+
* upstreams to adjust log verbosity. Forwarded as benign plumbing, and
|
|
565
|
+
* reported on the protocol sink (not the decision sink) so it can never
|
|
566
|
+
* be mistaken for a policy decision.
|
|
567
|
+
*/
|
|
568
|
+
case "logging/setLevel":
|
|
569
|
+
this.onDecision({ kind: "protocol", method: message.method, action: "forward" });
|
|
570
|
+
this.log(`forward ${message.method} (benign protocol plumbing)`);
|
|
531
571
|
return this.#forwardToAny(message);
|
|
572
|
+
|
|
573
|
+
default:
|
|
574
|
+
// Default-deny applies to methods, not just tools. Anything the
|
|
575
|
+
// gateway does not model is evaluated as `mcp.<method>` against the
|
|
576
|
+
// active policy and forwarded only on an explicit permit — previously
|
|
577
|
+
// this branch forwarded to the first live upstream unevaluated and
|
|
578
|
+
// unrecorded, which made every unmodeled method a full bypass.
|
|
579
|
+
return this.#rejectUnknown(message);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* Allowlisted lifecycle notifications: session plumbing with no content and
|
|
584
|
+
* no upstream side effect beyond what the protocol requires. Reported on
|
|
585
|
+
* the protocol sink, never the decision sink. */
|
|
586
|
+
static #BENIGN_NOTIFICATIONS = new Set([
|
|
587
|
+
"notifications/initialized",
|
|
588
|
+
"notifications/cancelled",
|
|
589
|
+
"notifications/progress",
|
|
590
|
+
]);
|
|
591
|
+
|
|
592
|
+
async #handleNotification(message) {
|
|
593
|
+
if (Gateway.#BENIGN_NOTIFICATIONS.has(message.method)) {
|
|
594
|
+
this.onDecision({ kind: "protocol", method: message.method, action: "forward" });
|
|
595
|
+
for (const up of this.upstreams.values()) up.send(message);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const { decision } = await this.guard.authorize({
|
|
599
|
+
tool: `mcp.notification.${message.method}`,
|
|
600
|
+
server: null,
|
|
601
|
+
args: message.params ?? {},
|
|
602
|
+
...callerIdentity(message.params),
|
|
603
|
+
});
|
|
604
|
+
this.stats = this.guard.stats;
|
|
605
|
+
if (decision.verdict !== "permit") {
|
|
606
|
+
this.log(`DROP notification ${message.method} (${decision.rule ?? "default-deny"})`);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
this.onDecision({ kind: "protocol", method: message.method, action: "forward", decision: decision.decisionId });
|
|
610
|
+
for (const up of this.upstreams.values()) up.send(message);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async #handlePromptsGet(message) {
|
|
614
|
+
const fullName = message.params?.name ?? "";
|
|
615
|
+
const sep = fullName.indexOf(NS);
|
|
616
|
+
const server = sep === -1 ? null : fullName.slice(0, sep);
|
|
617
|
+
const promptName = sep === -1 ? fullName : fullName.slice(sep + NS.length);
|
|
618
|
+
const up = server ? this.upstreams.get(server) : null;
|
|
619
|
+
|
|
620
|
+
if (!up || !up.alive) {
|
|
621
|
+
this.write(
|
|
622
|
+
errorResponse(
|
|
623
|
+
message.id,
|
|
624
|
+
ERROR_CODE.UPSTREAM_UNAVAILABLE,
|
|
625
|
+
`No registered server for prompt "${fullName}".`,
|
|
626
|
+
),
|
|
627
|
+
);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const { agent: callerAgent, delegation } = callerIdentity(message.params);
|
|
632
|
+
const { decision } = await this.guard.authorize({
|
|
633
|
+
tool: "prompts.get",
|
|
634
|
+
server,
|
|
635
|
+
args: { name: promptName, ...(message.params?.arguments ?? {}) },
|
|
636
|
+
agent: callerAgent,
|
|
637
|
+
delegation,
|
|
638
|
+
});
|
|
639
|
+
this.stats = this.guard.stats;
|
|
640
|
+
|
|
641
|
+
if (decision.verdict === "deny") {
|
|
642
|
+
this.log(`DENY prompts/get ${promptName} (${decision.rule})`);
|
|
643
|
+
this.write(deniedToolResult(message.id, decision));
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (decision.verdict === "hold") {
|
|
647
|
+
decision.approvalId = `apr_${String(decision.decisionId).slice(4, 12)}`;
|
|
648
|
+
this.log(`HOLD prompts/get ${promptName} (${decision.rule})`);
|
|
649
|
+
this.write(heldToolResult(message.id, decision));
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const gatewayId = `gw-${this.nextGatewayId++}`;
|
|
654
|
+
this.inflight.set(gatewayId, { clientId: message.id, upstream: up, decision });
|
|
655
|
+
up.send({
|
|
656
|
+
jsonrpc: "2.0",
|
|
657
|
+
id: gatewayId,
|
|
658
|
+
method: "prompts/get",
|
|
659
|
+
params: { ...message.params, name: promptName },
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async #handleCompletion(message) {
|
|
664
|
+
const ref = message.params?.ref ?? {};
|
|
665
|
+
const target = typeof ref.name === "string" && ref.name
|
|
666
|
+
? ref.name
|
|
667
|
+
: typeof ref.uri === "string" ? ref.uri : "";
|
|
668
|
+
const { agent: callerAgent, delegation } = callerIdentity(message.params);
|
|
669
|
+
const { decision } = await this.guard.authorize({
|
|
670
|
+
tool: "completion.complete",
|
|
671
|
+
server: null,
|
|
672
|
+
args: { ref: target, argument: message.params?.argument ?? {} },
|
|
673
|
+
agent: callerAgent,
|
|
674
|
+
delegation,
|
|
675
|
+
});
|
|
676
|
+
this.stats = this.guard.stats;
|
|
677
|
+
|
|
678
|
+
if (decision.verdict === "deny") {
|
|
679
|
+
this.log(`DENY completion/complete ${target} (${decision.rule})`);
|
|
680
|
+
this.write(deniedToolResult(message.id, decision));
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (decision.verdict === "hold") {
|
|
684
|
+
decision.approvalId = `apr_${String(decision.decisionId).slice(4, 12)}`;
|
|
685
|
+
this.log(`HOLD completion/complete ${target} (${decision.rule})`);
|
|
686
|
+
this.write(heldToolResult(message.id, decision));
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
return this.#forwardToAny(message);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async #rejectUnknown(message) {
|
|
693
|
+
const method = message.method ?? "(missing)";
|
|
694
|
+
const { agent: callerAgent, delegation } = callerIdentity(message.params);
|
|
695
|
+
const { decision } = await this.guard.authorize({
|
|
696
|
+
tool: `mcp.${method}`,
|
|
697
|
+
server: null,
|
|
698
|
+
args: message.params ?? {},
|
|
699
|
+
agent: callerAgent,
|
|
700
|
+
delegation,
|
|
701
|
+
});
|
|
702
|
+
this.stats = this.guard.stats;
|
|
703
|
+
|
|
704
|
+
if (decision.verdict !== "permit") {
|
|
705
|
+
this.log(`DENY ${method} (${decision.rule ?? "default-deny"}) — unmodeled method, no explicit permit`);
|
|
706
|
+
this.write(deniedToolResult(message.id, decision));
|
|
707
|
+
return;
|
|
532
708
|
}
|
|
709
|
+
this.log(`PERMIT ${method} (${decision.rule}) — explicitly permitted unmodeled method`);
|
|
710
|
+
return this.#forwardToAny(message);
|
|
533
711
|
}
|
|
534
712
|
|
|
535
713
|
#handleInitialize(message) {
|
|
@@ -675,8 +853,11 @@ export class Gateway {
|
|
|
675
853
|
}
|
|
676
854
|
|
|
677
855
|
// Unsubscribing is always permitted: refusing to let an agent stop
|
|
678
|
-
// receiving something is not a security property.
|
|
856
|
+
// receiving something is not a security property. Reported on the
|
|
857
|
+
// protocol sink so the forward is visible without fabricating a policy
|
|
858
|
+
// decision that never happened.
|
|
679
859
|
if (message.method === "resources/unsubscribe") {
|
|
860
|
+
this.onDecision({ kind: "protocol", method: message.method, action: "forward" });
|
|
680
861
|
const gatewayId = `gw-${this.nextGatewayId++}`;
|
|
681
862
|
this.inflight.set(gatewayId, { clientId: message.id, upstream: up });
|
|
682
863
|
up.send({ jsonrpc: "2.0", id: gatewayId, method: message.method, params: { ...message.params, uri } });
|