csoai-gspc-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -0
- package/gspc-tools.json +65 -0
- package/index.mjs +374 -0
- package/package.json +43 -0
- package/verify-card.mjs +208 -0
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# csoai-gspc-mcp
|
|
2
|
+
|
|
3
|
+
Stdio MCP server for the live GSPC board and the signed measurement cards at
|
|
4
|
+
[councilof.ai](https://councilof.ai). Zero dependencies. Node >= 20.
|
|
5
|
+
|
|
6
|
+
**Doctrine, enforced in the tools, not just stated here:** we measure, never
|
|
7
|
+
certify. Verdicts are three-state — VALID / INVALID (with the reason) /
|
|
8
|
+
UNCHECKABLE — never two-state. An unmeasured axis is a first-class answer, not
|
|
9
|
+
an error and not a zero. A fetch failure is a distinct UNREACHABLE state; no
|
|
10
|
+
cached number is ever presented as live. Two surfaces that count the same thing
|
|
11
|
+
are reported as two labelled numbers and never reconciled.
|
|
12
|
+
|
|
13
|
+
## Tools
|
|
14
|
+
|
|
15
|
+
| tool | what it does |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `board_totals` | Live totals from `GET https://councilof.ai/api/gspc`: slot count and measured count as two labelled numbers with their `kind` and `as_of` dates. UNREACHABLE state on fetch failure. |
|
|
18
|
+
| `get_axis` | One axis row from the live board: `n`, `accuracy`, `interval`, MEASURED/UNMEASURED status, dates. Args: `{ "axis": "jail" }`. |
|
|
19
|
+
| `verify_card` | Verify a signed `gspc.measurement-card` under the published rule — recompute the id from the canonical body, check the Ed25519 signature under the **pinned** key `did:web:csoai.org#card-attestation-1`. A card signed with its own freshly-made key is INVALID, not valid. Args: `{ "card": <object | JSON string | councilof.ai URL> }`. |
|
|
20
|
+
| `list_cards` | What the published index (`/signed/card_index.json`) declares next to what the card store endpoint (`/api/cards`) reports — two labelled numbers, never reconciled. Optional `axis`, `limit`. |
|
|
21
|
+
|
|
22
|
+
The same four tools, from the same definitions file
|
|
23
|
+
(`functions/mcp/gspc-tools.json`), are served over HTTP at
|
|
24
|
+
`https://councilof.ai/mcp` (streamable HTTP, JSON-RPC 2.0 POST). Use whichever
|
|
25
|
+
transport your client speaks; the contracts are identical.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
This package is **not yet published to npm** (checked 2026-08-27). Until it is,
|
|
30
|
+
install from a checkout of the repo — the server is a single file with no
|
|
31
|
+
dependencies, so "install" is just pointing your client at it:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
git clone https://github.com/CSOAI-ORG/councilof-ai.git
|
|
35
|
+
# the server is at councilof-ai/mcp/gspc-server/index.mjs — no npm install needed
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Below, replace `/ABS/PATH/TO/councilof-ai` with where you cloned it.
|
|
39
|
+
|
|
40
|
+
### Claude Code
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
claude mcp add gspc -- node /ABS/PATH/TO/councilof-ai/mcp/gspc-server/index.mjs
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Once published to npm this becomes `claude mcp add gspc -- npx -y csoai-gspc-mcp`.
|
|
47
|
+
|
|
48
|
+
### Claude Desktop
|
|
49
|
+
|
|
50
|
+
Add to `claude_desktop_config.json` (macOS:
|
|
51
|
+
`~/Library/Application Support/Claude/claude_desktop_config.json`; Windows:
|
|
52
|
+
`%APPDATA%\Claude\claude_desktop_config.json`):
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"gspc": {
|
|
58
|
+
"command": "node",
|
|
59
|
+
"args": ["/ABS/PATH/TO/councilof-ai/mcp/gspc-server/index.mjs"]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Cursor
|
|
66
|
+
|
|
67
|
+
Add to `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` globally):
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"mcpServers": {
|
|
72
|
+
"gspc": {
|
|
73
|
+
"command": "node",
|
|
74
|
+
"args": ["/ABS/PATH/TO/councilof-ai/mcp/gspc-server/index.mjs"]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Any other stdio MCP client (Grok Bot, DSH harness, your own agent)
|
|
81
|
+
|
|
82
|
+
Spawn `node /ABS/PATH/TO/councilof-ai/mcp/gspc-server/index.mjs` and speak
|
|
83
|
+
newline-delimited JSON-RPC 2.0 on its stdin/stdout (stderr is logs only):
|
|
84
|
+
|
|
85
|
+
1. send `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"you","version":"0"}}}`
|
|
86
|
+
2. send `{"jsonrpc":"2.0","method":"notifications/initialized"}`
|
|
87
|
+
3. send `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`
|
|
88
|
+
4. call tools: `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"board_totals","arguments":{}}}`
|
|
89
|
+
|
|
90
|
+
Every `tools/call` result carries both a human `content[0].text` summary and a
|
|
91
|
+
machine `structuredContent` object. Protocol versions accepted: 2024-11-05,
|
|
92
|
+
2025-03-26, 2025-06-18.
|
|
93
|
+
|
|
94
|
+
If you cannot spawn processes, POST the same JSON-RPC bodies to
|
|
95
|
+
`https://councilof.ai/mcp` instead.
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
- `GSPC_ORIGIN` — override the live origin (default `https://councilof.ai`).
|
|
100
|
+
Card URLs are only ever fetched from councilof.ai / csoai.org.
|
|
101
|
+
|
|
102
|
+
## Verify it yourself
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
node smoke.mjs
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Real transport, no mocks: spawns the server, runs
|
|
109
|
+
initialize → tools/list → tools/call, then proves the three verify_card
|
|
110
|
+
verdicts — a genuine published card is VALID, the same card with one byte of
|
|
111
|
+
body changed is INVALID (id mismatch), and a forged card signed with a
|
|
112
|
+
freshly-generated key is INVALID (pubkey is not the published
|
|
113
|
+
card-attestation key) even though it is perfectly self-consistent.
|
|
114
|
+
|
|
115
|
+
## One source of truth
|
|
116
|
+
|
|
117
|
+
- Tool definitions: `functions/mcp/gspc-tools.json` — shared byte-for-byte with
|
|
118
|
+
the HTTP endpoint (`functions/mcp/[[path]].ts`). Neither surface defines
|
|
119
|
+
these tools anywhere else.
|
|
120
|
+
- Card verification: `public/signed/verify-card.mjs` — the published CLI
|
|
121
|
+
verifier, imported and run as-is.
|
|
122
|
+
- In a repo checkout the canonical files are read directly; `npm run prepack`
|
|
123
|
+
(`pack.mjs`) copies them into the tarball and refuses to pack on drift.
|
|
124
|
+
|
|
125
|
+
Apache-2.0. CSOAI Ltd (UK 16939677).
|
package/gspc-tools.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "csoai.gspc-mcp-tools/1",
|
|
3
|
+
"note": "ONE source of tool definitions for the GSPC MCP tools, shared by the stdio server (mcp/gspc-server/index.mjs) and the HTTP endpoint (functions/mcp/[[path]].ts) so the two surfaces cannot drift. Display text uses 'axis', never the plural — owner language ruling; code identifiers are exempt.",
|
|
4
|
+
"tools": [
|
|
5
|
+
{
|
|
6
|
+
"name": "board_totals",
|
|
7
|
+
"description": "Live GSPC board totals from https://councilof.ai/api/gspc. Returns the slot count and the measured count as two labelled numbers WITH their kind — a slot is a declared position on the board, a measurement is a real run behind it; the two are never summed and never swapped — plus as_of dates for the board and for this fetch. We measure, never certify. If the board cannot be fetched the answer is a distinct UNREACHABLE state: no cached number is ever presented as live.",
|
|
8
|
+
"inputSchema": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"properties": {},
|
|
11
|
+
"additionalProperties": false
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "get_axis",
|
|
16
|
+
"description": "One axis row from the live GSPC board at https://councilof.ai/api/gspc: n, accuracy, interval, MEASURED or UNMEASURED status, family, and dates. An unmeasured axis is a first-class answer — a declared slot with no run behind it, published so the gap is visible — never an error and never a zero. Never a certification.",
|
|
17
|
+
"inputSchema": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"properties": {
|
|
20
|
+
"axis": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "The axis name as it appears on the board, e.g. governance, safety, jail, provenance-controls. Case-insensitive. An unknown name returns the list of names the board actually carries."
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"required": ["axis"],
|
|
26
|
+
"additionalProperties": false
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "verify_card",
|
|
31
|
+
"description": "Verify a signed gspc.measurement-card under the published rule (https://councilof.ai/signed/HOW-TO-VERIFY.md): recompute the id from the canonical body bytes, then check the Ed25519 signature under the PINNED key published at did:web:csoai.org#card-attestation-1. A card that carries its own key proves only that the file is self-consistent — anyone can alter a body and sign it with a key they just generated — so a signer other than the published key is reported INVALID. Three verdicts, never two: VALID, INVALID (with the reason), or UNCHECKABLE when the check could not be completed — 'could not check' is a different claim from 'forged'. Accepts the card as a JSON object, a JSON string, or a councilof.ai / csoai.org URL. Never a certification.",
|
|
32
|
+
"inputSchema": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"card": {
|
|
36
|
+
"description": "The signed card: an object, a JSON string, or a councilof.ai / csoai.org URL to one.",
|
|
37
|
+
"anyOf": [{ "type": "object" }, { "type": "string" }]
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"required": ["card"],
|
|
41
|
+
"additionalProperties": false
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "list_cards",
|
|
46
|
+
"description": "The published signed-card index (https://councilof.ai/signed/card_index.json): what the index declares (n_cards) and how many rows it actually carries, reported next to — never reconciled with — the count the card store endpoint (https://councilof.ai/api/cards) reports for itself. Two labelled numbers from two surfaces; if they disagree, this tool shows the disagreement rather than picking one. Optional filters return recent rows: axis, limit.",
|
|
47
|
+
"inputSchema": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"properties": {
|
|
50
|
+
"axis": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "Only list rows whose axis matches this name (case-insensitive)."
|
|
53
|
+
},
|
|
54
|
+
"limit": {
|
|
55
|
+
"type": "integer",
|
|
56
|
+
"minimum": 0,
|
|
57
|
+
"maximum": 150,
|
|
58
|
+
"description": "How many rows to include in the listing (newest first). Default 10. The two counts are always reported in full regardless of this limit."
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"additionalProperties": false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* csoai-gspc-mcp — stdio MCP server for the live GSPC board and the signed cards.
|
|
4
|
+
*
|
|
5
|
+
* Zero dependencies. Node >= 20 (WebCrypto Ed25519 and global fetch).
|
|
6
|
+
* Transport: MCP stdio — newline-delimited JSON-RPC 2.0 on stdin/stdout.
|
|
7
|
+
* Logs go to stderr only; stdout carries nothing but protocol messages.
|
|
8
|
+
*
|
|
9
|
+
* DOCTRINE (carried into every tool, not just this comment):
|
|
10
|
+
* - We measure, never certify. No tool here issues a certification.
|
|
11
|
+
* - Three-state verdicts: VALID / INVALID (with the reason) / UNCHECKABLE.
|
|
12
|
+
* "I could not check" is a different claim from "this is forged".
|
|
13
|
+
* - Unmeasured is first-class. A declared slot with no run behind it is an
|
|
14
|
+
* honest answer, never an error, never a zero, never rounded up.
|
|
15
|
+
* - Live means live. A fetch failure returns a distinct UNREACHABLE state;
|
|
16
|
+
* no cached number is ever presented as a live one.
|
|
17
|
+
* - Two surfaces that count the same thing are reported as two labelled
|
|
18
|
+
* numbers. This server never reconciles them.
|
|
19
|
+
*
|
|
20
|
+
* ONE SOURCE OF TRUTH:
|
|
21
|
+
* - Tool definitions come from gspc-tools.json — the same file the HTTP
|
|
22
|
+
* endpoint at councilof.ai/mcp imports (functions/mcp/gspc-tools.json).
|
|
23
|
+
* In a repo checkout that file is read directly; the npm package ships a
|
|
24
|
+
* byte-identical copy made at pack time (npm run prepack).
|
|
25
|
+
* - verify_card runs the code in public/signed/verify-card.mjs — the same
|
|
26
|
+
* module the published CLI verifier is. Same rule: repo file first, packed
|
|
27
|
+
* copy as fallback.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createInterface } from "node:readline";
|
|
31
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
|
|
34
|
+
const VERSION = "0.1.0";
|
|
35
|
+
const ORIGIN = process.env.GSPC_ORIGIN || "https://councilof.ai";
|
|
36
|
+
const FETCH_TIMEOUT_MS = 15000;
|
|
37
|
+
|
|
38
|
+
/** Card URLs may be fetched only from the estate's own published origins. */
|
|
39
|
+
const FETCHABLE_ORIGINS = ["https://councilof.ai/", "https://csoai.org/", "https://www.csoai.org/"];
|
|
40
|
+
|
|
41
|
+
/* ------------------------------------------------- one-source module loading */
|
|
42
|
+
|
|
43
|
+
function firstExisting(paths) {
|
|
44
|
+
for (const p of paths) {
|
|
45
|
+
const abs = fileURLToPath(new URL(p, import.meta.url));
|
|
46
|
+
if (existsSync(abs)) return abs;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TOOLS_PATH = firstExisting([
|
|
52
|
+
"../../functions/mcp/gspc-tools.json", // repo checkout: the canonical file
|
|
53
|
+
"./gspc-tools.json", // npm package: the byte-identical pack-time copy
|
|
54
|
+
]);
|
|
55
|
+
if (!TOOLS_PATH) {
|
|
56
|
+
process.stderr.write("csoai-gspc-mcp: gspc-tools.json not found — broken install\n");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const TOOLS = JSON.parse(readFileSync(TOOLS_PATH, "utf8")).tools;
|
|
60
|
+
|
|
61
|
+
const VERIFIER_PATH = firstExisting([
|
|
62
|
+
"../../public/signed/verify-card.mjs", // repo checkout: the canonical file
|
|
63
|
+
"./verify-card.mjs", // npm package: the byte-identical pack-time copy
|
|
64
|
+
]);
|
|
65
|
+
if (!VERIFIER_PATH) {
|
|
66
|
+
process.stderr.write("csoai-gspc-mcp: verify-card.mjs not found — broken install\n");
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
const { verifyCard } = await import(`file://${VERIFIER_PATH}`);
|
|
70
|
+
|
|
71
|
+
/* ------------------------------------------------------------------ fetching */
|
|
72
|
+
|
|
73
|
+
async function fetchJson(path) {
|
|
74
|
+
const url = `${ORIGIN}${path}`;
|
|
75
|
+
const r = await fetch(url, {
|
|
76
|
+
headers: { accept: "application/json" },
|
|
77
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
78
|
+
});
|
|
79
|
+
if (!r.ok) throw new Error(`GET ${url} returned HTTP ${r.status}`);
|
|
80
|
+
return r.json();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The distinct unreachable state. Never a cached number, never a zero that
|
|
85
|
+
* could be mistaken for a measurement.
|
|
86
|
+
*/
|
|
87
|
+
function unreachable(path, e) {
|
|
88
|
+
return {
|
|
89
|
+
state: "UNREACHABLE",
|
|
90
|
+
reachable: false,
|
|
91
|
+
source: `${ORIGIN}${path}`,
|
|
92
|
+
error: e instanceof Error ? e.message : String(e),
|
|
93
|
+
attempted_at: new Date().toISOString(),
|
|
94
|
+
note:
|
|
95
|
+
"The live source could not be fetched. No cached or remembered number is " +
|
|
96
|
+
"substituted — an unreachable board is a different claim from any count.",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* --------------------------------------------------------------------- tools */
|
|
101
|
+
|
|
102
|
+
async function boardTotals() {
|
|
103
|
+
let d;
|
|
104
|
+
try {
|
|
105
|
+
d = await fetchJson("/api/gspc");
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return unreachable("/api/gspc", e);
|
|
108
|
+
}
|
|
109
|
+
const t = d.totals ?? {};
|
|
110
|
+
return {
|
|
111
|
+
state: "LIVE",
|
|
112
|
+
reachable: true,
|
|
113
|
+
kind: "live-board-totals",
|
|
114
|
+
source: `${ORIGIN}/api/gspc`,
|
|
115
|
+
as_of: { board_measured_on: d.measured_on ?? null, fetched_at: new Date().toISOString() },
|
|
116
|
+
counts: [
|
|
117
|
+
{
|
|
118
|
+
name: "axis_slots",
|
|
119
|
+
value: t.axes ?? null,
|
|
120
|
+
kind: "declared slot count — a slot is a position on the board, not evidence anything was measured",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "measured",
|
|
124
|
+
value: t.measured_axes ?? null,
|
|
125
|
+
kind: "measurement count — slots with a real run behind them",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "unmeasured",
|
|
129
|
+
value: t.unmeasured_axes ?? null,
|
|
130
|
+
kind: "declared slots with no run behind them — published so the gap is visible; first-class, not an error",
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
count_grammar: t.count_grammar ?? null,
|
|
134
|
+
public_count: t.public_count ?? null,
|
|
135
|
+
by_family: t.by_family ?? null,
|
|
136
|
+
not_a_certification: true,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function getAxis(args) {
|
|
141
|
+
const wanted = String(args.axis ?? "").trim().toLowerCase();
|
|
142
|
+
if (!wanted) return { state: "BAD_INPUT", error: "pass an axis name, e.g. governance" };
|
|
143
|
+
let d;
|
|
144
|
+
try {
|
|
145
|
+
d = await fetchJson("/api/gspc");
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return unreachable("/api/gspc", e);
|
|
148
|
+
}
|
|
149
|
+
const rows = d.axes ?? [];
|
|
150
|
+
const row = rows.find((r) => String(r.axis ?? "").toLowerCase() === wanted);
|
|
151
|
+
if (!row) {
|
|
152
|
+
return {
|
|
153
|
+
state: "NOT_ON_BOARD",
|
|
154
|
+
axis: wanted,
|
|
155
|
+
note: "This name is not a row on the live board. That is a fact about the board, not a verdict about the subject.",
|
|
156
|
+
board_carries: rows.map((r) => r.axis),
|
|
157
|
+
as_of: { board_measured_on: d.measured_on ?? null, fetched_at: new Date().toISOString() },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const measured = String(row.status ?? "").toUpperCase() === "MEASURED";
|
|
161
|
+
return {
|
|
162
|
+
state: "LIVE",
|
|
163
|
+
axis: row.axis,
|
|
164
|
+
family: row.family ?? null,
|
|
165
|
+
status: row.status ?? null,
|
|
166
|
+
measured,
|
|
167
|
+
measured_note: measured
|
|
168
|
+
? "a real run stands behind this row"
|
|
169
|
+
: "a declared slot with no run behind it — published so the gap is visible; first-class, not an error and not a zero",
|
|
170
|
+
n: row.n ?? null,
|
|
171
|
+
accuracy: row.accuracy ?? null,
|
|
172
|
+
interval: row.interval ?? null,
|
|
173
|
+
leader: row.leader ?? null,
|
|
174
|
+
dataset: row.dataset ?? null,
|
|
175
|
+
note: row.note ?? null,
|
|
176
|
+
as_of: { board_measured_on: d.measured_on ?? null, fetched_at: new Date().toISOString() },
|
|
177
|
+
source: `${ORIGIN}/api/gspc`,
|
|
178
|
+
not_a_certification: true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Coerce whatever the caller passed into a card object, or say why we could not. */
|
|
183
|
+
async function coerceCard(raw) {
|
|
184
|
+
if (raw && typeof raw === "object") return { card: raw };
|
|
185
|
+
if (typeof raw !== "string")
|
|
186
|
+
return { error: "pass the card as an object, a JSON string, or a councilof.ai / csoai.org URL" };
|
|
187
|
+
const s = raw.trim();
|
|
188
|
+
if (/^https?:\/\//i.test(s)) {
|
|
189
|
+
if (!FETCHABLE_ORIGINS.some((o) => s.startsWith(o)))
|
|
190
|
+
return {
|
|
191
|
+
error:
|
|
192
|
+
"only councilof.ai and csoai.org URLs are fetched by this tool; fetch other URLs yourself and pass the JSON",
|
|
193
|
+
};
|
|
194
|
+
try {
|
|
195
|
+
const r = await fetch(s, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
196
|
+
if (!r.ok) return { error: `card fetch returned HTTP ${r.status}` };
|
|
197
|
+
return { card: await r.json() };
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return { error: `card fetch failed: ${e.message}` };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
return { card: JSON.parse(s) };
|
|
204
|
+
} catch {
|
|
205
|
+
return { error: "the string is neither valid JSON nor a councilof.ai / csoai.org URL" };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function verifyCardTool(args) {
|
|
210
|
+
const { card, error } = await coerceCard(args.card ?? args.record ?? args.json ?? args.url ?? args.input);
|
|
211
|
+
if (error) return { state: "UNCHECKABLE", reason: error, not_a_certification: true };
|
|
212
|
+
const v = await verifyCard(card);
|
|
213
|
+
return {
|
|
214
|
+
state: v.state, // VALID | INVALID | UNCHECKABLE — three verdicts, never two
|
|
215
|
+
id: v.id ?? card?.id ?? null,
|
|
216
|
+
axis: v.axis ?? null,
|
|
217
|
+
reason: v.reason ?? null,
|
|
218
|
+
rule: `${ORIGIN}/signed/HOW-TO-VERIFY.md`,
|
|
219
|
+
pinned_key: "did:web:csoai.org#card-attestation-1",
|
|
220
|
+
not_a_certification: true,
|
|
221
|
+
note:
|
|
222
|
+
v.state === "VALID"
|
|
223
|
+
? "The body reproduces its own id and the signature verifies under the published card-attestation key. This is a verified measurement card — not a certification of anything."
|
|
224
|
+
: v.state === "INVALID"
|
|
225
|
+
? "This card fails the published rule for the stated reason. INVALID is a positive finding, distinct from UNCHECKABLE."
|
|
226
|
+
: "The check could not be completed. 'Could not check' is a different claim from 'forged'.",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function listCards(args) {
|
|
231
|
+
const out = {
|
|
232
|
+
doctrine:
|
|
233
|
+
"Two labelled numbers from two surfaces, reported separately and never reconciled by this tool. If they disagree, the disagreement is the finding.",
|
|
234
|
+
index: null,
|
|
235
|
+
card_store_count_endpoint: null,
|
|
236
|
+
rows: null,
|
|
237
|
+
not_a_certification: true,
|
|
238
|
+
};
|
|
239
|
+
try {
|
|
240
|
+
const idx = await fetchJson("/signed/card_index.json");
|
|
241
|
+
const rows = Array.isArray(idx.cards) ? idx.cards : [];
|
|
242
|
+
out.index = {
|
|
243
|
+
source: `${ORIGIN}/signed/card_index.json`,
|
|
244
|
+
n_cards_declared: idx.n_cards ?? null,
|
|
245
|
+
rows_carried: rows.length,
|
|
246
|
+
head: idx.head ?? null,
|
|
247
|
+
packaged_at: idx.packaged_at ?? null,
|
|
248
|
+
pubkey: idx.pubkey ?? null,
|
|
249
|
+
};
|
|
250
|
+
const wanted = args.axis ? String(args.axis).toLowerCase() : null;
|
|
251
|
+
const limit = Number.isInteger(args.limit) ? args.limit : 10;
|
|
252
|
+
out.rows = rows
|
|
253
|
+
.filter((r) => !wanted || String(r.axis ?? "").toLowerCase() === wanted)
|
|
254
|
+
.slice()
|
|
255
|
+
.sort((a, b) => String(b.ts ?? "").localeCompare(String(a.ts ?? "")))
|
|
256
|
+
.slice(0, limit)
|
|
257
|
+
.map((r) => ({ card: r.card, axis: r.axis, ts: r.ts, signed: r.signed }));
|
|
258
|
+
} catch (e) {
|
|
259
|
+
out.index = unreachable("/signed/card_index.json", e);
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const api = await fetchJson("/api/cards");
|
|
263
|
+
out.card_store_count_endpoint = {
|
|
264
|
+
source: `${ORIGIN}/api/cards`,
|
|
265
|
+
count: api?.cards?.count ?? null,
|
|
266
|
+
signed: api?.cards?.signed ?? null,
|
|
267
|
+
};
|
|
268
|
+
} catch (e) {
|
|
269
|
+
out.card_store_count_endpoint = unreachable("/api/cards", e);
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const HANDLERS = {
|
|
275
|
+
board_totals: boardTotals,
|
|
276
|
+
get_axis: getAxis,
|
|
277
|
+
verify_card: verifyCardTool,
|
|
278
|
+
list_cards: listCards,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/* ----------------------------------------------------------------- transport */
|
|
282
|
+
|
|
283
|
+
const SUPPORTED_PROTOCOLS = ["2024-11-05", "2025-03-26", "2025-06-18"];
|
|
284
|
+
|
|
285
|
+
function send(msg) {
|
|
286
|
+
process.stdout.write(JSON.stringify(msg) + "\n");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function reply(id, result) {
|
|
290
|
+
send({ jsonrpc: "2.0", id, result });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function replyError(id, code, message) {
|
|
294
|
+
send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function summaryLine(name, payload) {
|
|
298
|
+
if (payload.state === "UNREACHABLE" || payload.index?.state === "UNREACHABLE")
|
|
299
|
+
return `UNREACHABLE — the live source could not be fetched; no cached number is substituted.`;
|
|
300
|
+
switch (name) {
|
|
301
|
+
case "board_totals":
|
|
302
|
+
return `LIVE board totals — ${payload.public_count ?? "see counts"} (slots and measurements are different kinds; never summed).`;
|
|
303
|
+
case "get_axis":
|
|
304
|
+
return payload.state === "NOT_ON_BOARD"
|
|
305
|
+
? `NOT ON BOARD — "${payload.axis}" is not a row the live board carries.`
|
|
306
|
+
: `${payload.status ?? "?"} — axis "${payload.axis}" (${payload.measured ? "a real run stands behind this row" : "declared slot, no run behind it"}).`;
|
|
307
|
+
case "verify_card":
|
|
308
|
+
return `${payload.state}${payload.reason ? " — " + payload.reason : ""}${payload.state === "VALID" ? ` — ${String(payload.id).slice(0, 16)}… verifies under the published key.` : ""}`;
|
|
309
|
+
case "list_cards": {
|
|
310
|
+
const a = payload.index?.n_cards_declared ?? "?";
|
|
311
|
+
const b = payload.card_store_count_endpoint?.count ?? "?";
|
|
312
|
+
return `index declares ${a} card rows; the store's count endpoint reports ${b}. Two labelled numbers, not reconciled here.`;
|
|
313
|
+
}
|
|
314
|
+
default:
|
|
315
|
+
return name;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function handle(msg) {
|
|
320
|
+
const { id, method, params } = msg;
|
|
321
|
+
const isRequest = id !== undefined && id !== null;
|
|
322
|
+
|
|
323
|
+
if (method === "initialize") {
|
|
324
|
+
const asked = params?.protocolVersion;
|
|
325
|
+
return reply(id, {
|
|
326
|
+
protocolVersion: SUPPORTED_PROTOCOLS.includes(asked) ? asked : "2024-11-05",
|
|
327
|
+
capabilities: { tools: {} },
|
|
328
|
+
serverInfo: { name: "csoai-gspc-mcp", version: VERSION },
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (method === "notifications/initialized" || method === "initialized") return; // notification, no reply
|
|
332
|
+
if (method === "ping") return reply(id, {});
|
|
333
|
+
if (method === "tools/list") return reply(id, { tools: TOOLS });
|
|
334
|
+
|
|
335
|
+
if (method === "tools/call") {
|
|
336
|
+
const name = params?.name;
|
|
337
|
+
const fn = HANDLERS[name];
|
|
338
|
+
if (!fn) return replyError(id, -32602, `unknown tool: ${name}`);
|
|
339
|
+
try {
|
|
340
|
+
const payload = await fn(params?.arguments ?? {});
|
|
341
|
+
return reply(id, {
|
|
342
|
+
content: [{ type: "text", text: `${summaryLine(name, payload)}\n\n${JSON.stringify(payload, null, 2)}` }],
|
|
343
|
+
structuredContent: payload,
|
|
344
|
+
isError: false,
|
|
345
|
+
});
|
|
346
|
+
} catch (e) {
|
|
347
|
+
return reply(id, {
|
|
348
|
+
content: [{ type: "text", text: `tool error: ${e instanceof Error ? e.message : String(e)}` }],
|
|
349
|
+
isError: true,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (isRequest) return replyError(id, -32601, `method not found: ${method}`);
|
|
355
|
+
// Unknown notification: ignore silently, per JSON-RPC.
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
359
|
+
rl.on("line", (line) => {
|
|
360
|
+
const s = line.trim();
|
|
361
|
+
if (!s) return;
|
|
362
|
+
let msg;
|
|
363
|
+
try {
|
|
364
|
+
msg = JSON.parse(s);
|
|
365
|
+
} catch {
|
|
366
|
+
return replyError(null, -32700, "parse error");
|
|
367
|
+
}
|
|
368
|
+
handle(msg).catch((e) => {
|
|
369
|
+
process.stderr.write(`csoai-gspc-mcp: ${e?.stack ?? e}\n`);
|
|
370
|
+
if (msg?.id !== undefined && msg?.id !== null) replyError(msg.id, -32603, "internal error");
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
rl.on("close", () => process.exit(0));
|
|
374
|
+
process.stderr.write(`csoai-gspc-mcp ${VERSION} — stdio MCP server, live source ${ORIGIN}\n`);
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "csoai-gspc-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stdio MCP server for the live GSPC board and the signed measurement cards at councilof.ai. Four honest tools: board_totals, get_axis, verify_card (pinned-key, three-state), list_cards. We measure, never certify; unmeasured is first-class; verdicts are VALID / INVALID / UNCHECKABLE, never two-state. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"csoai-gspc-mcp": "index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"main": "index.mjs",
|
|
10
|
+
"files": [
|
|
11
|
+
"index.mjs",
|
|
12
|
+
"gspc-tools.json",
|
|
13
|
+
"verify-card.mjs",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"prepack": "node pack.mjs",
|
|
18
|
+
"smoke": "node smoke.mjs"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"mcp",
|
|
22
|
+
"model-context-protocol",
|
|
23
|
+
"csoai",
|
|
24
|
+
"gspc",
|
|
25
|
+
"measurement",
|
|
26
|
+
"ed25519",
|
|
27
|
+
"provenance",
|
|
28
|
+
"ai-governance"
|
|
29
|
+
],
|
|
30
|
+
"license": "Apache-2.0",
|
|
31
|
+
"homepage": "https://councilof.ai",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/CSOAI-ORG/councilof-ai.git",
|
|
35
|
+
"directory": "mcp/gspc-server"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/verify-card.mjs
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* verify-card.mjs — verify a Council of AI measurement card from JavaScript.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS FILE EXISTS. The preimage our cards are signed over was produced by CPython's
|
|
7
|
+
* json.dumps, which renders a float of integral value as "0.0". ECMAScript's JSON.stringify,
|
|
8
|
+
* Go's encoding/json and RFC 8785 (JCS) all render the same value as "0". A large share of
|
|
9
|
+
* our cards contain such a value, so a NAIVE JavaScript verifier computes different bytes and
|
|
10
|
+
* reports a FALSE FAILURE on roughly a third of the set.
|
|
11
|
+
*
|
|
12
|
+
* We cannot re-canonicalise: every card id is the sha256 of these exact bytes, so changing
|
|
13
|
+
* them would invalidate every id and break every citation. So the quirk is handled here,
|
|
14
|
+
* once, in a file anyone can read — rather than left for each implementer to rediscover.
|
|
15
|
+
*
|
|
16
|
+
* THE HONEST LIMIT. JavaScript cannot distinguish 0 from 0.0 at runtime; both are the same
|
|
17
|
+
* IEEE-754 double. So this verifier cannot infer which fields are floats — it is told, by
|
|
18
|
+
* FLOAT_FIELDS below, which is a property of our schema and not of the data. A future card
|
|
19
|
+
* format should use JCS so this note becomes unnecessary. These cards cannot migrate.
|
|
20
|
+
*
|
|
21
|
+
* node verify-card.mjs <card.json> [more.json ...]
|
|
22
|
+
* node verify-card.mjs --all # fetch and verify every published card
|
|
23
|
+
*
|
|
24
|
+
* Requires Node 19+ (for the built-in WebCrypto Ed25519). No dependencies.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const ORIGIN = "https://councilof.ai";
|
|
28
|
+
|
|
29
|
+
// The pinned key. A card carries its own `pubkey`, and verifying against THAT proves only
|
|
30
|
+
// that the file is self-consistent — anyone can alter a body and sign it with a key they
|
|
31
|
+
// just generated. Authenticity requires pinning to the key published in our DID document:
|
|
32
|
+
// did:web:csoai.org#card-attestation-1
|
|
33
|
+
const PINNED_PUBKEY_HEX =
|
|
34
|
+
"d4cb0eaa16d5f50bf7633a36aa34fe09a55e124b9316ded2abdb122bb9c37e38";
|
|
35
|
+
|
|
36
|
+
// Fields whose values are floats in our schema. See "THE HONEST LIMIT" above.
|
|
37
|
+
const FLOAT_FIELDS = new Set(["accuracy", "ci_low", "ci_high", "recall", "precision", "f1"]);
|
|
38
|
+
|
|
39
|
+
/** Canonicalise exactly as CPython json.dumps(sort_keys=True, separators=(',',':')) does. */
|
|
40
|
+
function canonical(value, key = null) {
|
|
41
|
+
if (value === null) return "null";
|
|
42
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
43
|
+
if (typeof value === "number") {
|
|
44
|
+
if (!Number.isFinite(value)) throw new Error(`non-finite number at ${key}`);
|
|
45
|
+
// A float of integral value renders with a trailing ".0" in CPython.
|
|
46
|
+
if (Number.isInteger(value) && FLOAT_FIELDS.has(key)) return value.toFixed(1);
|
|
47
|
+
return String(value);
|
|
48
|
+
}
|
|
49
|
+
if (typeof value === "string") return jsonString(value);
|
|
50
|
+
if (Array.isArray(value)) return "[" + value.map((v) => canonical(v, key)).join(",") + "]";
|
|
51
|
+
if (typeof value === "object") {
|
|
52
|
+
const keys = Object.keys(value).sort();
|
|
53
|
+
return "{" + keys.map((k) => jsonString(k) + ":" + canonical(value[k], k)).join(",") + "}";
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`unserialisable value at ${key}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** JSON string escaping with ensure_ascii=True — non-ASCII becomes \\uXXXX, as CPython does. */
|
|
59
|
+
function jsonString(s) {
|
|
60
|
+
let out = '"';
|
|
61
|
+
for (const ch of s) {
|
|
62
|
+
const c = ch.codePointAt(0);
|
|
63
|
+
if (ch === '"') out += '\\"';
|
|
64
|
+
else if (ch === "\\") out += "\\\\";
|
|
65
|
+
// CPython's ESCAPE_DCT maps 0x08 and 0x0c to the SHORT forms \b and \f, not to
|
|
66
|
+
// / . Omitting these two lines produced a wrong preimage for any body
|
|
67
|
+
// containing them. No published card does, so the bug was latent -- found 2026-08-26.
|
|
68
|
+
else if (ch === "\b") out += "\\b";
|
|
69
|
+
else if (ch === "\f") out += "\\f";
|
|
70
|
+
else if (ch === "\n") out += "\\n";
|
|
71
|
+
else if (ch === "\r") out += "\\r";
|
|
72
|
+
else if (ch === "\t") out += "\\t";
|
|
73
|
+
else if (c < 0x20) out += "\\u" + c.toString(16).padStart(4, "0");
|
|
74
|
+
else if (c < 0x7f) out += ch;
|
|
75
|
+
else if (c <= 0xffff) out += "\\u" + c.toString(16).padStart(4, "0");
|
|
76
|
+
else {
|
|
77
|
+
// Astral plane: CPython emits a surrogate pair, so we must too.
|
|
78
|
+
const v = c - 0x10000;
|
|
79
|
+
out += "\\u" + (0xd800 + (v >> 10)).toString(16).padStart(4, "0");
|
|
80
|
+
out += "\\u" + (0xdc00 + (v & 0x3ff)).toString(16).padStart(4, "0");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out + '"';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const hex = (buf) =>
|
|
87
|
+
[...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
88
|
+
const unhex = (s) => Uint8Array.from(s.match(/../g).map((b) => parseInt(b, 16)));
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* JCS (RFC 8785) canonicaliser — the v2 preimage rule. A card carrying
|
|
92
|
+
* `preimage_rule: "jcs-rfc8785"` is hashed over THIS form, NOT the v1 CPython form.
|
|
93
|
+
* Differences from v1 (canonical above):
|
|
94
|
+
* 1. No FLOAT_FIELDS: every integral float renders as an integer ("0", not "0.0").
|
|
95
|
+
* 2. ensure_ascii=False: non-ASCII stays literal (JS JSON.stringify does this), whereas
|
|
96
|
+
* v1 escapes to \\uXXXX (CPython ensure_ascii=True).
|
|
97
|
+
* 3. Same key-sort + compact separators + escape of quote/backslash/control chars.
|
|
98
|
+
* Matches harness/arena/jcs.py (Python) and the conformance corpus in
|
|
99
|
+
* harness/arena/jcs_conformance.py (18/18 ordinary cases agree cross-language).
|
|
100
|
+
*/
|
|
101
|
+
function canonicalJcs(value, key = null) {
|
|
102
|
+
if (value === null) return "null";
|
|
103
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
104
|
+
if (typeof value === "number") {
|
|
105
|
+
if (!Number.isFinite(value)) throw new Error(`non-finite number at ${key}`);
|
|
106
|
+
// JCS/ECMAScript: integral floats in [10^-6, 10^21) emit as integers; -0 -> "0".
|
|
107
|
+
if (Number.isInteger(value) && value !== 0) {
|
|
108
|
+
const av = Math.abs(value);
|
|
109
|
+
if (av >= 1e-6 && av < 1e21) return String(value);
|
|
110
|
+
}
|
|
111
|
+
if (value === 0) return "0"; // covers -0.0
|
|
112
|
+
return String(value);
|
|
113
|
+
}
|
|
114
|
+
if (typeof value === "string") return jcsString(value);
|
|
115
|
+
if (Array.isArray(value)) return "[" + value.map((v) => canonicalJcs(v, key)).join(",") + "]";
|
|
116
|
+
if (typeof value === "object") {
|
|
117
|
+
const keys = Object.keys(value).sort();
|
|
118
|
+
return "{" + keys.map((k) => jcsString(k) + ":" + canonicalJcs(value[k], k)).join(",") + "}";
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`unserialisable value at ${key}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** JCS string escaping: quote/backslash/control-char escapes, non-ASCII stays LITERAL. */
|
|
124
|
+
function jcsString(s) {
|
|
125
|
+
let out = '"';
|
|
126
|
+
for (const ch of s) {
|
|
127
|
+
const c = ch.codePointAt(0);
|
|
128
|
+
if (ch === '"') out += '\\"';
|
|
129
|
+
else if (ch === "\\") out += "\\\\";
|
|
130
|
+
else if (ch === "\b") out += "\\b";
|
|
131
|
+
else if (ch === "\f") out += "\\f";
|
|
132
|
+
else if (ch === "\n") out += "\\n";
|
|
133
|
+
else if (ch === "\r") out += "\\r";
|
|
134
|
+
else if (ch === "\t") out += "\\t";
|
|
135
|
+
else if (c < 0x20) out += "\\u" + c.toString(16).padStart(4, "0");
|
|
136
|
+
else out += ch; // non-ASCII literal (ensure_ascii=False)
|
|
137
|
+
}
|
|
138
|
+
return out + '"';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Three outcomes, never two: VALID, INVALID (with the reason), or UNCHECKABLE.
|
|
143
|
+
* A verifier that cannot complete its path must say so rather than returning false —
|
|
144
|
+
* "I could not check" is not the same claim as "this is forged".
|
|
145
|
+
*/
|
|
146
|
+
export async function verifyCard(card) {
|
|
147
|
+
if (!card || typeof card !== "object" || !card.body)
|
|
148
|
+
return { state: "UNCHECKABLE", reason: "not a card: no body" };
|
|
149
|
+
if (!card.pubkey || !card.signature || !card.id)
|
|
150
|
+
return { state: "UNCHECKABLE", reason: "missing pubkey, signature or id" };
|
|
151
|
+
|
|
152
|
+
if (card.pubkey !== PINNED_PUBKEY_HEX)
|
|
153
|
+
return { state: "INVALID", reason: "pubkey is not the published card-attestation key" };
|
|
154
|
+
|
|
155
|
+
let preimage;
|
|
156
|
+
try {
|
|
157
|
+
// Preimage-rule dispatch (roadmap item 1): absent = legacy CPython v1; "jcs-rfc8785" = JCS v2.
|
|
158
|
+
// Never re-sign v1 cards — the verifier dispatches on the field.
|
|
159
|
+
const rule = card.preimage_rule || card.canon || "cpython-v1";
|
|
160
|
+
const fn = rule === "jcs-rfc8785" ? canonicalJcs : canonical;
|
|
161
|
+
preimage = new TextEncoder().encode(fn(card.body));
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return { state: "UNCHECKABLE", reason: `cannot canonicalise: ${e.message}` };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const digest = hex(await crypto.subtle.digest("SHA-256", preimage));
|
|
167
|
+
if (digest !== card.id)
|
|
168
|
+
return { state: "INVALID", reason: `id mismatch: body hashes to ${digest.slice(0, 16)}…` };
|
|
169
|
+
|
|
170
|
+
let key;
|
|
171
|
+
try {
|
|
172
|
+
key = await crypto.subtle.importKey("raw", unhex(card.pubkey), "Ed25519", false, ["verify"]);
|
|
173
|
+
} catch {
|
|
174
|
+
return { state: "UNCHECKABLE", reason: "this runtime has no Ed25519 (needs Node 19+)" };
|
|
175
|
+
}
|
|
176
|
+
const ok = await crypto.subtle.verify("Ed25519", key, unhex(card.signature), preimage);
|
|
177
|
+
return ok
|
|
178
|
+
? { state: "VALID", id: card.id, axis: card.body.axis }
|
|
179
|
+
: { state: "INVALID", reason: "signature does not verify under the pinned key" };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------- CLI
|
|
183
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
184
|
+
const args = process.argv.slice(2);
|
|
185
|
+
const cards = [];
|
|
186
|
+
if (args[0] === "--all") {
|
|
187
|
+
const idx = await (await fetch(`${ORIGIN}/signed/card_index.json`)).json();
|
|
188
|
+
const rows = idx.cards || idx.entries || idx;
|
|
189
|
+
process.stderr.write(`fetching ${rows.length} cards…\n`);
|
|
190
|
+
for (const r of rows)
|
|
191
|
+
cards.push(await (await fetch(`${ORIGIN}/signed/cards/${r.card}.json`)).json());
|
|
192
|
+
} else if (args.length) {
|
|
193
|
+
const { readFileSync } = await import("node:fs");
|
|
194
|
+
for (const f of args) cards.push(JSON.parse(readFileSync(f, "utf8")));
|
|
195
|
+
} else {
|
|
196
|
+
console.error("usage: node verify-card.mjs <card.json ...> | --all");
|
|
197
|
+
process.exit(2);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const tally = { VALID: 0, INVALID: 0, UNCHECKABLE: 0 };
|
|
201
|
+
for (const c of cards) {
|
|
202
|
+
const r = await verifyCard(c);
|
|
203
|
+
tally[r.state]++;
|
|
204
|
+
if (r.state !== "VALID") console.error(` ${r.state} ${c?.id?.slice(0, 16) ?? "?"} ${r.reason}`);
|
|
205
|
+
}
|
|
206
|
+
console.log(`VALID ${tally.VALID} · INVALID ${tally.INVALID} · UNCHECKABLE ${tally.UNCHECKABLE}`);
|
|
207
|
+
process.exit(tally.INVALID || tally.UNCHECKABLE ? 1 : 0);
|
|
208
|
+
}
|