@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 +52 -0
- package/bin/strix-mcp-proxy.mjs +26 -19
- package/examples/nvidia-nooa/.mcp.json +20 -0
- package/examples/nvidia-nooa/README.md +181 -0
- package/examples/nvidia-nooa/demo-upstream.mjs +89 -0
- package/examples/nvidia-nooa/nooa_agent.py +45 -0
- package/examples/nvidia-nooa/openshell-policy.yaml +43 -0
- package/examples/nvidia-nooa/proxy-config.json +56 -0
- package/examples/nvidia-nooa/verify-proof.mjs +79 -0
- package/examples/youspot/README.md +58 -0
- package/examples/youspot-proxy-config.json +236 -0
- package/package.json +7 -3
- package/scripts/run-tests.mjs +41 -0
- package/src/index.mjs +5 -0
- package/src/proxy.mjs +46 -1
- package/src/shadow-discovery.mjs +179 -0
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
|
package/bin/strix-mcp-proxy.mjs
CHANGED
|
@@ -14,13 +14,7 @@
|
|
|
14
14
|
* --policy-default DENY \
|
|
15
15
|
* --risk-low ALLOW --risk-medium APPROVAL_REQUIRED
|
|
16
16
|
*
|
|
17
|
-
*
|
|
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
|
-
//
|
|
49
|
-
console.error(`[strix-proxy]
|
|
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
|
|
105
|
-
if (
|
|
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
|
|
147
|
-
(default:
|
|
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.
|
|
154
|
-
|
|
155
|
-
in place of the upstream MCP server
|
|
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
|
|
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.
|
|
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
|
|
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
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<{
|
|
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
|
+
}
|