@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.
Files changed (61) hide show
  1. package/CONTRIBUTING.md +117 -0
  2. package/LICENSE +183 -0
  3. package/README.md +323 -0
  4. package/SECURITY.md +61 -0
  5. package/action.yml +134 -0
  6. package/dist/canonicalJson.js +38 -0
  7. package/dist/capabilities.js +139 -0
  8. package/dist/ci.js +26 -0
  9. package/dist/cli.js +1843 -0
  10. package/dist/clientSupport.js +76 -0
  11. package/dist/codexToml.js +213 -0
  12. package/dist/config.js +337 -0
  13. package/dist/constants.js +3 -0
  14. package/dist/continueYaml.js +76 -0
  15. package/dist/doctor.js +163 -0
  16. package/dist/install.js +191 -0
  17. package/dist/installed.js +405 -0
  18. package/dist/integrity.js +14 -0
  19. package/dist/inventory.js +169 -0
  20. package/dist/packageIntegrity.js +153 -0
  21. package/dist/plan.js +595 -0
  22. package/dist/policy.js +310 -0
  23. package/dist/registry.js +1610 -0
  24. package/dist/runtimeAdvisory.js +80 -0
  25. package/dist/safeFetch.js +157 -0
  26. package/dist/sarif.js +162 -0
  27. package/dist/scan.js +113 -0
  28. package/dist/search.js +44 -0
  29. package/dist/secrets.js +165 -0
  30. package/dist/signing.js +146 -0
  31. package/dist/tester.js +240 -0
  32. package/dist/trust.js +528 -0
  33. package/dist/tui/app.js +1731 -0
  34. package/dist/tui/command.js +50 -0
  35. package/dist/tui/configSnippet.js +11 -0
  36. package/dist/tui/constants.js +37 -0
  37. package/dist/tui/format.js +31 -0
  38. package/dist/tui/installedState.js +23 -0
  39. package/dist/tui/layout.js +65 -0
  40. package/dist/tui/selectors.js +282 -0
  41. package/dist/tui/types.js +1 -0
  42. package/dist/tui/ui/trust.js +77 -0
  43. package/dist/tui/views/installed.js +82 -0
  44. package/dist/tui/views/panels.js +637 -0
  45. package/dist/tui.js +12 -0
  46. package/dist/types.js +1 -0
  47. package/dist/verificationTrust.js +103 -0
  48. package/dist/verify.js +537 -0
  49. package/dist/version.js +1 -0
  50. package/dist/versions.js +127 -0
  51. package/docs/assets/readme/terminal-demo.svg +174 -0
  52. package/docs/assets/readme/tui-browse-overview.jpg +0 -0
  53. package/docs/assets/readme/tui-config-preview.jpg +0 -0
  54. package/docs/assets/readme/tui-help.jpg +0 -0
  55. package/docs/assets/readme/tui-installed-inventory.jpg +0 -0
  56. package/docs/how-to/catch-drift-in-ci.md +189 -0
  57. package/docs/how-to/custom-registries.md +156 -0
  58. package/docs/how-to/toolpin-curated-registry.md +153 -0
  59. package/package.json +76 -0
  60. package/registry/README.md +92 -0
  61. 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
+ }