From 7b3c10fd6d7394028876c5ebc073c0521775ac99 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:30:59 -0400 Subject: [PATCH] ci: pin our own MCP tool surface with truecopy support/dump-tools.mjs writes the server's real tools/list response to mcp-manifest.json (in-memory MCP client, sorted, stable) with a --check mode; the manifest is pinned in truecopy.lock and gated in CI by askalf/truecopy-action@v1.0.1 (SHA-pinned). Chain: code -> manifest (--check) -> lock (verify). A changed tool name, description, or generated schema fails the gate until consciously re-pinned. --- .github/workflows/truecopy-gate.yml | 31 +++++++++ mcp-manifest.json | 98 +++++++++++++++++++++++++++++ support/dump-tools.mjs | 48 ++++++++++++++ truecopy.lock | 23 +++++++ 4 files changed, 200 insertions(+) create mode 100644 .github/workflows/truecopy-gate.yml create mode 100644 mcp-manifest.json create mode 100644 support/dump-tools.mjs create mode 100644 truecopy.lock diff --git a/.github/workflows/truecopy-gate.yml b/.github/workflows/truecopy-gate.yml new file mode 100644 index 0000000..800ed16 --- /dev/null +++ b/.github/workflows/truecopy-gate.yml @@ -0,0 +1,31 @@ +# Dogfood: the stack pins its own MCP tool surface. mcp-manifest.json is the +# server's real tools/list response (support/dump-tools.mjs), pinned in +# truecopy.lock. The chain: code -> manifest (--check) -> lock (verify). +# Changing a tool name, description, or schema — or a dep bump that changes +# the generated schemas — fails this gate until consciously re-pinned. +name: truecopy gate + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: read-all + +jobs: + verify: + name: verify pinned tool surface + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20 + - run: npm ci --no-audit --no-fund + - name: manifest matches the live tool surface + run: node support/dump-tools.mjs --check + - name: truecopy verify + uses: askalf/truecopy-action@5c6fcf00edb83ff4846206392722292d45fee227 # v1.0.1 diff --git a/mcp-manifest.json b/mcp-manifest.json new file mode 100644 index 0000000..8ec2a64 --- /dev/null +++ b/mcp-manifest.json @@ -0,0 +1,98 @@ +{ + "name": "own-your-stack", + "tools": [ + { + "name": "canon_scan", + "title": "Scan an MCP/skill manifest for supply-chain poisoning", + "description": "Paste an MCP server or skill manifest (JSON) and truecopy scans its tool names/descriptions for hidden instructions, exfiltration lures, and other poisoned-skill / tool-poisoning attacks. Returns a verdict (clean / flagged) and the findings. Vet a third-party tool BEFORE you trust it.", + "inputSchema": { + "type": "object", + "properties": { + "manifest": { + "type": "string", + "description": "the MCP/skill manifest as JSON text" + } + }, + "required": [ + "manifest" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "keeper_lease", + "title": "Lease a credential — you get an opaque handle, never the secret", + "description": "Request a short-lived, scoped lease for a credential held in the strongroom vault. You receive a lease handle (id + scope + ttl); the secret itself is materialized only at the egress point when the lease is redeemed, and never enters your context. The named secret must already be in the vault.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the vault secret name to lease" + }, + "host": { + "description": "restrict the lease to this destination host", + "type": "string" + }, + "ttlS": { + "description": "lease lifetime in seconds (default 300)", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "uses": { + "description": "max redemptions (default 1)", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "name" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "warden_check", + "title": "Is this action safe to run? (action firewall)", + "description": "Submit a tool action — { tool, input } — and get redstamp's verdict: decision (allow / approve / block), risk tier, and the reasons. Catches shell/exec, SSRF + cloud-metadata, secret exfiltration, dangerous writes/deletes, and prompt-injection in the arguments. Call this BEFORE executing any consequential tool call.", + "inputSchema": { + "type": "object", + "properties": { + "tool": { + "type": "string", + "description": "the tool/action name, e.g. fetch, shell, write, delete, read" + }, + "input": { + "description": "the arguments the tool would run with (url, command, path, content, …)", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "tool" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + } + ] +} diff --git a/support/dump-tools.mjs b/support/dump-tools.mjs new file mode 100644 index 0000000..80b7286 --- /dev/null +++ b/support/dump-tools.mjs @@ -0,0 +1,48 @@ +/** + * Dump the composed server's live tool surface — the exact `tools/list` + * wire response, via a real MCP client over an in-memory transport — to + * mcp-manifest.json, where truecopy pins it (see truecopy.lock). + * + * node support/dump-tools.mjs regenerate mcp-manifest.json + * node support/dump-tools.mjs --check exit 1 if the committed manifest + * no longer matches the code + * + * The chain this enables in CI: code -> manifest (--check) -> lock + * (truecopy verify). Changing the tool surface — or bumping a dep that + * changes the generated schemas — fails the gate until the manifest is + * regenerated and consciously re-pinned. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { createOysServer } from '../mcp.mjs'; + +const MANIFEST = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'mcp-manifest.json'); + +const { server } = createOysServer(); +const [clientT, serverT] = InMemoryTransport.createLinkedPair(); +const client = new Client({ name: 'oys-dump-tools', version: '0' }); +await Promise.all([server.connect(serverT), client.connect(clientT)]); + +const { tools } = await client.listTools(); +const manifest = { + name: 'own-your-stack', + tools: tools.slice().sort((a, b) => a.name.localeCompare(b.name)), +}; +const rendered = JSON.stringify(manifest, null, 2) + '\n'; +await client.close(); + +if (process.argv.includes('--check')) { + const committed = fs.existsSync(MANIFEST) ? fs.readFileSync(MANIFEST, 'utf8').replace(/\r\n/g, '\n') : ''; + if (committed !== rendered) { + console.error('mcp-manifest.json is stale: the server\'s tool surface changed.'); + console.error('Regenerate with `node support/dump-tools.mjs`, review the diff, and re-pin (`truecopy add mcp-manifest.json`).'); + process.exit(1); + } + console.log('mcp-manifest.json matches the live tool surface'); +} else { + fs.writeFileSync(MANIFEST, rendered); + console.log(`wrote ${MANIFEST} (${manifest.tools.length} tools)`); +} diff --git a/truecopy.lock b/truecopy.lock new file mode 100644 index 0000000..e302bd2 --- /dev/null +++ b/truecopy.lock @@ -0,0 +1,23 @@ +{ + "version": 1, + "skills": { + "own-your-stack": { + "source": "mcp-manifest.json", + "kind": "mcp", + "hash": "82ee2bdcb462d9ddac4ed2a488a5b6c10a2308fbe6071ef5da3a12c47e14b44c", + "scannedAt": "2026-07-16T17:30:10.521Z", + "verdict": "clean", + "findings": 0, + "detection": { + "engine": "redstamp", + "version": "0.4.1" + }, + "parts": { + "canon_scan": "b0465b249ae555a3872488db17c1fb2965951ec7b9bdfe6c4590901e6cbbd15a", + "keeper_lease": "7a62c4055c035c4a6602a098264f8ce150026e8d1ac7951a4e3cad2f3d645c16", + "warden_check": "21e1989a475f3483a81a2fb85e9f214bb0d453cc634f32d438e125c1852797f1", + "(manifest)": "d0e70ff8f270eabfc57551ef012b3becc28668e593fdefa6185405c51984fa6b" + } + } + } +}