@strixgov/mcp-proxy 0.1.8 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,58 @@ All notable changes to `@strixgov/mcp-proxy` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.1]
11
+
12
+ ### Fixed
13
+ - `npm test` in the published package crashed with a missing
14
+ `scripts/preflight.mjs` (the tarball did not ship `scripts/`). `test` now runs
15
+ `scripts/run-tests.mjs`, which runs the suite in a source checkout and, in an
16
+ installed copy, exits non-zero with an explanation rather than crashing or
17
+ passing on zero tests. Reported by an external tester.
18
+
19
+ ## [0.2.0]
20
+
21
+ Minor, not a patch: this release carries the already-merged shadow-discovery
22
+ feature (below) to npm for the first time, alongside a dependency-range fix.
23
+ Neither has shipped to npm before this release — `0.1.8` predates both.
24
+
25
+ ### Added
26
+ - **Shadow discovery (GSD-1 Phase 4)** — `src/shadow-discovery.mjs`,
27
+ on by default (opt out with `shadowDiscovery: false`). The proxy
28
+ already sits on the live tool-call path; this module records what the
29
+ upstream actually advertises and what the agent actually calls, and
30
+ reports the delta against the classified capability set — the action
31
+ surfaces static discovery (source scanners, Semgrep pack) structurally
32
+ cannot see. Strictly observation: it never changes a verdict and never
33
+ touches the policy / approval / receipt pipeline. Output is a
34
+ `snapshot()` on the `startProxy` handle plus an append-only JSONL log
35
+ at `<storagePath>/shadow-discovery.jsonl`; every snapshot and log line
36
+ carries the mandatory `measurement` disclaimer (**unsigned
37
+ measurement, never proof**) and nothing receipt-shaped. New exports:
38
+ `createShadowDiscovery`, `SHADOW_LOG_BASENAME`,
39
+ `SHADOW_MEASUREMENT_DISCLAIMER`; new handle fields `shadowDiscovery`,
40
+ `shadowDiscoveryLogPath`. Program doc:
41
+ `solo-builder-core/docs/proposals/governed-surface-discovery-v1.md`.
42
+
43
+ ### Fixed
44
+ - `dependencies["@strixgov/tool-gateway"]` was published (in `0.1.8`) as
45
+ `^0.4.1` — a hard, non-optional dependency, so a fresh `npm install
46
+ @strixgov/mcp-proxy` resolved the highest matching version, `0.4.1`,
47
+ never `0.5.0`. `0.5.0` fixed a real defect in `tool-gateway`'s
48
+ `terminalApprove()`: a non-interactive-session guard that could never
49
+ fire (`isTTY === false` — Node marks a real TTY `true` and leaves
50
+ everything else `undefined`, never `false`), so a headless approval
51
+ request fell through to writing a prompt banner onto `stdout`. For
52
+ this package specifically, `stdout` **is** the live MCP JSON-RPC
53
+ channel, so the stray banner corrupted the protocol stream a client
54
+ was mid-parse on. The in-repo dependency range was corrected to
55
+ `^0.5.0` when `tool-gateway@0.5.0` shipped, but this package was never
56
+ republished against it — every `npm install` of `0.1.8` (and, by the
57
+ same defect, of `0.1.5`–`0.1.7`) still pulled the buggy range. This
58
+ release is the republish; no other code changed for this fix.
59
+
8
60
  ## [0.1.8]
9
61
 
10
62
  Republish of 0.1.7 with correct dependency specifiers — code is
@@ -14,13 +14,7 @@
14
14
  * --policy-default DENY \
15
15
  * --risk-low ALLOW --risk-medium APPROVAL_REQUIRED
16
16
  *
17
- * The CLI is intentionally minimal — anything elaborate goes in the
18
- * config file. Operators wiring this into Claude Desktop, mcp-cli, or
19
- * an IDE integration just change their existing MCP command from
20
- * `<upstream>` to `strix-mcp-proxy --config <path>`.
21
- *
22
- * Audit events (proxy lifecycle + denied calls) print to stderr so the
23
- * MCP stdio channel on stdout stays clean for the protocol.
17
+ * Audit events print to stderr so the MCP stdio channel on stdout stays clean.
24
18
  */
25
19
 
26
20
  import { startProxy, loadConfig, resolveCapabilities } from "../src/index.mjs";
@@ -41,15 +35,22 @@ try {
41
35
  const handle = await startProxy({
42
36
  ...opts,
43
37
  onAudit: (event) => {
44
- // stderr: doesn't collide with the MCP stdio protocol on stdout.
45
38
  console.error(`[strix-proxy] ${event.kind} ${JSON.stringify(event.detail)}`);
46
39
  },
47
40
  onReceipt: (r) => {
48
- // One-line summary per receipt; full receipts persist via storagePath when set.
49
- console.error(`[strix-proxy] receipt ${r.decision} ${r.capabilityId}`);
41
+ // This is the signed pre-invocation authorization receipt.
42
+ console.error(`[strix-proxy] authorization ${r.decision} ${r.capabilityId} ${r.receiptId}`);
50
43
  },
51
44
  });
52
45
 
46
+ // Signed post-execution outcomes are separate artifacts. They are emitted
47
+ // only after an ALLOW path actually attempts the upstream call.
48
+ handle.gateway.on("outcome", (outcome) => {
49
+ console.error(
50
+ `[strix-proxy] outcome ${outcome.executionStatus} ${outcome.capabilityId} ${outcome.outcomeId}`,
51
+ );
52
+ });
53
+
53
54
  const shutdown = async (signal) => {
54
55
  console.error(`[strix-proxy] shutting down (${signal})`);
55
56
  try { await handle.stop(); } catch { /* best-effort */ }
@@ -62,8 +63,6 @@ try {
62
63
  process.exit(1);
63
64
  }
64
65
 
65
- // ─── arg parsing ────────────────────────────────────────────────────────────
66
-
67
66
  async function parseArgs(argv) {
68
67
  const flag = (name) => {
69
68
  const i = argv.indexOf(name);
@@ -83,6 +82,7 @@ async function parseArgs(argv) {
83
82
  return {
84
83
  serverId: cfg.serverId,
85
84
  upstream: cfg.upstream,
85
+ upstreamCredentials: cfg.upstreamCredentials,
86
86
  capabilities: await resolveCapabilities(cfg.capabilities),
87
87
  policy: cfg.policy,
88
88
  approval: cfg.approval,
@@ -101,8 +101,8 @@ async function parseArgs(argv) {
101
101
  const policyDefault = flag("--policy-default") ?? "DENY";
102
102
  const riskOverrides = {};
103
103
  for (const level of ["LOW", "MEDIUM", "HIGH", "CRITICAL"]) {
104
- const v = flag(`--risk-${level.toLowerCase()}`);
105
- if (v) riskOverrides[level] = v;
104
+ const value = flag(`--risk-${level.toLowerCase()}`);
105
+ if (value) riskOverrides[level] = value;
106
106
  }
107
107
  const approvalEnabled = argv.includes("--approval-enabled");
108
108
  const storagePath = flag("--storage-path");
@@ -143,15 +143,22 @@ FLAG MODE
143
143
  --risk-high <D> Override decision for HIGH-risk calls
144
144
  --risk-critical <D> Override decision for CRITICAL-risk calls
145
145
  --approval-enabled Enable the approval gate (requires custom prompt)
146
- --storage-path <dir> Persist receipts to JSONL at this directory
147
- (default: in-memory)
146
+ --storage-path <dir> Persist authorization receipts and execution outcomes
147
+ (default: ~/.strix-mcp-proxy/<serverId>)
148
+
149
+ TRUSTED WORKLOAD IDENTITY
150
+ STRIX_TRUSTED_ACTOR_ID Fixed authenticated workload identity
151
+ STRIX_TRUSTED_ACTOR_ROLE Optional role bound to authorization receipts
152
+ STRIX_REQUIRE_TRUSTED_IDENTITY Set true to reject client-only identity claims
153
+ STRIX_TENANT_ID Tenant bound into signed artifacts
154
+ STRIX_ENVIRONMENT Environment bound into signed artifacts
148
155
 
149
156
  GENERAL
150
157
  --help, -h Show this help
151
158
  --version, -v Show proxy version
152
159
 
153
- The proxy speaks MCP over stdio toward its consumer. Configure your
154
- MCP-aware client (Claude Desktop, mcp-cli, etc.) to spawn this binary
155
- in place of the upstream MCP server you previously used directly.
160
+ The proxy speaks MCP over stdio toward its consumer. NVIDIA OO Agents,
161
+ Claude Desktop, mcp-cli, and other .mcp.json clients can spawn this binary
162
+ in place of the upstream MCP server.
156
163
  `);
157
164
  }
@@ -0,0 +1,20 @@
1
+ {
2
+ "mcpServers": {
3
+ "strix_secure_coding": {
4
+ "command": "node",
5
+ "args": [
6
+ "./packages/strixgov-mcp-proxy/bin/strix-mcp-proxy.mjs",
7
+ "--config",
8
+ "./packages/strixgov-mcp-proxy/examples/nvidia-nooa/proxy-config.json"
9
+ ],
10
+ "env": {
11
+ "STRIX_TRUSTED_ACTOR_ID": "spiffe://openshell.local/nooa/remediator",
12
+ "STRIX_TRUSTED_ACTOR_ROLE": "agent",
13
+ "STRIX_REQUIRE_TRUSTED_IDENTITY": "true",
14
+ "STRIX_TENANT_ID": "nvidia-nooa-demo",
15
+ "STRIX_ENVIRONMENT": "openshell"
16
+ },
17
+ "transport": "stdio"
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,181 @@
1
+ # NVIDIA OO Agents + Strix governed MCP profile
2
+
3
+ This example connects NVIDIA OO Agents (NOOA) to an existing MCP server through
4
+ `@strixgov/mcp-proxy`.
5
+
6
+ NOOA keeps its native object model: `MCPManager.create_from_server()` discovers
7
+ the MCP tools and generates typed Python methods from their JSON Schemas. Strix
8
+ is inserted at the MCP side-effect boundary, not into the model prompt or NOOA
9
+ reasoning loop.
10
+
11
+ ## Architecture
12
+
13
+ ```text
14
+ NOOA Agent
15
+ |
16
+ | MCP stdio (.mcp.json)
17
+ v
18
+ Strix MCP Proxy
19
+ |-- trusted workload identity
20
+ |-- capability classification
21
+ |-- ALLOW / APPROVAL_REQUIRED / DENY
22
+ |-- signed authorization receipt before invocation
23
+ |-- signed execution outcome after an allowed invocation
24
+ v
25
+ Upstream MCP server
26
+ ```
27
+
28
+ The example policy permits repository inspection and patch proposal, requires
29
+ separate approval for production merge, and denies credential rotation.
30
+
31
+ ## Prerequisites
32
+
33
+ - Node.js 18+
34
+ - the Strix monorepo dependencies installed
35
+ - a checkout of NVIDIA `labs-OO-Agents` with its MCP extra installed
36
+ - Python and `uv` as required by NOOA
37
+
38
+ From the Strix monorepo root:
39
+
40
+ ```bash
41
+ pnpm install --frozen-lockfile
42
+ ```
43
+
44
+ From the NVIDIA OO Agents checkout:
45
+
46
+ ```bash
47
+ uv sync --extra mcp
48
+ ```
49
+
50
+ ## Run without OpenShell
51
+
52
+ The Python process must use the NOOA environment, while its working directory
53
+ must be the Strix monorepo root so the relative paths in `.mcp.json` and
54
+ `proxy-config.json` resolve.
55
+
56
+ Activate the NOOA environment first:
57
+
58
+ ```bash
59
+ # Linux or macOS, from the NVIDIA OO Agents checkout
60
+ source .venv/bin/activate
61
+
62
+ # Windows PowerShell
63
+ .\.venv\Scripts\Activate.ps1
64
+ ```
65
+
66
+ Then change to the Strix monorepo root and run:
67
+
68
+ ```bash
69
+ python packages/strixgov-mcp-proxy/examples/nvidia-nooa/nooa_agent.py
70
+ ```
71
+
72
+ The NOOA MCP configuration starts the Strix proxy, and the proxy starts the demo
73
+ upstream MCP server. Running `uv run` from the Strix repository without selecting
74
+ the NOOA project environment is not sufficient because Strix itself does not
75
+ declare the NOOA Python package.
76
+
77
+ ## Run in NVIDIA OpenShell
78
+
79
+ Create the sandbox first, then apply the complete policy and wait for the
80
+ sandbox to confirm the hot-reload:
81
+
82
+ ```bash
83
+ openshell sandbox create --name nooa-strix
84
+
85
+ openshell policy set nooa-strix \
86
+ --policy packages/strixgov-mcp-proxy/examples/nvidia-nooa/openshell-policy.yaml \
87
+ --wait
88
+
89
+ openshell sandbox connect nooa-strix
90
+ ```
91
+
92
+ The policy file includes the static filesystem/process fields because
93
+ `openshell policy set` replaces the entire policy. Its GitHub REST rule uses
94
+ `tls: terminate`, `enforcement: enforce`, and `access: read-only`, allowing the
95
+ listed Node proxy binaries to make read requests while rejecting mutating HTTP
96
+ methods. All other outbound traffic remains denied unless another policy or
97
+ configured inference provider permits it.
98
+
99
+ Adapt binary paths, endpoints, and inference-provider configuration to the
100
+ actual image before treating the template as production configuration.
101
+
102
+ ## Trusted identity
103
+
104
+ `.mcp.json` supplies a fixed workload identity to the proxy process:
105
+
106
+ ```text
107
+ STRIX_TRUSTED_ACTOR_ID=spiffe://openshell.local/nooa/remediator
108
+ STRIX_REQUIRE_TRUSTED_IDENTITY=true
109
+ ```
110
+
111
+ This identity overrides any `strix_actor_id` value supplied by the MCP client.
112
+ Client metadata remains a claim, not an authentication boundary. If required
113
+ identity cannot be resolved, Strix emits a signed DENY authorization receipt and
114
+ does not call the upstream handler.
115
+
116
+ For production, replace the fixed identity with an authenticated resolver tied
117
+ to the workload transport or OpenShell control plane.
118
+
119
+ ## Evidence
120
+
121
+ The proxy writes two append-only files under `.strix-nooa/evidence/`:
122
+
123
+ ```text
124
+ receipts.jsonl signed pre-invocation authorization receipts
125
+ execution-outcomes.jsonl signed post-invocation outcome records
126
+ ```
127
+
128
+ A denied or unapproved action has no execution-outcome record because the
129
+ upstream handler was not invoked. An allowed action has a linked `SUCCEEDED` or
130
+ `FAILED` outcome.
131
+
132
+ The public key is stored under `.strix-nooa/keys/public-jwk.json`. Verify both
133
+ the authorization chain and all linked execution outcomes offline from the
134
+ Strix monorepo root:
135
+
136
+ ```bash
137
+ node packages/strixgov-mcp-proxy/examples/nvidia-nooa/verify-proof.mjs
138
+ ```
139
+
140
+ The command exits `0` only when the authorization chain and every published
141
+ execution outcome return `VERIFIED`. It prints machine-readable JSON with the
142
+ schema, hash, key-resolution, signature, consistency, and authorization-link
143
+ results for each outcome.
144
+
145
+ The independent outcome verifier is also exported for other tools:
146
+
147
+ ```javascript
148
+ import { verifyExecutionOutcomeRecord } from "@strixgov/verifier/execution-outcome";
149
+ ```
150
+
151
+ Verification checks the published schema, rejects unsigned extension fields,
152
+ and reports the outcome hash, key resolution, Ed25519 signature,
153
+ execution-state consistency, and authorization-receipt link separately.
154
+
155
+ ## Approval behavior
156
+
157
+ `merge_production` uses the file approver. The proxy writes an approval request
158
+ under `.strix-nooa/approvals/` and waits up to five minutes. Without an approved
159
+ response, execution fails closed and the upstream merge handler is not invoked.
160
+
161
+ `rotate_credentials` is denied directly by policy.
162
+
163
+ ## Truthful claim boundary
164
+
165
+ This example demonstrates that calls flowing through the configured Strix proxy
166
+ are evaluated before invocation and produce portable signed authorization and
167
+ outcome evidence.
168
+
169
+ It does not prove that every possible route to a real downstream service is
170
+ blocked. The stronger anti-bypass claim requires one of these deployment
171
+ conditions:
172
+
173
+ - OpenShell or network policy prevents the agent from reaching the downstream
174
+ directly;
175
+ - the proxy is the sole credential holder; or
176
+ - the downstream validates a Strix `execution_authorization_v1` token before
177
+ performing the side effect.
178
+
179
+ The demo upstream is intentionally non-consequential. Replace it with a real MCP
180
+ server only after capability classification, approval handling, credential
181
+ custody, and anti-bypass controls have been reviewed for that deployment.
@@ -0,0 +1,89 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from "@modelcontextprotocol/sdk/types.js";
7
+
8
+ const server = new Server(
9
+ { name: "strix-nooa-secure-coding-demo", version: "0.1.0" },
10
+ { capabilities: { tools: {} } },
11
+ );
12
+
13
+ const tools = [
14
+ {
15
+ name: "inspect_repository",
16
+ description: "Inspect repository metadata without modifying the repository.",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: {
20
+ repository: { type: "string" },
21
+ ref: { type: "string", default: "HEAD" },
22
+ },
23
+ required: ["repository"],
24
+ },
25
+ },
26
+ {
27
+ name: "propose_patch",
28
+ description: "Create a patch proposal on a remediation branch without merging it.",
29
+ inputSchema: {
30
+ type: "object",
31
+ properties: {
32
+ repository: { type: "string" },
33
+ finding: { type: "string" },
34
+ },
35
+ required: ["repository", "finding"],
36
+ },
37
+ },
38
+ {
39
+ name: "merge_production",
40
+ description: "Merge an approved commit to the production branch.",
41
+ inputSchema: {
42
+ type: "object",
43
+ properties: {
44
+ repository: { type: "string" },
45
+ commit: { type: "string" },
46
+ },
47
+ required: ["repository", "commit"],
48
+ },
49
+ },
50
+ {
51
+ name: "rotate_credentials",
52
+ description: "Rotate a production credential.",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ credential: { type: "string" },
57
+ },
58
+ required: ["credential"],
59
+ },
60
+ },
61
+ ];
62
+
63
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
64
+
65
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
66
+ const { name, arguments: args = {} } = request.params;
67
+ switch (name) {
68
+ case "inspect_repository":
69
+ return {
70
+ content: [{ type: "text", text: JSON.stringify({ repository: args.repository, ref: args.ref ?? "HEAD", findings: 1 }) }],
71
+ };
72
+ case "propose_patch":
73
+ return {
74
+ content: [{ type: "text", text: JSON.stringify({ branch: "remediation/nooa-demo", patchProposed: true }) }],
75
+ };
76
+ case "merge_production":
77
+ return {
78
+ content: [{ type: "text", text: JSON.stringify({ merged: true, commit: args.commit }) }],
79
+ };
80
+ case "rotate_credentials":
81
+ return {
82
+ content: [{ type: "text", text: JSON.stringify({ rotated: true, credential: args.credential }) }],
83
+ };
84
+ default:
85
+ throw new Error(`Unknown tool '${name}'`);
86
+ }
87
+ });
88
+
89
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,45 @@
1
+ from pathlib import Path
2
+
3
+ try:
4
+ from nooa.mcp import MCPManager
5
+ except ImportError as exc:
6
+ raise ImportError(
7
+ "NVIDIA OO Agents MCP support is required. In the NOOA repository run: uv sync --extra mcp"
8
+ ) from exc
9
+
10
+ from nooa.util.quickstart import Agent, autorun, llm
11
+
12
+
13
+ MCP_CONFIG = Path(__file__).with_name(".mcp.json")
14
+
15
+
16
+ class GovernedRemediationAgent(Agent, llm=llm):
17
+ """Inspect a repository and propose remediation without bypassing Strix governance.
18
+
19
+ Use the Strix-governed MCP tools. Repository inspection and patch proposal are
20
+ permitted by the demo policy. Production merge requires a separate approval.
21
+ Credential rotation is prohibited.
22
+ """
23
+
24
+ secure_coding = MCPManager.create_from_server(
25
+ "strix_secure_coding",
26
+ mcp_file=MCP_CONFIG,
27
+ )
28
+
29
+ async def remediate(self, repository: str, finding: str) -> str:
30
+ """Inspect the repository, propose a patch for the finding, and report the result.
31
+
32
+ Do not attempt production merge or credential rotation unless the user
33
+ explicitly requests it; Strix will independently evaluate every tool call.
34
+ """
35
+ ...
36
+
37
+
38
+ @autorun
39
+ async def main() -> None:
40
+ agent = GovernedRemediationAgent()
41
+ result = await agent.remediate(
42
+ "demo/repo",
43
+ "Dependency input is accepted without validation",
44
+ )
45
+ print(result)
@@ -0,0 +1,43 @@
1
+ version: 1
2
+
3
+ # Static controls. Paths not listed are inaccessible.
4
+ filesystem_policy:
5
+ include_workdir: true
6
+ read_only:
7
+ - /usr
8
+ - /lib
9
+ - /proc
10
+ - /dev/urandom
11
+ - /etc
12
+ - /app
13
+ - /var/log
14
+ read_write:
15
+ - /sandbox
16
+ - /tmp
17
+ - /dev/null
18
+
19
+ landlock:
20
+ compatibility: best_effort
21
+
22
+ process:
23
+ run_as_user: sandbox
24
+ run_as_group: sandbox
25
+
26
+ # Dynamic network controls. The Python NOOA process receives no ordinary
27
+ # outbound network permission here. A production deployment should route model
28
+ # inference through OpenShell's configured inference provider. Only the Node
29
+ # proxy process receives read-only GitHub API access in this example; mutating
30
+ # methods remain blocked at OpenShell even if application policy is bypassed.
31
+ network_policies:
32
+ strix_proxy_github_readonly:
33
+ name: strix-proxy-github-readonly
34
+ endpoints:
35
+ - host: api.github.com
36
+ port: 443
37
+ protocol: rest
38
+ tls: terminate
39
+ enforcement: enforce
40
+ access: read-only
41
+ binaries:
42
+ - path: /usr/bin/node
43
+ - path: /usr/local/bin/node
@@ -0,0 +1,56 @@
1
+ {
2
+ "serverId": "nooa-secure-coding",
3
+ "upstream": {
4
+ "command": "node",
5
+ "args": [
6
+ "./packages/strixgov-mcp-proxy/examples/nvidia-nooa/demo-upstream.mjs"
7
+ ]
8
+ },
9
+ "capabilities": [
10
+ {
11
+ "id": "mcp.nooa-secure-coding.inspect_repository",
12
+ "name": "inspect_repository",
13
+ "risk": "LOW",
14
+ "mode": "READ",
15
+ "description": "Inspect repository metadata without modification."
16
+ },
17
+ {
18
+ "id": "mcp.nooa-secure-coding.propose_patch",
19
+ "name": "propose_patch",
20
+ "risk": "HIGH",
21
+ "mode": "WRITE",
22
+ "description": "Create a patch proposal without merging it."
23
+ },
24
+ {
25
+ "id": "mcp.nooa-secure-coding.merge_production",
26
+ "name": "merge_production",
27
+ "risk": "CRITICAL",
28
+ "mode": "WRITE",
29
+ "description": "Merge an approved commit to production."
30
+ },
31
+ {
32
+ "id": "mcp.nooa-secure-coding.rotate_credentials",
33
+ "name": "rotate_credentials",
34
+ "risk": "CRITICAL",
35
+ "mode": "WRITE",
36
+ "description": "Rotate a production credential."
37
+ }
38
+ ],
39
+ "policy": {
40
+ "rules": {
41
+ "mcp.nooa-secure-coding.inspect_repository": "ALLOW",
42
+ "mcp.nooa-secure-coding.propose_patch": "ALLOW",
43
+ "mcp.nooa-secure-coding.merge_production": "APPROVAL_REQUIRED",
44
+ "mcp.nooa-secure-coding.rotate_credentials": "DENY"
45
+ },
46
+ "default": "DENY"
47
+ },
48
+ "approval": {
49
+ "enabled": true,
50
+ "type": "file",
51
+ "requestDir": "./.strix-nooa/approvals",
52
+ "timeoutMs": 300000
53
+ },
54
+ "storagePath": "./.strix-nooa/evidence",
55
+ "keyPath": "./.strix-nooa/keys"
56
+ }
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import { verifyReceiptChain } from "@strixgov/verifier";
7
+ import { verifyExecutionOutcomeRecord } from "@strixgov/verifier/execution-outcome";
8
+
9
+ const root = process.argv[2] ?? ".strix-nooa";
10
+ const evidenceDir = path.join(root, "evidence");
11
+ const keyDir = path.join(root, "keys");
12
+
13
+ const receipts = await readJsonl(path.join(evidenceDir, "receipts.jsonl"));
14
+ const outcomes = await readJsonl(path.join(evidenceDir, "execution-outcomes.jsonl"));
15
+ const publicJwk = JSON.parse(
16
+ await fs.readFile(path.join(keyDir, "public-jwk.json"), "utf8"),
17
+ );
18
+ const jwks = "keys" in publicJwk ? publicJwk : { keys: [publicJwk] };
19
+
20
+ const authorization = await verifyReceiptChain(receipts, { jwks });
21
+ const byId = new Map(receipts.map((receipt) => [receipt.receiptId, receipt]));
22
+ const execution = outcomes.map((outcome) => {
23
+ const authorizationReceipt = byId.get(outcome.authorizationReceiptId) ?? null;
24
+ return verifyExecutionOutcomeRecord(outcome, {
25
+ jwks,
26
+ authorizationReceipt,
27
+ });
28
+ });
29
+
30
+ const verdict =
31
+ authorization.chainValid === true &&
32
+ authorization.receipts.every(
33
+ (receipt) => receipt.verificationStatus === "VERIFIED",
34
+ ) &&
35
+ execution.every((outcome) => outcome.verificationStatus === "VERIFIED")
36
+ ? "VERIFIED"
37
+ : "FAILED";
38
+
39
+ const report = {
40
+ verdict,
41
+ authorization: {
42
+ chainValid: authorization.chainValid,
43
+ count: authorization.count,
44
+ receipts: authorization.receipts.map((receipt) => ({
45
+ receiptId: receipt.receiptId,
46
+ verificationStatus: receipt.verificationStatus,
47
+ })),
48
+ },
49
+ execution: execution.map((outcome) => ({
50
+ outcomeId: outcome.outcomeId,
51
+ schemaValid: outcome.schemaValid,
52
+ hashValid: outcome.hashValid,
53
+ keyResolved: outcome.keyResolved,
54
+ signatureValid: outcome.signatureValid,
55
+ consistencyValid: outcome.consistencyValid,
56
+ linkValid: outcome.linkValid,
57
+ verificationStatus: outcome.verificationStatus,
58
+ error: outcome.error,
59
+ })),
60
+ };
61
+
62
+ console.log(JSON.stringify(report, null, 2));
63
+ process.exit(verdict === "VERIFIED" ? 0 : 1);
64
+
65
+ async function readJsonl(file) {
66
+ const text = await fs.readFile(file, "utf8");
67
+ return text
68
+ .split("\n")
69
+ .filter(Boolean)
70
+ .map((line, index) => {
71
+ try {
72
+ return JSON.parse(line);
73
+ } catch (err) {
74
+ throw new Error(
75
+ `${file}:${index + 1}: ${err instanceof Error ? err.message : String(err)}`,
76
+ );
77
+ }
78
+ });
79
+ }
@@ -0,0 +1,58 @@
1
+ # Govern YouSpot with the Strix MCP proxy
2
+
3
+ This example uses YouSpot's official stdio bridge as the upstream server:
4
+
5
+ `MCP host -> @strixgov/mcp-proxy -> @youspot/mcp -> https://youspot.com/mcp/v1`
6
+
7
+ No YouSpot tool implementation is changed.
8
+
9
+ ## 1. Provide the YouSpot token to the host environment
10
+
11
+ ```bash
12
+ export YOUSPOT_TOKEN="..."
13
+ ```
14
+
15
+ `@youspot/mcp` requires the token for `tools/call`. The example config intentionally does not store it; the proxy passes the process environment through to the upstream when `upstream.env` is omitted.
16
+
17
+ ## 2. Point the MCP host at Strix
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "youspot-governed": {
23
+ "command": "npx",
24
+ "args": [
25
+ "-y",
26
+ "@strixgov/mcp-proxy",
27
+ "--config",
28
+ "/absolute/path/to/youspot-proxy-config.json"
29
+ ]
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ The same shape works in stdio-capable hosts that accept command/args MCP definitions. Use the host's normal secure environment mechanism for `YOUSPOT_TOKEN`.
36
+
37
+ ## 3. What the starter policy does
38
+
39
+ - reviewed read tools -> explicit ALLOW rules
40
+ - every other LOW/MEDIUM/HIGH tool -> APPROVAL_REQUIRED
41
+ - CRITICAL -> DENY
42
+ - policy default -> DENY
43
+
44
+ The explicit rules are intentional. `@strixgov/mcp-proxy` documents name heuristics as a discovery fallback, not a production security boundary. A YouSpot tool added after this example was reviewed therefore does not inherit an automatic LOW/ALLOW path.
45
+ - bundled approval channel -> disabled
46
+
47
+ With approval disabled, any call that evaluates to `APPROVAL_REQUIRED` is refused rather than auto-approved. Configure a separate approver only when the response channel is not writable by the acting agent.
48
+
49
+ ## 4. Verify
50
+
51
+ ```bash
52
+ npx @strixgov/verifier chain ~/.strix-mcp-proxy/youspot/receipts.jsonl \
53
+ --jwks ~/.strix-mcp-proxy/youspot/keys/public-jwk.json
54
+ ```
55
+
56
+ ## Coverage boundary
57
+
58
+ Only traffic routed through this local proxy is governed. Direct `https://youspot.com/mcp/v1` connections remain outside this example. Remote-HTTP-only clients require a hosted Strix gateway.
@@ -0,0 +1,236 @@
1
+ {
2
+ "serverId": "youspot",
3
+ "upstream": {
4
+ "command": "npx",
5
+ "args": [
6
+ "-y",
7
+ "@youspot/mcp"
8
+ ]
9
+ },
10
+ "capabilities": [
11
+ {
12
+ "id": "mcp.youspot.get_graph_object",
13
+ "name": "get_graph_object",
14
+ "risk": "LOW",
15
+ "mode": "READ",
16
+ "description": "Read one object from the YouSpot graph."
17
+ },
18
+ {
19
+ "id": "mcp.youspot.list_graph_objects",
20
+ "name": "list_graph_objects",
21
+ "risk": "LOW",
22
+ "mode": "READ",
23
+ "description": "List objects from the YouSpot graph."
24
+ },
25
+ {
26
+ "id": "mcp.youspot.search_graph_objects",
27
+ "name": "search_graph_objects",
28
+ "risk": "LOW",
29
+ "mode": "READ",
30
+ "description": "Search the YouSpot graph."
31
+ },
32
+ {
33
+ "id": "mcp.youspot.get_gmail_message",
34
+ "name": "get_gmail_message",
35
+ "risk": "LOW",
36
+ "mode": "READ",
37
+ "description": "Read one Gmail message."
38
+ },
39
+ {
40
+ "id": "mcp.youspot.search_gmail_messages",
41
+ "name": "search_gmail_messages",
42
+ "risk": "LOW",
43
+ "mode": "READ",
44
+ "description": "Search connected Gmail mailboxes."
45
+ },
46
+ {
47
+ "id": "mcp.youspot.list_gmail_labels",
48
+ "name": "list_gmail_labels",
49
+ "risk": "LOW",
50
+ "mode": "READ",
51
+ "description": "List Gmail labels."
52
+ },
53
+ {
54
+ "id": "mcp.youspot.get_calendar_events",
55
+ "name": "get_calendar_events",
56
+ "risk": "LOW",
57
+ "mode": "READ",
58
+ "description": "Read calendar events."
59
+ },
60
+ {
61
+ "id": "mcp.youspot.list_google_calendars",
62
+ "name": "list_google_calendars",
63
+ "risk": "LOW",
64
+ "mode": "READ",
65
+ "description": "List Google calendars."
66
+ },
67
+ {
68
+ "id": "mcp.youspot.list_slack_channels",
69
+ "name": "list_slack_channels",
70
+ "risk": "LOW",
71
+ "mode": "READ",
72
+ "description": "List Slack channels known to YouSpot."
73
+ },
74
+ {
75
+ "id": "mcp.youspot.get_slack_thread",
76
+ "name": "get_slack_thread",
77
+ "risk": "LOW",
78
+ "mode": "READ",
79
+ "description": "Read a saved Slack thread."
80
+ },
81
+ {
82
+ "id": "mcp.youspot.search_slack_messages",
83
+ "name": "search_slack_messages",
84
+ "risk": "LOW",
85
+ "mode": "READ",
86
+ "description": "Search saved Slack messages."
87
+ },
88
+ {
89
+ "id": "mcp.youspot.list_integrations",
90
+ "name": "list_integrations",
91
+ "risk": "LOW",
92
+ "mode": "READ",
93
+ "description": "List connected integrations."
94
+ },
95
+ {
96
+ "id": "mcp.youspot.add_slack_reaction",
97
+ "name": "add_slack_reaction",
98
+ "risk": "MEDIUM",
99
+ "mode": "WRITE",
100
+ "description": "Add a reaction to a Slack message."
101
+ },
102
+ {
103
+ "id": "mcp.youspot.archive_gmail_message",
104
+ "name": "archive_gmail_message",
105
+ "risk": "MEDIUM",
106
+ "mode": "WRITE",
107
+ "description": "Archive a Gmail message."
108
+ },
109
+ {
110
+ "id": "mcp.youspot.connect_graph_objects",
111
+ "name": "connect_graph_objects",
112
+ "risk": "MEDIUM",
113
+ "mode": "WRITE",
114
+ "description": "Create a directed edge between graph objects."
115
+ },
116
+ {
117
+ "id": "mcp.youspot.create_gmail_draft",
118
+ "name": "create_gmail_draft",
119
+ "risk": "MEDIUM",
120
+ "mode": "WRITE",
121
+ "description": "Create a Gmail draft."
122
+ },
123
+ {
124
+ "id": "mcp.youspot.create_graph_object",
125
+ "name": "create_graph_object",
126
+ "risk": "MEDIUM",
127
+ "mode": "WRITE",
128
+ "description": "Create a YouSpot graph object."
129
+ },
130
+ {
131
+ "id": "mcp.youspot.disconnect_graph_objects",
132
+ "name": "disconnect_graph_objects",
133
+ "risk": "MEDIUM",
134
+ "mode": "WRITE",
135
+ "description": "Remove a graph-object edge."
136
+ },
137
+ {
138
+ "id": "mcp.youspot.set_follow_up",
139
+ "name": "set_follow_up",
140
+ "risk": "MEDIUM",
141
+ "mode": "WRITE",
142
+ "description": "Create a dated follow-up fact."
143
+ },
144
+ {
145
+ "id": "mcp.youspot.set_notification_emails",
146
+ "name": "set_notification_emails",
147
+ "risk": "MEDIUM",
148
+ "mode": "WRITE",
149
+ "description": "Change YouSpot notification-email settings."
150
+ },
151
+ {
152
+ "id": "mcp.youspot.update_graph_object",
153
+ "name": "update_graph_object",
154
+ "risk": "MEDIUM",
155
+ "mode": "WRITE",
156
+ "description": "Update fields on a graph object."
157
+ },
158
+ {
159
+ "id": "mcp.youspot.create_calendar_event",
160
+ "name": "create_calendar_event",
161
+ "risk": "HIGH",
162
+ "mode": "WRITE",
163
+ "description": "Create or queue a calendar event."
164
+ },
165
+ {
166
+ "id": "mcp.youspot.send_email",
167
+ "name": "send_email",
168
+ "risk": "HIGH",
169
+ "mode": "WRITE",
170
+ "description": "Send email to the signed-in user's own address."
171
+ },
172
+ {
173
+ "id": "mcp.youspot.send_slack_message",
174
+ "name": "send_slack_message",
175
+ "risk": "HIGH",
176
+ "mode": "WRITE",
177
+ "description": "Post a Slack message or DM."
178
+ },
179
+ {
180
+ "id": "mcp.youspot.send_text_to_user",
181
+ "name": "send_text_to_user",
182
+ "risk": "HIGH",
183
+ "mode": "WRITE",
184
+ "description": "Send an iMessage/SMS to the user's verified phone."
185
+ },
186
+ {
187
+ "id": "mcp.youspot.delete_graph_object",
188
+ "name": "delete_graph_object",
189
+ "risk": "CRITICAL",
190
+ "mode": "WRITE",
191
+ "description": "Delete one graph object."
192
+ },
193
+ {
194
+ "id": "mcp.youspot.delete_graph_objects",
195
+ "name": "delete_graph_objects",
196
+ "risk": "CRITICAL",
197
+ "mode": "WRITE",
198
+ "description": "Delete all graph objects of one type."
199
+ },
200
+ {
201
+ "id": "mcp.youspot.disconnect_integration",
202
+ "name": "disconnect_integration",
203
+ "risk": "CRITICAL",
204
+ "mode": "WRITE",
205
+ "description": "Disconnect an integration; may delete synced records."
206
+ }
207
+ ],
208
+ "policy": {
209
+ "rules": {
210
+ "mcp.youspot.get_graph_object": "ALLOW",
211
+ "mcp.youspot.list_graph_objects": "ALLOW",
212
+ "mcp.youspot.search_graph_objects": "ALLOW",
213
+ "mcp.youspot.get_gmail_message": "ALLOW",
214
+ "mcp.youspot.search_gmail_messages": "ALLOW",
215
+ "mcp.youspot.list_gmail_labels": "ALLOW",
216
+ "mcp.youspot.get_calendar_events": "ALLOW",
217
+ "mcp.youspot.list_google_calendars": "ALLOW",
218
+ "mcp.youspot.list_slack_channels": "ALLOW",
219
+ "mcp.youspot.get_slack_thread": "ALLOW",
220
+ "mcp.youspot.search_slack_messages": "ALLOW",
221
+ "mcp.youspot.list_integrations": "ALLOW"
222
+ },
223
+ "riskOverrides": {
224
+ "LOW": "APPROVAL_REQUIRED",
225
+ "MEDIUM": "APPROVAL_REQUIRED",
226
+ "HIGH": "APPROVAL_REQUIRED",
227
+ "CRITICAL": "DENY"
228
+ },
229
+ "default": "DENY"
230
+ },
231
+ "approval": {
232
+ "enabled": false
233
+ },
234
+ "storagePath": "~/.strix-mcp-proxy/youspot",
235
+ "keyPath": "~/.strix-mcp-proxy/youspot/keys"
236
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strixgov/mcp-proxy",
3
- "version": "0.1.8",
3
+ "version": "0.2.1",
4
4
  "description": "Strix MCP Proxy — the distribution wedge for governed agent tool execution. Point your MCP client at the proxy instead of the underlying server; every callTool is intercepted, evaluated against policy, and emits a signed Ed25519 receipt. Designed so Mode 3 (capability-enforced) is the v0.2.0 upgrade path, not a rewrite.",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -18,7 +18,10 @@
18
18
  "@modelcontextprotocol/sdk": "^1.0.0",
19
19
  "@strixgov/capabilities-mcp-common": "^0.1.0",
20
20
  "@strixgov/mcp-adapter": "^0.1.0",
21
- "@strixgov/tool-gateway": "^0.4.1"
21
+ "@strixgov/tool-gateway": "^0.5.0"
22
+ },
23
+ "devDependencies": {
24
+ "zod": "^3.25.76"
22
25
  },
23
26
  "keywords": [
24
27
  "strix",
@@ -40,6 +43,7 @@
40
43
  "files": [
41
44
  "src/",
42
45
  "bin/",
46
+ "scripts/run-tests.mjs",
43
47
  "examples/",
44
48
  "README.md",
45
49
  "CHANGELOG.md",
@@ -54,6 +58,6 @@
54
58
  "node": ">=18.0.0"
55
59
  },
56
60
  "scripts": {
57
- "test": "node --test test/*.test.mjs"
61
+ "test": "node scripts/run-tests.mjs"
58
62
  }
59
63
  }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npm test` entry point for @strixgov/mcp-proxy.
4
+ *
5
+ * The suite needs a source checkout (devDependencies and pnpm workspace links),
6
+ * so the npm tarball does not ship test/ or scripts/preflight.mjs. Previously
7
+ * `test` called preflight.mjs directly, so `npm test` in an installed copy died
8
+ * with "Cannot find module scripts/preflight.mjs". This wrapper fails closed
9
+ * with an explanation instead: it never exits 0 having run zero tests.
10
+ */
11
+ import { existsSync } from "node:fs";
12
+ import { spawnSync } from "node:child_process";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const root = fileURLToPath(new URL("..", import.meta.url));
16
+ const testDir = fileURLToPath(new URL("../test", import.meta.url));
17
+ const preflight = fileURLToPath(new URL("./preflight.mjs", import.meta.url));
18
+
19
+ if (!existsSync(testDir) || !existsSync(preflight)) {
20
+ process.stderr.write(
21
+ [
22
+ "",
23
+ " @strixgov/mcp-proxy: no tests to run here.",
24
+ "",
25
+ " The test suite is not included in the published npm package.",
26
+ " Run it from a source checkout of https://github.com/Strixgov/strix:",
27
+ "",
28
+ " pnpm install",
29
+ " pnpm --dir packages/strixgov-mcp-proxy test",
30
+ "",
31
+ " Exiting non-zero on purpose: zero tests run must not read as a pass.",
32
+ "",
33
+ ].join("\n"),
34
+ );
35
+ process.exit(1);
36
+ }
37
+
38
+ for (const args of [[preflight], ["--test"]]) {
39
+ const r = spawnSync(process.execPath, args, { cwd: root, stdio: "inherit" });
40
+ if (r.status !== 0) process.exit(r.status ?? 1);
41
+ }
package/src/index.mjs CHANGED
@@ -17,3 +17,8 @@
17
17
 
18
18
  export { startProxy } from "./proxy.mjs";
19
19
  export { loadConfig, resolveCapabilities } from "./config.mjs";
20
+ export {
21
+ createShadowDiscovery,
22
+ SHADOW_LOG_BASENAME,
23
+ SHADOW_MEASUREMENT_DISCLAIMER,
24
+ } from "./shadow-discovery.mjs";
package/src/proxy.mjs CHANGED
@@ -34,6 +34,7 @@ import os from "node:os";
34
34
  import { governMCPServer } from "@strixgov/mcp-adapter";
35
35
  import { loadOrCreateSigningKey, fileApprover } from "@strixgov/tool-gateway";
36
36
  import { webhookApprover } from "./webhook-approver.mjs";
37
+ import { createShadowDiscovery } from "./shadow-discovery.mjs";
37
38
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
38
39
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
39
40
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -59,9 +60,16 @@ import {
59
60
  * connectedMode?: object,
60
61
  * onReceipt?: (receipt: object) => void,
61
62
  * onAudit?: (event: { kind: string, detail: object }) => void,
63
+ * shadowDiscovery?: boolean,
62
64
  * transport?: { kind: "stdio" } | { kind: "test", server: object, client: object },
63
65
  * }} opts
64
- * @returns {Promise<{ stop: () => Promise<void>, gateway: object, signingKey: object }>}
66
+ * @returns {Promise<{
67
+ * stop: () => Promise<void>,
68
+ * gateway: object,
69
+ * signingKey: object,
70
+ * shadowDiscovery: object | null,
71
+ * shadowDiscoveryLogPath: string | null,
72
+ * }>}
65
73
  */
66
74
  export async function startProxy(opts) {
67
75
  if (!opts) {
@@ -254,6 +262,37 @@ export async function startProxy(opts) {
254
262
  governed.gateway.on("receipt", opts.onReceipt);
255
263
  }
256
264
 
265
+ // ─── 3d. Shadow discovery (GSD-1 Phase 4) — observation only ───────────────
266
+ // Records what the upstream actually advertises and what actually gets
267
+ // called, and reports the delta against the classified capability set —
268
+ // the actions runtime traffic sees that static discovery missed.
269
+ // Strictly read-only over events the proxy already handles: it never
270
+ // changes a verdict and never touches the policy/approval/receipt
271
+ // pipeline. Opt out with `shadowDiscovery: false`. The JSONL shadow log
272
+ // shares the receipt store's lifecycle (only written when a storagePath
273
+ // is in effect) and is an unsigned measurement — never proof.
274
+ const shadowDiscovery =
275
+ opts.shadowDiscovery === false
276
+ ? null
277
+ : createShadowDiscovery({
278
+ serverId,
279
+ capabilities: opts.capabilities,
280
+ storagePath: resolvedStoragePath,
281
+ onAudit: (event) => audit(opts, event),
282
+ });
283
+
284
+ // Seed the advertised-tool universe once at startup so a proxy that
285
+ // never receives a tools/list from its consumer still reports the
286
+ // upstream's surface. Best-effort: a slow/broken upstream listTools
287
+ // must not fail proxy startup — the same call will be retried on the
288
+ // consumer's first tools/list anyway.
289
+ if (shadowDiscovery) {
290
+ try {
291
+ const seed = await upstreamClient.listTools();
292
+ shadowDiscovery.recordToolList(seed.tools ?? []);
293
+ } catch { /* best-effort seed; consumer-driven listTools re-records */ }
294
+ }
295
+
257
296
  // ─── 4. Expose the proxy as an MCP server toward the consumer ──────────────
258
297
  const proxyServer = new Server(
259
298
  { name: `strix-proxy:${serverId}`, version: "0.1.0" },
@@ -267,6 +306,7 @@ export async function startProxy(opts) {
267
306
  // Re-fetch the full descriptors from the upstream so callers see
268
307
  // the original argument shapes — governance doesn't reshape tools.
269
308
  const upstream = await upstreamClient.listTools();
309
+ shadowDiscovery?.recordToolList(upstream.tools ?? []);
270
310
  const upstreamByName = new Map(upstream.tools.map((t) => [t.name, t]));
271
311
  return {
272
312
  tools: tools.map((t) => upstreamByName.get(t.name) ?? { name: t.name }),
@@ -275,6 +315,9 @@ export async function startProxy(opts) {
275
315
 
276
316
  proxyServer.setRequestHandler(CallToolRequestSchema, async (request) => {
277
317
  const { name, arguments: args } = request.params;
318
+ // Shadow discovery counts the call BEFORE governance evaluates —
319
+ // a denied call is still an observed action surface.
320
+ shadowDiscovery?.recordCall(name);
278
321
  // Forward actor context if the client put it in _meta — this is how
279
322
  // mcp-aware clients attribute calls to a specific agent identity.
280
323
  const ctx = {
@@ -316,6 +359,8 @@ export async function startProxy(opts) {
316
359
  return {
317
360
  gateway: governed.gateway,
318
361
  signingKey: governed.signingKey,
362
+ shadowDiscovery,
363
+ shadowDiscoveryLogPath: shadowDiscovery?.logPath ?? null,
319
364
  async stop() {
320
365
  try { await proxyServer.close(); } catch { /* best-effort */ }
321
366
  try { await upstreamClient.close(); } catch { /* best-effort */ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Shadow discovery — runtime reconciliation for static governed-surface
3
+ * discovery (GSD-1 Phase 4; program doc:
4
+ * solo-builder-core/docs/proposals/governed-surface-discovery-v1.md).
5
+ *
6
+ * Static scanners (the /strix-wire scanner, `solo govern scan`, the
7
+ * Semgrep pack) can only find action points that pattern-match in
8
+ * source. They structurally cannot see (a) tools an upstream MCP server
9
+ * exposes that no companion capability pack has classified, or (b) the
10
+ * genuinely runtime-decided agent tool call. The proxy already sits on
11
+ * the live tool-call path, so it is the natural observation point: this
12
+ * module records what the upstream ACTUALLY advertises and what the
13
+ * agent ACTUALLY calls, and reports the delta against the classified
14
+ * capability registry.
15
+ *
16
+ * Two honesty rules, both load-bearing:
17
+ *
18
+ * 1. **Observation only, never enforcement.** Shadow discovery reads
19
+ * the same events the proxy already handles; it never changes a
20
+ * verdict, never blocks a call, never touches the policy /
21
+ * approval / receipt pipeline. Every enforcement decision remains
22
+ * with governMCPServer.
23
+ *
24
+ * 2. **Unsigned measurement, never proof.** The shadow log and every
25
+ * snapshot carry a mandatory `measurement` disclaimer and are
26
+ * shaped nothing like a signed receipt (no signature, no kid, no
27
+ * canonical payload). A shadow report says "the runtime saw X" —
28
+ * it proves nothing and must never be rendered as if it did.
29
+ *
30
+ * Output surfaces:
31
+ * - in-memory `snapshot()` for programmatic consumers / the CLI,
32
+ * - an append-only JSONL log at `<storagePath>/shadow-discovery.jsonl`
33
+ * when a storagePath is configured (same lifecycle as receipts).
34
+ */
35
+
36
+ import fs from "node:fs";
37
+ import path from "node:path";
38
+
39
+ export const SHADOW_MEASUREMENT_DISCLAIMER =
40
+ "unsigned measurement, never proof — runtime observation only";
41
+
42
+ export const SHADOW_LOG_BASENAME = "shadow-discovery.jsonl";
43
+
44
+ /**
45
+ * Create a shadow-discovery recorder.
46
+ *
47
+ * @param {{
48
+ * serverId?: string,
49
+ * capabilities?: Array<{ name?: string, id?: string }>,
50
+ * storagePath?: string | null,
51
+ * onAudit?: (event: { kind: string, detail: object }) => void,
52
+ * now?: () => string,
53
+ * }} [opts]
54
+ */
55
+ export function createShadowDiscovery(opts = {}) {
56
+ const serverId = opts.serverId ?? "proxy";
57
+ const now = opts.now ?? (() => new Date().toISOString());
58
+
59
+ // The classified universe: tool names the companion capability pack
60
+ // (or an inline caller-managed list) already knows. A tool outside
61
+ // this set still executes under the gateway's generic fallback
62
+ // classification — but static discovery never saw it, which is
63
+ // exactly what this module exists to surface.
64
+ const classifiedNames = new Set(
65
+ (opts.capabilities ?? [])
66
+ .map((cap) => cap?.name)
67
+ .filter((name) => typeof name === "string" && name.length > 0),
68
+ );
69
+
70
+ const logPath = opts.storagePath
71
+ ? path.join(opts.storagePath, SHADOW_LOG_BASENAME)
72
+ : null;
73
+
74
+ /** @type {Map<string, { name: string, description?: string, classified: boolean }>} */
75
+ const advertised = new Map();
76
+ /** @type {Map<string, number>} */
77
+ const calls = new Map();
78
+ /** @type {Map<string, number>} */
79
+ const unclassifiedCalls = new Map();
80
+
81
+ function audit(kind, detail) {
82
+ if (typeof opts.onAudit === "function") {
83
+ try { opts.onAudit({ kind, detail }); } catch { /* observation must never throw */ }
84
+ }
85
+ }
86
+
87
+ function appendLog(entry) {
88
+ if (!logPath) return;
89
+ try {
90
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
91
+ fs.appendFileSync(
92
+ logPath,
93
+ JSON.stringify({
94
+ measurement: SHADOW_MEASUREMENT_DISCLAIMER,
95
+ serverId,
96
+ at: now(),
97
+ ...entry,
98
+ }) + "\n",
99
+ "utf8",
100
+ );
101
+ } catch {
102
+ // Best-effort telemetry: a full disk or read-only mount must never
103
+ // take down the governed call path this module merely observes.
104
+ }
105
+ }
106
+
107
+ return {
108
+ logPath,
109
+
110
+ /**
111
+ * Record the upstream's advertised tool universe (a tools/list
112
+ * response). Newly-seen unclassified tools are logged + audited —
113
+ * each one is a surface static discovery missed.
114
+ *
115
+ * @param {Array<{ name: string, description?: string }>} tools
116
+ */
117
+ recordToolList(tools) {
118
+ if (!Array.isArray(tools)) return;
119
+ const newlyUnclassified = [];
120
+ for (const tool of tools) {
121
+ const name = tool?.name;
122
+ if (typeof name !== "string" || name.length === 0) continue;
123
+ const known = advertised.has(name);
124
+ const classified = classifiedNames.has(name);
125
+ advertised.set(name, { name, description: tool.description, classified });
126
+ if (!known && !classified) newlyUnclassified.push(name);
127
+ }
128
+ if (newlyUnclassified.length > 0) {
129
+ audit("shadow.unclassified-tools", { serverId, tools: newlyUnclassified });
130
+ appendLog({ kind: "tool-list", unclassified: newlyUnclassified, advertised: tools.length });
131
+ }
132
+ },
133
+
134
+ /**
135
+ * Record a tool invocation flowing through the proxy. Counting
136
+ * happens regardless of the governance verdict — a denied call is
137
+ * still an observed action surface.
138
+ *
139
+ * @param {string} name
140
+ */
141
+ recordCall(name) {
142
+ if (typeof name !== "string" || name.length === 0) return;
143
+ calls.set(name, (calls.get(name) ?? 0) + 1);
144
+ if (!classifiedNames.has(name)) {
145
+ const n = (unclassifiedCalls.get(name) ?? 0) + 1;
146
+ unclassifiedCalls.set(name, n);
147
+ // First sighting of each unclassified tool gets an audit event
148
+ // + a log line; repeat calls only bump the counter (a chatty
149
+ // agent shouldn't flood the shadow log).
150
+ if (n === 1) {
151
+ audit("shadow.unclassified-call", { serverId, tool: name });
152
+ appendLog({ kind: "unclassified-call", tool: name });
153
+ }
154
+ }
155
+ },
156
+
157
+ /**
158
+ * The reconciliation report: what the runtime saw vs what static
159
+ * classification covers. Type-disjoint from receipts by
160
+ * construction (no signature-shaped fields), with the mandatory
161
+ * measurement disclaimer on every snapshot.
162
+ */
163
+ snapshot() {
164
+ const advertisedList = [...advertised.values()];
165
+ return {
166
+ measurement: SHADOW_MEASUREMENT_DISCLAIMER,
167
+ serverId,
168
+ classifiedCapabilityCount: classifiedNames.size,
169
+ advertisedToolCount: advertisedList.length,
170
+ unclassifiedAdvertised: advertisedList
171
+ .filter((t) => !t.classified)
172
+ .map((t) => t.name)
173
+ .sort(),
174
+ callCounts: Object.fromEntries([...calls.entries()].sort()),
175
+ unclassifiedCallCounts: Object.fromEntries([...unclassifiedCalls.entries()].sort()),
176
+ };
177
+ },
178
+ };
179
+ }