@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cirvix_ai/agent-control",
3
- "version": "0.1.0",
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",
@@ -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 are forwarded to every upstream and never answered.
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
- for (const up of this.upstreams.values()) up.send(message);
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
- default:
529
- // Anything else is broadcast to the first live upstream. The gateway
530
- // deliberately does not invent behaviour for methods it doesn't model.
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 } });