@proofofwork-agency/toolpin 0.2.3
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/CONTRIBUTING.md +117 -0
- package/LICENSE +183 -0
- package/README.md +323 -0
- package/SECURITY.md +61 -0
- package/action.yml +134 -0
- package/dist/canonicalJson.js +38 -0
- package/dist/capabilities.js +139 -0
- package/dist/ci.js +26 -0
- package/dist/cli.js +1843 -0
- package/dist/clientSupport.js +76 -0
- package/dist/codexToml.js +213 -0
- package/dist/config.js +337 -0
- package/dist/constants.js +3 -0
- package/dist/continueYaml.js +76 -0
- package/dist/doctor.js +163 -0
- package/dist/install.js +191 -0
- package/dist/installed.js +405 -0
- package/dist/integrity.js +14 -0
- package/dist/inventory.js +169 -0
- package/dist/packageIntegrity.js +153 -0
- package/dist/plan.js +595 -0
- package/dist/policy.js +310 -0
- package/dist/registry.js +1610 -0
- package/dist/runtimeAdvisory.js +80 -0
- package/dist/safeFetch.js +157 -0
- package/dist/sarif.js +162 -0
- package/dist/scan.js +113 -0
- package/dist/search.js +44 -0
- package/dist/secrets.js +165 -0
- package/dist/signing.js +146 -0
- package/dist/tester.js +240 -0
- package/dist/trust.js +528 -0
- package/dist/tui/app.js +1731 -0
- package/dist/tui/command.js +50 -0
- package/dist/tui/configSnippet.js +11 -0
- package/dist/tui/constants.js +37 -0
- package/dist/tui/format.js +31 -0
- package/dist/tui/installedState.js +23 -0
- package/dist/tui/layout.js +65 -0
- package/dist/tui/selectors.js +282 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/ui/trust.js +77 -0
- package/dist/tui/views/installed.js +82 -0
- package/dist/tui/views/panels.js +637 -0
- package/dist/tui.js +12 -0
- package/dist/types.js +1 -0
- package/dist/verificationTrust.js +103 -0
- package/dist/verify.js +537 -0
- package/dist/version.js +1 -0
- package/dist/versions.js +127 -0
- package/docs/assets/readme/terminal-demo.svg +174 -0
- package/docs/assets/readme/tui-browse-overview.jpg +0 -0
- package/docs/assets/readme/tui-config-preview.jpg +0 -0
- package/docs/assets/readme/tui-help.jpg +0 -0
- package/docs/assets/readme/tui-installed-inventory.jpg +0 -0
- package/docs/how-to/catch-drift-in-ci.md +189 -0
- package/docs/how-to/custom-registries.md +156 -0
- package/docs/how-to/toolpin-curated-registry.md +153 -0
- package/package.json +76 -0
- package/registry/README.md +92 -0
- package/registry/v0/servers +115 -0
package/action.yml
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
name: ToolPin CI
|
|
2
|
+
description: Verify an MCP lockfile with toolpin ci.
|
|
3
|
+
|
|
4
|
+
inputs:
|
|
5
|
+
working-directory:
|
|
6
|
+
description: Directory that contains mcp-lock.json.
|
|
7
|
+
required: false
|
|
8
|
+
default: .
|
|
9
|
+
file:
|
|
10
|
+
description: Lockfile path, relative to working-directory.
|
|
11
|
+
required: false
|
|
12
|
+
default: mcp-lock.json
|
|
13
|
+
source:
|
|
14
|
+
description: Registry source passed to --source.
|
|
15
|
+
required: false
|
|
16
|
+
default: all
|
|
17
|
+
live:
|
|
18
|
+
description: Fetch live registry data instead of relying on local cache.
|
|
19
|
+
required: false
|
|
20
|
+
default: "true"
|
|
21
|
+
verify:
|
|
22
|
+
description: Run verification before comparing locked plans.
|
|
23
|
+
required: false
|
|
24
|
+
default: "false"
|
|
25
|
+
expect-digest:
|
|
26
|
+
description: Expected whole-lock digest from a trusted out-of-band source.
|
|
27
|
+
required: false
|
|
28
|
+
default: ""
|
|
29
|
+
signature:
|
|
30
|
+
description: Detached signature path.
|
|
31
|
+
required: false
|
|
32
|
+
default: ""
|
|
33
|
+
public-key:
|
|
34
|
+
description: Public key path used with signature.
|
|
35
|
+
required: false
|
|
36
|
+
default: ""
|
|
37
|
+
toolpin-version:
|
|
38
|
+
description: Optional npm version specifier for the @proofofwork-agency/toolpin npm package after public npm publish. Leave empty to install from this action source.
|
|
39
|
+
required: false
|
|
40
|
+
default: ""
|
|
41
|
+
policy:
|
|
42
|
+
description: Policy file path.
|
|
43
|
+
required: false
|
|
44
|
+
default: .toolpin/policy.json
|
|
45
|
+
no-policy:
|
|
46
|
+
description: Pass --no-policy and skip policy enforcement.
|
|
47
|
+
required: false
|
|
48
|
+
default: "false"
|
|
49
|
+
timeout:
|
|
50
|
+
description: Live verification timeout in milliseconds.
|
|
51
|
+
required: false
|
|
52
|
+
default: "15000"
|
|
53
|
+
skip-live-verification:
|
|
54
|
+
description: Pass --skip-live-verification when verify is enabled.
|
|
55
|
+
required: false
|
|
56
|
+
default: "false"
|
|
57
|
+
|
|
58
|
+
runs:
|
|
59
|
+
using: composite
|
|
60
|
+
steps:
|
|
61
|
+
- name: Set up Node.js
|
|
62
|
+
uses: actions/setup-node@v4
|
|
63
|
+
with:
|
|
64
|
+
node-version: 22
|
|
65
|
+
|
|
66
|
+
- name: Install ToolPin
|
|
67
|
+
shell: bash
|
|
68
|
+
run: |
|
|
69
|
+
set -euo pipefail
|
|
70
|
+
|
|
71
|
+
if [[ -n "${TOOLPIN_VERSION}" ]]; then
|
|
72
|
+
npm install -g "@proofofwork-agency/toolpin@${TOOLPIN_VERSION}"
|
|
73
|
+
else
|
|
74
|
+
npm ci --prefix "$GITHUB_ACTION_PATH"
|
|
75
|
+
npm run --prefix "$GITHUB_ACTION_PATH" build
|
|
76
|
+
npm install -g "$GITHUB_ACTION_PATH"
|
|
77
|
+
fi
|
|
78
|
+
env:
|
|
79
|
+
TOOLPIN_VERSION: ${{ inputs.toolpin-version }}
|
|
80
|
+
|
|
81
|
+
- name: Run toolpin ci
|
|
82
|
+
shell: bash
|
|
83
|
+
working-directory: ${{ inputs.working-directory }}
|
|
84
|
+
run: |
|
|
85
|
+
set -euo pipefail
|
|
86
|
+
|
|
87
|
+
cmd=(toolpin ci --file "$TOOLPIN_FILE" --source "$TOOLPIN_SOURCE")
|
|
88
|
+
|
|
89
|
+
if [[ "$TOOLPIN_LIVE" == "true" ]]; then
|
|
90
|
+
cmd+=(--live)
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
if [[ "$TOOLPIN_VERIFY" == "true" ]]; then
|
|
94
|
+
cmd+=(--verify --timeout "$TOOLPIN_TIMEOUT")
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
if [[ "$TOOLPIN_SKIP_LIVE_VERIFICATION" == "true" ]]; then
|
|
98
|
+
cmd+=(--skip-live-verification)
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
if [[ -n "$TOOLPIN_EXPECT_DIGEST" ]]; then
|
|
102
|
+
cmd+=(--expect-digest "$TOOLPIN_EXPECT_DIGEST")
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
if [[ -n "$TOOLPIN_SIGNATURE" || -n "$TOOLPIN_PUBLIC_KEY" ]]; then
|
|
106
|
+
if [[ -z "$TOOLPIN_SIGNATURE" || -z "$TOOLPIN_PUBLIC_KEY" ]]; then
|
|
107
|
+
echo "ToolPin CI requires both signature and public-key when either is set." >&2
|
|
108
|
+
exit 2
|
|
109
|
+
fi
|
|
110
|
+
cmd+=(--signature "$TOOLPIN_SIGNATURE" --public-key "$TOOLPIN_PUBLIC_KEY")
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
if [[ "$TOOLPIN_NO_POLICY" == "true" ]]; then
|
|
114
|
+
cmd+=(--no-policy)
|
|
115
|
+
elif [[ -n "$TOOLPIN_POLICY" ]]; then
|
|
116
|
+
cmd+=(--policy "$TOOLPIN_POLICY")
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
printf 'Running:'
|
|
120
|
+
printf ' %q' "${cmd[@]}"
|
|
121
|
+
printf '\n'
|
|
122
|
+
"${cmd[@]}"
|
|
123
|
+
env:
|
|
124
|
+
TOOLPIN_FILE: ${{ inputs.file }}
|
|
125
|
+
TOOLPIN_SOURCE: ${{ inputs.source }}
|
|
126
|
+
TOOLPIN_LIVE: ${{ inputs.live }}
|
|
127
|
+
TOOLPIN_VERIFY: ${{ inputs.verify }}
|
|
128
|
+
TOOLPIN_EXPECT_DIGEST: ${{ inputs.expect-digest }}
|
|
129
|
+
TOOLPIN_SIGNATURE: ${{ inputs.signature }}
|
|
130
|
+
TOOLPIN_PUBLIC_KEY: ${{ inputs.public-key }}
|
|
131
|
+
TOOLPIN_POLICY: ${{ inputs.policy }}
|
|
132
|
+
TOOLPIN_NO_POLICY: ${{ inputs.no-policy }}
|
|
133
|
+
TOOLPIN_TIMEOUT: ${{ inputs.timeout }}
|
|
134
|
+
TOOLPIN_SKIP_LIVE_VERIFICATION: ${{ inputs.skip-live-verification }}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function canonicalJson(value, options = {}) {
|
|
2
|
+
return JSON.stringify(sortJson(value, options));
|
|
3
|
+
}
|
|
4
|
+
function sortJson(value, options) {
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
return value.map((entry) => sortJson(entry, options));
|
|
7
|
+
if (!isRecord(value))
|
|
8
|
+
return value;
|
|
9
|
+
const normalizedEntries = Object.entries(value).map(([key, child]) => [normalizeKey(key), child]);
|
|
10
|
+
ensureUniqueKeys(normalizedEntries.map(([key]) => key));
|
|
11
|
+
const entries = normalizedEntries
|
|
12
|
+
.sort(([left], [right]) => compareKeys(left, right))
|
|
13
|
+
.map(([key, child]) => [key, sortJson(child, options)])
|
|
14
|
+
.filter(([, child]) => !options.pruneEmptyObjects || !isRecord(child) || Object.keys(child).length > 0);
|
|
15
|
+
return Object.fromEntries(entries);
|
|
16
|
+
}
|
|
17
|
+
function normalizeKey(key) {
|
|
18
|
+
return key.normalize("NFC");
|
|
19
|
+
}
|
|
20
|
+
function compareKeys(left, right) {
|
|
21
|
+
if (left < right)
|
|
22
|
+
return -1;
|
|
23
|
+
if (left > right)
|
|
24
|
+
return 1;
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
function ensureUniqueKeys(keys) {
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
if (seen.has(key)) {
|
|
31
|
+
throw new Error(`Canonical JSON object has duplicate key after NFC normalization: ${key}`);
|
|
32
|
+
}
|
|
33
|
+
seen.add(key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function isRecord(value) {
|
|
37
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
38
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { canonicalJson } from "./canonicalJson.js";
|
|
3
|
+
const TOOLPIN_CAPABILITIES_META = "dev.toolpin/capabilities";
|
|
4
|
+
const TOOLPIN_ATTESTATIONS_META = "dev.toolpin/attestations";
|
|
5
|
+
export function deriveCapabilityManifest(server, options = {}) {
|
|
6
|
+
return {
|
|
7
|
+
version: 1,
|
|
8
|
+
serverName: server.name,
|
|
9
|
+
serverVersion: server.version,
|
|
10
|
+
registrySource: server.registrySource,
|
|
11
|
+
packageTypes: [...new Set(server.packageTypes)].sort(),
|
|
12
|
+
transports: [...new Set(server.transports)].sort(),
|
|
13
|
+
remoteHosts: remoteHosts(server).sort(),
|
|
14
|
+
secrets: capabilitySecrets(server).sort((left, right) => `${left.source}:${left.name}`.localeCompare(`${right.source}:${right.name}`)),
|
|
15
|
+
generatedAt: options.generatedAt ?? new Date().toISOString(),
|
|
16
|
+
toolDescriptionHash: options.toolDescriptionHash,
|
|
17
|
+
toolManifestHash: options.toolManifestHash,
|
|
18
|
+
toolDescriptionScan: options.toolDescriptionScan,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function hashToolDescriptions(tools, generatedAt = new Date().toISOString()) {
|
|
22
|
+
const normalized = tools
|
|
23
|
+
.map((tool) => ({
|
|
24
|
+
name: tool.name,
|
|
25
|
+
description: tool.description ?? "",
|
|
26
|
+
}))
|
|
27
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
28
|
+
const value = createHash("sha256").update(canonicalJson(normalized)).digest("hex");
|
|
29
|
+
return {
|
|
30
|
+
algorithm: "sha256",
|
|
31
|
+
value,
|
|
32
|
+
toolCount: normalized.length,
|
|
33
|
+
generatedAt,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function hashToolManifests(tools, generatedAt = new Date().toISOString()) {
|
|
37
|
+
const normalized = tools
|
|
38
|
+
.map((tool) => ({
|
|
39
|
+
name: tool.name,
|
|
40
|
+
description: tool.description ?? "",
|
|
41
|
+
inputSchema: tool.inputSchema ?? {},
|
|
42
|
+
}))
|
|
43
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
44
|
+
const value = createHash("sha256").update(canonicalJson(normalized)).digest("hex");
|
|
45
|
+
return {
|
|
46
|
+
algorithm: "sha256",
|
|
47
|
+
value,
|
|
48
|
+
toolCount: normalized.length,
|
|
49
|
+
generatedAt,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function readCapabilityManifest(server) {
|
|
53
|
+
const value = server.raw._meta?.[TOOLPIN_CAPABILITIES_META] ?? server.registryMeta?.[TOOLPIN_CAPABILITIES_META];
|
|
54
|
+
return isCapabilityManifest(value) ? value : undefined;
|
|
55
|
+
}
|
|
56
|
+
export function readAttestations(server) {
|
|
57
|
+
const value = server.raw._meta?.[TOOLPIN_ATTESTATIONS_META] ?? server.registryMeta?.[TOOLPIN_ATTESTATIONS_META];
|
|
58
|
+
return Array.isArray(value) ? value.filter(isAttestation) : [];
|
|
59
|
+
}
|
|
60
|
+
export function attestationBadge(attestation) {
|
|
61
|
+
return `${attestation.type}-declared`;
|
|
62
|
+
}
|
|
63
|
+
function remoteHosts(server) {
|
|
64
|
+
const hosts = [];
|
|
65
|
+
for (const remote of server.raw.remotes ?? []) {
|
|
66
|
+
try {
|
|
67
|
+
hosts.push(new URL(remote.url).host);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Invalid URLs are reported by trust/verification; they cannot produce an egress host.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...new Set(hosts)];
|
|
74
|
+
}
|
|
75
|
+
function capabilitySecrets(server) {
|
|
76
|
+
const secrets = [];
|
|
77
|
+
for (const pkg of server.raw.packages ?? []) {
|
|
78
|
+
for (const variable of pkg.environmentVariables ?? []) {
|
|
79
|
+
if (variable.isSecret || variable.isRequired) {
|
|
80
|
+
secrets.push({
|
|
81
|
+
name: variable.name,
|
|
82
|
+
source: "env",
|
|
83
|
+
required: variable.isRequired === true,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const remote of server.raw.remotes ?? []) {
|
|
89
|
+
for (const header of remote.headers ?? []) {
|
|
90
|
+
if (header.isSecret || header.isRequired) {
|
|
91
|
+
secrets.push({
|
|
92
|
+
name: header.name,
|
|
93
|
+
source: "header",
|
|
94
|
+
required: header.isRequired === true,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return secrets;
|
|
100
|
+
}
|
|
101
|
+
export function isCapabilityManifest(value) {
|
|
102
|
+
if (!isRecord(value))
|
|
103
|
+
return false;
|
|
104
|
+
return (value.version === 1 &&
|
|
105
|
+
typeof value.serverName === "string" &&
|
|
106
|
+
typeof value.serverVersion === "string" &&
|
|
107
|
+
typeof value.registrySource === "string" &&
|
|
108
|
+
Array.isArray(value.packageTypes) &&
|
|
109
|
+
Array.isArray(value.transports) &&
|
|
110
|
+
Array.isArray(value.remoteHosts) &&
|
|
111
|
+
Array.isArray(value.secrets) &&
|
|
112
|
+
typeof value.generatedAt === "string" &&
|
|
113
|
+
(value.toolDescriptionHash === undefined || isToolDescriptionHash(value.toolDescriptionHash)) &&
|
|
114
|
+
(value.toolManifestHash === undefined || isToolManifestHash(value.toolManifestHash)) &&
|
|
115
|
+
(value.toolDescriptionScan === undefined || isToolDescriptionScan(value.toolDescriptionScan)));
|
|
116
|
+
}
|
|
117
|
+
function isToolDescriptionHash(value) {
|
|
118
|
+
return isToolManifestHash(value);
|
|
119
|
+
}
|
|
120
|
+
function isToolManifestHash(value) {
|
|
121
|
+
return (isRecord(value) &&
|
|
122
|
+
value.algorithm === "sha256" &&
|
|
123
|
+
typeof value.value === "string" &&
|
|
124
|
+
typeof value.toolCount === "number" &&
|
|
125
|
+
typeof value.generatedAt === "string");
|
|
126
|
+
}
|
|
127
|
+
function isToolDescriptionScan(value) {
|
|
128
|
+
return (isRecord(value) &&
|
|
129
|
+
value.version === 1 &&
|
|
130
|
+
typeof value.generatedAt === "string" &&
|
|
131
|
+
typeof value.scannedDescriptions === "number" &&
|
|
132
|
+
Array.isArray(value.findings));
|
|
133
|
+
}
|
|
134
|
+
function isAttestation(value) {
|
|
135
|
+
return isRecord(value) && typeof value.type === "string";
|
|
136
|
+
}
|
|
137
|
+
function isRecord(value) {
|
|
138
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
139
|
+
}
|
package/dist/ci.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readLockfile, verifyAgainstLockfile } from "./plan.js";
|
|
2
|
+
export async function verifyFrozenInstall(lockfilePath, resolveCurrentPlan) {
|
|
3
|
+
const lockfile = await readLockfile(lockfilePath);
|
|
4
|
+
const entries = Object.entries(lockfile.servers);
|
|
5
|
+
const issues = [];
|
|
6
|
+
for (const [key, locked] of entries) {
|
|
7
|
+
try {
|
|
8
|
+
const current = await resolveCurrentPlan(locked, key);
|
|
9
|
+
const verification = await verifyAgainstLockfile(current, lockfilePath);
|
|
10
|
+
if (!verification.ok) {
|
|
11
|
+
issues.push({ key, messages: verification.messages });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
issues.push({ key, messages: [error instanceof Error ? error.message : String(error)] });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (entries.length === 0) {
|
|
19
|
+
issues.push({ key: lockfilePath, messages: ["lockfile has no server entries"] });
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
ok: issues.length === 0,
|
|
23
|
+
checked: entries.length,
|
|
24
|
+
issues,
|
|
25
|
+
};
|
|
26
|
+
}
|