@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
@@ -0,0 +1,80 @@
1
+ import net from "node:net";
2
+ import { readInstalledServerConfig } from "./doctor.js";
3
+ import { resolveConfigTarget } from "./install.js";
4
+ export async function localHttpRuntimeAdvisory(serverName, client, scope) {
5
+ const target = resolveConfigTarget(client, scope);
6
+ const installed = await readInstalledServerConfig(target.file, serverName, client);
7
+ if (installed.kind !== "ok")
8
+ return undefined;
9
+ const endpoint = localHttpEndpoint(installed.config);
10
+ if (!endpoint)
11
+ return undefined;
12
+ const running = await isTcpPortOpen(endpoint.host, endpoint.port);
13
+ return {
14
+ ...endpoint,
15
+ running,
16
+ message: running
17
+ ? `local HTTP endpoint ${endpoint.url} is accepting connections; delete removes config/lock only and does not stop that process.`
18
+ : `local HTTP endpoint ${endpoint.url} is configured, but port ${endpoint.port} is not accepting connections right now.`,
19
+ };
20
+ }
21
+ export function localHttpEndpoint(config) {
22
+ const record = asRecord(config);
23
+ const raw = firstString(record.url, record.httpUrl, record.serverUrl);
24
+ if (!raw)
25
+ return undefined;
26
+ let url;
27
+ try {
28
+ url = new URL(raw);
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ if (url.protocol !== "http:" && url.protocol !== "https:")
34
+ return undefined;
35
+ if (!url.port)
36
+ return undefined;
37
+ const host = normalizeHost(url.hostname);
38
+ if (!isLoopbackHost(host))
39
+ return undefined;
40
+ const port = Number.parseInt(url.port, 10);
41
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
42
+ return undefined;
43
+ return { url: raw, host, port };
44
+ }
45
+ async function isTcpPortOpen(host, port, timeoutMs = 200) {
46
+ return new Promise((resolve) => {
47
+ const socket = net.createConnection({ host: connectHost(host), port });
48
+ let settled = false;
49
+ const finish = (open) => {
50
+ if (settled)
51
+ return;
52
+ settled = true;
53
+ socket.destroy();
54
+ resolve(open);
55
+ };
56
+ socket.setTimeout(timeoutMs);
57
+ socket.once("connect", () => finish(true));
58
+ socket.once("timeout", () => finish(false));
59
+ socket.once("error", () => finish(false));
60
+ });
61
+ }
62
+ function isLoopbackHost(host) {
63
+ if (host === "localhost" || host === "::1" || host === "0.0.0.0")
64
+ return true;
65
+ if (/^127(?:\.\d{1,3}){3}$/.test(host))
66
+ return true;
67
+ return false;
68
+ }
69
+ function connectHost(host) {
70
+ return host === "0.0.0.0" ? "127.0.0.1" : host;
71
+ }
72
+ function normalizeHost(host) {
73
+ return host.toLowerCase().replace(/^\[|\]$/g, "");
74
+ }
75
+ function firstString(...values) {
76
+ return values.find((value) => typeof value === "string" && value.length > 0);
77
+ }
78
+ function asRecord(value) {
79
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
80
+ }
@@ -0,0 +1,157 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+ const DEFAULT_TIMEOUT_MS = 15000;
4
+ const DEFAULT_MAX_BYTES = 1024 * 1024;
5
+ export async function safeFetch(input, options = {}) {
6
+ const url = new URL(input);
7
+ await assertSafeUrl(url, options.allowedHosts, options.lookup);
8
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, maxBytes: _maxBytes, allowedHosts: _allowedHosts, fetch: fetchImpl = fetch, lookup: _lookup, ...fetchOptions } = options;
9
+ return fetchImpl(url, {
10
+ ...fetchOptions,
11
+ redirect: "error",
12
+ signal: fetchOptions.signal ?? AbortSignal.timeout(timeoutMs),
13
+ });
14
+ }
15
+ export async function safeFetchBuffer(input, options = {}) {
16
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
17
+ const response = await safeFetch(input, options);
18
+ if (!response.ok)
19
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
20
+ return readResponseCapped(response, maxBytes);
21
+ }
22
+ export async function safeFetchJson(input, options = {}) {
23
+ const bytes = await safeFetchBuffer(input, {
24
+ ...options,
25
+ headers: {
26
+ Accept: "application/json",
27
+ ...(options.headers ?? {}),
28
+ },
29
+ });
30
+ return JSON.parse(bytes.toString("utf8"));
31
+ }
32
+ export async function assertSafeUrl(url, allowedHosts, lookupImpl = lookup) {
33
+ if (url.protocol !== "https:")
34
+ throw new Error(`Refusing non-HTTPS URL: ${url.href}`);
35
+ const hostname = normalizeHostname(url.hostname);
36
+ if (allowedHosts && !allowedHosts.has(hostname)) {
37
+ throw new Error(`Refusing untrusted host ${hostname}`);
38
+ }
39
+ await assertPublicHostname(hostname, lookupImpl);
40
+ }
41
+ async function assertPublicHostname(hostname, lookupImpl) {
42
+ if (isIP(hostname)) {
43
+ if (isBlockedIp(hostname))
44
+ throw new Error(`Refusing private or reserved IP address ${hostname}`);
45
+ return;
46
+ }
47
+ const addresses = await lookupImpl(hostname, { all: true, verbatim: true });
48
+ for (const address of addresses) {
49
+ if (isBlockedIp(address.address)) {
50
+ throw new Error(`Refusing private or reserved IP address ${address.address} for ${hostname}`);
51
+ }
52
+ }
53
+ }
54
+ async function readResponseCapped(response, maxBytes) {
55
+ const reader = response.body?.getReader();
56
+ if (!reader)
57
+ return Buffer.from(await response.arrayBuffer());
58
+ const chunks = [];
59
+ let total = 0;
60
+ for (;;) {
61
+ const { done, value } = await reader.read();
62
+ if (done)
63
+ break;
64
+ if (!value)
65
+ continue;
66
+ total += value.byteLength;
67
+ if (total > maxBytes) {
68
+ await reader.cancel();
69
+ throw new Error(`Response exceeded ${maxBytes} byte limit`);
70
+ }
71
+ chunks.push(value);
72
+ }
73
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
74
+ }
75
+ function normalizeHostname(hostname) {
76
+ return hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
77
+ }
78
+ function isBlockedIp(address) {
79
+ const normalized = normalizeHostname(address);
80
+ const family = isIP(normalized);
81
+ if (family === 4)
82
+ return isBlockedIpv4(normalized);
83
+ if (family === 6)
84
+ return isBlockedIpv6(normalized);
85
+ return true;
86
+ }
87
+ function isBlockedIpv4(address) {
88
+ const parts = address.split(".").map((part) => Number.parseInt(part, 10));
89
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
90
+ return true;
91
+ const [a, b] = parts;
92
+ return a === 0
93
+ || a === 10
94
+ || a === 127
95
+ || (a === 169 && b === 254)
96
+ || (a === 172 && b >= 16 && b <= 31)
97
+ || (a === 192 && b === 168)
98
+ || (a === 100 && b >= 64 && b <= 127)
99
+ || (a === 198 && (b === 18 || b === 19))
100
+ || a >= 224;
101
+ }
102
+ function isBlockedIpv6(address) {
103
+ const lower = address.toLowerCase();
104
+ const mappedIpv4 = lower.match(/(?:^|:)ffff:(\d+\.\d+\.\d+\.\d+)$/);
105
+ if (mappedIpv4)
106
+ return isBlockedIpv4(mappedIpv4[1]);
107
+ const groups = parseIpv6Groups(lower);
108
+ if (groups) {
109
+ const embeddedIpv4 = ipv4FromEmbeddedIpv6(groups);
110
+ if (embeddedIpv4 && isBlockedIpv4(embeddedIpv4))
111
+ return true;
112
+ }
113
+ return lower === "::"
114
+ || lower === "::1"
115
+ || lower.startsWith("fc")
116
+ || lower.startsWith("fd")
117
+ || lower.startsWith("fe8")
118
+ || lower.startsWith("fe9")
119
+ || lower.startsWith("fea")
120
+ || lower.startsWith("feb")
121
+ || lower.startsWith("ff");
122
+ }
123
+ function parseIpv6Groups(address) {
124
+ const withoutZone = address.split("%", 1)[0];
125
+ const [headRaw, tailRaw, extra] = withoutZone.split("::");
126
+ if (extra !== undefined)
127
+ return undefined;
128
+ const head = headRaw ? headRaw.split(":") : [];
129
+ const tail = tailRaw ? tailRaw.split(":") : [];
130
+ if (!withoutZone.includes("::") && head.length !== 8)
131
+ return undefined;
132
+ const missing = 8 - head.length - tail.length;
133
+ if (missing < 0)
134
+ return undefined;
135
+ const groups = [...head, ...Array.from({ length: missing }, () => "0"), ...tail];
136
+ if (groups.length !== 8)
137
+ return undefined;
138
+ const parsed = groups.map((part) => Number.parseInt(part, 16));
139
+ if (parsed.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff))
140
+ return undefined;
141
+ return parsed;
142
+ }
143
+ function ipv4FromEmbeddedIpv6(groups) {
144
+ const firstFiveZero = groups.slice(0, 5).every((group) => group === 0);
145
+ const compatible = firstFiveZero && groups[5] === 0;
146
+ const mapped = firstFiveZero && groups[5] === 0xffff;
147
+ if (!compatible && !mapped)
148
+ return undefined;
149
+ const high = groups[6];
150
+ const low = groups[7];
151
+ return [
152
+ (high >> 8) & 0xff,
153
+ high & 0xff,
154
+ (low >> 8) & 0xff,
155
+ low & 0xff,
156
+ ].join(".");
157
+ }
package/dist/sarif.js ADDED
@@ -0,0 +1,162 @@
1
+ import { createHash } from "node:crypto";
2
+ const SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json";
3
+ const HELP_URI = "https://github.com/proofofwork-agency/toolpin#security-model";
4
+ const RULES = [
5
+ rule("agent_instruction_override", "Agent instruction override", "Registry or tool metadata asks the agent to ignore higher-priority instructions.", "scan", "warning"),
6
+ rule("agent_hidden_behavior", "Hidden agent behavior", "Registry or tool metadata asks the agent to hide behavior from the user.", "scan", "warning"),
7
+ rule("agent_forced_tool_order", "Forced tool order", "Registry or tool metadata tries to force tool invocation order.", "scan", "note"),
8
+ rule("hidden_control_characters", "Hidden control characters", "Registry or tool metadata contains hidden or control characters.", "scan", "warning"),
9
+ rule("duplicate_tool_name", "Duplicate tool name", "A live tools/list response contains duplicate tool names.", "scan", "warning"),
10
+ rule("cross_tool_instruction", "Cross-tool instruction", "A tool description instructs the agent to use a sibling tool.", "scan", "note"),
11
+ rule("no_install_target", "No install target", "The server has no installable package or remote target.", "verify", "error"),
12
+ rule("mutable_oci_tag", "Mutable OCI tag", "An OCI package is not pinned by digest.", "verify", "error"),
13
+ rule("missing_mcpb_hash", "Missing MCPB hash", "An MCPB package is missing fileSha256.", "verify", "error"),
14
+ rule("package_probe_failed", "Package probe failed", "Live package capability verification failed.", "verify", "error"),
15
+ rule("remote_probe_failed", "Remote probe failed", "Live remote capability verification failed.", "verify", "error"),
16
+ rule("remote_probe_skipped", "Remote probe skipped", "Live remote capability verification was skipped.", "verify", "warning"),
17
+ rule("ci_lock_drift", "CI lock drift", "Frozen install verification found lockfile drift or resolver failures.", "ci", "error"),
18
+ rule("ci_digest_mismatch", "CI digest mismatch", "The lockfile digest does not match the expected digest.", "ci", "error"),
19
+ rule("ci_signature_failed", "CI signature failure", "The detached lockfile signature check failed.", "ci", "error"),
20
+ ];
21
+ export function sarifLog(results, options = {}) {
22
+ return {
23
+ version: "2.1.0",
24
+ $schema: SARIF_SCHEMA,
25
+ runs: [
26
+ {
27
+ tool: {
28
+ driver: {
29
+ name: "ToolPin",
30
+ informationUri: "https://github.com/proofofwork-agency/toolpin",
31
+ rules: rulesForResults(results),
32
+ },
33
+ },
34
+ invocations: [
35
+ {
36
+ executionSuccessful: options.executionSuccessful ?? !results.some((result) => result.level === "error"),
37
+ startTimeUtc: options.generatedAt ?? new Date().toISOString(),
38
+ },
39
+ ],
40
+ results,
41
+ },
42
+ ],
43
+ };
44
+ }
45
+ export function scanSarifResults(scans) {
46
+ return scans.flatMap((scan) => scan.findings.map((finding) => resultFromScanFinding(finding)));
47
+ }
48
+ export function verificationSarifResults(report) {
49
+ const normalized = new Map();
50
+ for (const issue of report.issues) {
51
+ const result = resultFromTrustIssue(issue, `server:${report.serverName}`);
52
+ normalized.set(dedupeKey(result), result);
53
+ }
54
+ const embeddedScan = report.capabilityManifest.toolDescriptionScan;
55
+ if (embeddedScan) {
56
+ for (const result of scanSarifResults([embeddedScan])) {
57
+ normalized.set(dedupeKey(result), result);
58
+ }
59
+ }
60
+ return [...normalized.values()];
61
+ }
62
+ export function ciSarifResults(report, lockfilePath) {
63
+ return report.issues.flatMap((issue) => issue.messages.map((message) => ciSarifResult("ci_lock_drift", message, lockfilePath, issue.key)));
64
+ }
65
+ export function ciSarifResult(code, message, lockfilePath, subject = lockfilePath) {
66
+ return {
67
+ ruleId: code,
68
+ level: "error",
69
+ message: { text: message },
70
+ locations: [
71
+ {
72
+ physicalLocation: {
73
+ artifactLocation: { uri: lockfilePath },
74
+ },
75
+ logicalLocations: [
76
+ {
77
+ name: subject,
78
+ fullyQualifiedName: subject,
79
+ kind: "lockfileEntry",
80
+ },
81
+ ],
82
+ },
83
+ ],
84
+ partialFingerprints: fingerprintParts(code, subject, message),
85
+ };
86
+ }
87
+ function resultFromScanFinding(finding) {
88
+ return {
89
+ ruleId: finding.code,
90
+ level: levelForSeverity(finding.severity),
91
+ message: { text: finding.message },
92
+ locations: [logicalLocation(finding.subject)],
93
+ partialFingerprints: fingerprintParts(finding.code, finding.subject, finding.message),
94
+ };
95
+ }
96
+ function resultFromTrustIssue(issue, fallbackSubject) {
97
+ const { subject, message } = splitSubject(issue.message, fallbackSubject);
98
+ return {
99
+ ruleId: issue.code,
100
+ level: levelForSeverity(issue.severity),
101
+ message: { text: message },
102
+ locations: [logicalLocation(subject)],
103
+ partialFingerprints: fingerprintParts(issue.code, subject, message),
104
+ };
105
+ }
106
+ function splitSubject(message, fallbackSubject) {
107
+ const match = /^(server|tool):([^:]+):\s+(.+)$/.exec(message);
108
+ if (!match)
109
+ return { subject: fallbackSubject, message };
110
+ return { subject: `${match[1]}:${match[2]}`, message: match[3] ?? message };
111
+ }
112
+ function logicalLocation(subject) {
113
+ const [kind, ...nameParts] = subject.split(":");
114
+ const name = nameParts.join(":") || subject;
115
+ return {
116
+ logicalLocations: [
117
+ {
118
+ name,
119
+ fullyQualifiedName: subject,
120
+ kind: kind || "server",
121
+ },
122
+ ],
123
+ };
124
+ }
125
+ function fingerprintParts(code, subject, message) {
126
+ return {
127
+ toolpinFindingId: createHash("sha256").update(`${code}\0${subject}\0${message}`).digest("hex"),
128
+ };
129
+ }
130
+ function dedupeKey(result) {
131
+ const subject = result.locations[0]?.logicalLocations?.[0]?.fullyQualifiedName ?? "";
132
+ return `${result.ruleId}\0${subject}\0${result.message.text}`;
133
+ }
134
+ function rulesForResults(results) {
135
+ const ids = new Set(results.map((result) => result.ruleId));
136
+ const selected = [...RULES];
137
+ for (const id of ids) {
138
+ if (!RULES.some((entry) => entry.id === id)) {
139
+ selected.push(rule(id, id, "ToolPin finding.", "verify", "warning"));
140
+ }
141
+ }
142
+ return selected;
143
+ }
144
+ function levelForSeverity(severity) {
145
+ if (severity === "critical")
146
+ return "error";
147
+ if (severity === "warning")
148
+ return "warning";
149
+ return "note";
150
+ }
151
+ function rule(id, name, description, category, severity) {
152
+ return {
153
+ id,
154
+ name,
155
+ shortDescription: { text: description },
156
+ helpUri: HELP_URI,
157
+ properties: {
158
+ category,
159
+ "problem.severity": severity,
160
+ },
161
+ };
162
+ }
package/dist/scan.js ADDED
@@ -0,0 +1,113 @@
1
+ const AGENT_DIRECTED_PATTERNS = [
2
+ {
3
+ code: "agent_instruction_override",
4
+ severity: "warning",
5
+ pattern: /\b(ignore|disregard|override)\s+(all\s+)?(previous|prior|above|system|developer)\s+instructions?\b/i,
6
+ label: "asks the agent to ignore higher-priority instructions",
7
+ },
8
+ {
9
+ code: "agent_hidden_behavior",
10
+ severity: "warning",
11
+ pattern: /\b(do\s+not|don't|never)\s+(tell|inform|notify|mention|reveal)\s+(the\s+)?user\b/i,
12
+ label: "asks the agent to hide behavior from the user",
13
+ },
14
+ {
15
+ code: "agent_forced_tool_order",
16
+ severity: "info",
17
+ pattern: /\b(always|must|required to)\s+(call|use|invoke|run)\s+[a-zA-Z0-9_.:/-]+(\s+first)?\b/i,
18
+ label: "forces tool ordering in descriptive metadata",
19
+ },
20
+ ];
21
+ const HIDDEN_CHARACTER_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u061C\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u;
22
+ export function scanServerMetadata(server, generatedAt = new Date().toISOString()) {
23
+ return scanToolDescriptions([{ name: server.name, description: server.description }], {
24
+ generatedAt,
25
+ subjectPrefix: "server",
26
+ });
27
+ }
28
+ export function scanToolDescriptions(tools, options = {}) {
29
+ const entries = tools.map((tool) => ({
30
+ subject: `${options.subjectPrefix ?? "tool"}:${tool.name}`,
31
+ text: `${tool.name}\n${tool.description ?? ""}`,
32
+ }));
33
+ const findings = [];
34
+ for (const entry of entries) {
35
+ findings.push(...scanEntry(entry));
36
+ }
37
+ findings.push(...duplicateToolFindings(tools, options.subjectPrefix ?? "tool"));
38
+ findings.push(...toolReferenceFindings(tools, options.subjectPrefix ?? "tool"));
39
+ return {
40
+ version: 1,
41
+ generatedAt: options.generatedAt ?? new Date().toISOString(),
42
+ scannedDescriptions: tools.length,
43
+ findings: findings.sort((left, right) => `${left.subject}:${left.code}:${left.message}`.localeCompare(`${right.subject}:${right.code}:${right.message}`)),
44
+ };
45
+ }
46
+ export function scanFindingsToTrustIssues(scan) {
47
+ return scan.findings.map((finding) => ({
48
+ severity: finding.severity,
49
+ code: finding.code,
50
+ message: `${finding.subject}: ${finding.message}`,
51
+ }));
52
+ }
53
+ function scanEntry(entry) {
54
+ const findings = [];
55
+ if (HIDDEN_CHARACTER_PATTERN.test(entry.text)) {
56
+ findings.push({
57
+ severity: "warning",
58
+ code: "hidden_control_characters",
59
+ subject: entry.subject,
60
+ message: "description contains hidden or control characters",
61
+ });
62
+ }
63
+ for (const detector of AGENT_DIRECTED_PATTERNS) {
64
+ if (detector.pattern.test(entry.text)) {
65
+ findings.push({
66
+ severity: detector.severity,
67
+ code: detector.code,
68
+ subject: entry.subject,
69
+ message: detector.label,
70
+ });
71
+ }
72
+ }
73
+ return findings;
74
+ }
75
+ function duplicateToolFindings(tools, subjectPrefix) {
76
+ const counts = new Map();
77
+ for (const tool of tools) {
78
+ counts.set(tool.name, (counts.get(tool.name) ?? 0) + 1);
79
+ }
80
+ return [...counts.entries()]
81
+ .filter(([, count]) => count > 1)
82
+ .map(([name, count]) => ({
83
+ severity: "warning",
84
+ code: "duplicate_tool_name",
85
+ subject: `${subjectPrefix}:${name}`,
86
+ message: `tool name appears ${count} times in this server's tools/list response`,
87
+ }));
88
+ }
89
+ function toolReferenceFindings(tools, subjectPrefix) {
90
+ const names = new Set(tools.map((tool) => tool.name));
91
+ const findings = [];
92
+ for (const tool of tools) {
93
+ const description = tool.description ?? "";
94
+ for (const name of names) {
95
+ if (name === tool.name)
96
+ continue;
97
+ const escapedName = escapeRegExp(name);
98
+ const pattern = new RegExp(`\\b(always\\s+)?(call|use|invoke|run)\\s+${escapedName}\\b`, "i");
99
+ if (pattern.test(description)) {
100
+ findings.push({
101
+ severity: "info",
102
+ code: "cross_tool_instruction",
103
+ subject: `${subjectPrefix}:${tool.name}`,
104
+ message: `description instructs the agent to use sibling tool ${name}`,
105
+ });
106
+ }
107
+ }
108
+ }
109
+ return findings;
110
+ }
111
+ function escapeRegExp(value) {
112
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
113
+ }
package/dist/search.js ADDED
@@ -0,0 +1,44 @@
1
+ import { scoreServer, trustRankingScore } from "./trust.js";
2
+ export function searchServers(servers, query, limit = 10) {
3
+ const terms = tokenize(query);
4
+ const knownSources = new Set(servers.map((server) => server.registrySource));
5
+ const sourceTerms = new Set(terms.filter((term) => knownSources.has(term)));
6
+ const textTerms = terms.filter((term) => !sourceTerms.has(term));
7
+ const scoringTerms = textTerms.length ? textTerms : terms;
8
+ return servers
9
+ .filter((server) => !sourceTerms.size || sourceTerms.has(server.registrySource))
10
+ .map((server) => {
11
+ const relevance = scoreRelevance(server, scoringTerms);
12
+ return { server, relevance, trust: scoreServer(server) };
13
+ })
14
+ .filter((result) => result.relevance > 0)
15
+ .sort((a, b) => b.relevance + trustSortScore(b) / 100 - (a.relevance + trustSortScore(a) / 100))
16
+ .slice(0, limit);
17
+ }
18
+ function trustSortScore(result) {
19
+ return trustRankingScore(result.trust);
20
+ }
21
+ function scoreRelevance(server, terms) {
22
+ const haystacks = [
23
+ { value: server.name, weight: 8 },
24
+ { value: server.title, weight: 6 },
25
+ { value: server.description, weight: 3 },
26
+ { value: server.registrySource, weight: 5 },
27
+ { value: server.packageTypes.join(" "), weight: 2 },
28
+ { value: server.transports.join(" "), weight: 2 },
29
+ { value: server.repositoryUrl ?? "", weight: 1 },
30
+ ];
31
+ return terms.reduce((score, term) => {
32
+ const termScore = haystacks.reduce((inner, haystack) => {
33
+ return inner + (haystack.value.toLowerCase().includes(term) ? haystack.weight : 0);
34
+ }, 0);
35
+ return score + termScore;
36
+ }, 0);
37
+ }
38
+ function tokenize(query) {
39
+ return query
40
+ .toLowerCase()
41
+ .split(/\s+/)
42
+ .map((term) => term.trim())
43
+ .filter(Boolean);
44
+ }