toolmoat 0.0.0-stage → 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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +302 -2
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +80 -0
  5. package/dist/clients/configs.d.ts +124 -0
  6. package/dist/clients/configs.js +403 -0
  7. package/dist/commands/approve.d.ts +15 -0
  8. package/dist/commands/approve.js +128 -0
  9. package/dist/commands/diff.d.ts +6 -0
  10. package/dist/commands/diff.js +114 -0
  11. package/dist/commands/pins.d.ts +18 -0
  12. package/dist/commands/pins.js +88 -0
  13. package/dist/commands/proxy.d.ts +24 -0
  14. package/dist/commands/proxy.js +144 -0
  15. package/dist/commands/rules.d.ts +15 -0
  16. package/dist/commands/rules.js +173 -0
  17. package/dist/commands/scan.d.ts +57 -0
  18. package/dist/commands/scan.js +323 -0
  19. package/dist/commands/unwrap.d.ts +5 -0
  20. package/dist/commands/unwrap.js +69 -0
  21. package/dist/commands/wrap.d.ts +131 -0
  22. package/dist/commands/wrap.js +518 -0
  23. package/dist/index.d.ts +20 -0
  24. package/dist/index.js +13 -0
  25. package/dist/patterns/indicators.d.ts +41 -0
  26. package/dist/patterns/indicators.js +85 -0
  27. package/dist/patterns/secrets.d.ts +20 -0
  28. package/dist/patterns/secrets.js +70 -0
  29. package/dist/proxy/audit.d.ts +53 -0
  30. package/dist/proxy/audit.js +79 -0
  31. package/dist/proxy/canonical.d.ts +13 -0
  32. package/dist/proxy/canonical.js +34 -0
  33. package/dist/proxy/diff.d.ts +22 -0
  34. package/dist/proxy/diff.js +124 -0
  35. package/dist/proxy/fingerprint.d.ts +34 -0
  36. package/dist/proxy/fingerprint.js +140 -0
  37. package/dist/proxy/firewall.d.ts +132 -0
  38. package/dist/proxy/firewall.js +665 -0
  39. package/dist/proxy/framing.d.ts +45 -0
  40. package/dist/proxy/framing.js +86 -0
  41. package/dist/proxy/lockfile.d.ts +58 -0
  42. package/dist/proxy/lockfile.js +258 -0
  43. package/dist/proxy/names.d.ts +27 -0
  44. package/dist/proxy/names.js +175 -0
  45. package/dist/proxy/policy.d.ts +72 -0
  46. package/dist/proxy/policy.js +86 -0
  47. package/dist/proxy/proxy.d.ts +18 -0
  48. package/dist/proxy/proxy.js +237 -0
  49. package/dist/proxy/spawn.d.ts +51 -0
  50. package/dist/proxy/spawn.js +110 -0
  51. package/dist/report/index.d.ts +35 -0
  52. package/dist/report/index.js +25 -0
  53. package/dist/report/json.d.ts +22 -0
  54. package/dist/report/json.js +40 -0
  55. package/dist/report/markdown.d.ts +12 -0
  56. package/dist/report/markdown.js +87 -0
  57. package/dist/report/sarif.d.ts +89 -0
  58. package/dist/report/sarif.js +142 -0
  59. package/dist/report/text.d.ts +16 -0
  60. package/dist/report/text.js +206 -0
  61. package/dist/rules/code.d.ts +15 -0
  62. package/dist/rules/code.js +729 -0
  63. package/dist/rules/config.d.ts +3 -0
  64. package/dist/rules/config.js +2164 -0
  65. package/dist/rules/index.d.ts +3 -0
  66. package/dist/rules/index.js +8 -0
  67. package/dist/rules/secrets.d.ts +2 -0
  68. package/dist/rules/secrets.js +108 -0
  69. package/dist/rules/text.d.ts +2 -0
  70. package/dist/rules/text.js +1698 -0
  71. package/dist/scan/discover.d.ts +37 -0
  72. package/dist/scan/discover.js +525 -0
  73. package/dist/scan/engine.d.ts +24 -0
  74. package/dist/scan/engine.js +111 -0
  75. package/dist/scan/ignore.d.ts +28 -0
  76. package/dist/scan/ignore.js +46 -0
  77. package/dist/types.d.ts +98 -0
  78. package/dist/types.js +25 -0
  79. package/dist/util/colors.d.ts +12 -0
  80. package/dist/util/colors.js +24 -0
  81. package/dist/util/frontmatter.d.ts +20 -0
  82. package/dist/util/frontmatter.js +136 -0
  83. package/dist/util/glob.d.ts +7 -0
  84. package/dist/util/glob.js +44 -0
  85. package/dist/util/hash.d.ts +3 -0
  86. package/dist/util/hash.js +24 -0
  87. package/dist/util/jsonc.d.ts +14 -0
  88. package/dist/util/jsonc.js +113 -0
  89. package/dist/util/paths.d.ts +9 -0
  90. package/dist/util/paths.js +37 -0
  91. package/dist/util/text.d.ts +29 -0
  92. package/dist/util/text.js +96 -0
  93. package/dist/util/toml-lite.d.ts +40 -0
  94. package/dist/util/toml-lite.js +507 -0
  95. package/dist/version.d.ts +1 -0
  96. package/dist/version.js +1 -0
  97. package/package.json +57 -4
@@ -0,0 +1,140 @@
1
+ import { scanFile } from '../scan/engine.js';
2
+ import { sha256 } from '../util/hash.js';
3
+ import { canonicalJson } from './canonical.js';
4
+ /** The parts of an MCP tool that shape what the model sees and does. Only these are pinned. */
5
+ export const FINGERPRINT_FIELDS = ['name', 'title', 'description', 'inputSchema', 'outputSchema', 'annotations'];
6
+ /** Picks the pinned fields of a raw tool object and normalizes key order so equal tools compare equal. */
7
+ export function toolDefinition(tool) {
8
+ const picked = {};
9
+ for (const field of FINGERPRINT_FIELDS) {
10
+ if (tool[field] !== undefined)
11
+ picked[field] = tool[field];
12
+ }
13
+ return JSON.parse(canonicalJson(picked));
14
+ }
15
+ /** sha256 of the canonical JSON of the pinned fields: stable under key reordering, sensitive to any other change. */
16
+ export function fingerprint(definition) {
17
+ return sha256(canonicalJson(definition));
18
+ }
19
+ /** Visiting more values than this means the definition cannot be read completely; see scanTool. */
20
+ const MAX_NODES = 500_000;
21
+ /** Keywords whose value is data the model reads as it is: every string below one counts. */
22
+ const VALUE_KEYS = new Set(['default', 'const', 'enum', 'examples']);
23
+ /** Schema keywords whose string is structure rather than prose. Every other string in a definition reaches the model. */
24
+ const STRUCTURAL_KEYS = new Set([
25
+ 'type', 'required', 'format', 'pattern', 'contentEncoding', 'contentMediaType',
26
+ '$schema', '$ref', '$id', '$anchor', '$dynamicRef', '$dynamicAnchor',
27
+ ]);
28
+ /** Keywords whose object value maps names to schemas, so its keys are parameter names and not keywords. */
29
+ const NAME_MAPS = new Set(['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']);
30
+ /**
31
+ * Collects every string of a tool definition that ends up in the model's
32
+ * context: name, title, description, annotation titles and, inside the input
33
+ * and output schemas, every string except structural keywords (type, format,
34
+ * pattern, $ref...): descriptions, titles, comments, vendor extensions,
35
+ * parameter names, defaults, enums and examples, at any depth. Each piece goes
36
+ * on its own paragraph.
37
+ */
38
+ export function modelVisibleText(definition) {
39
+ return collectModelText(definition).text;
40
+ }
41
+ /** Like modelVisibleText, and says when the definition was too big to read completely. Walks iteratively, so depth is no limit. */
42
+ export function collectModelText(definition) {
43
+ const parts = [];
44
+ for (const key of ['name', 'title', 'description']) {
45
+ const value = definition[key];
46
+ if (typeof value === 'string' && value)
47
+ parts.push(value);
48
+ }
49
+ let nodes = 0;
50
+ let truncated = false;
51
+ const stack = [definition.outputSchema, definition.inputSchema, definition.annotations].map((value) => ({ value, data: false, names: false }));
52
+ while (stack.length > 0) {
53
+ const { value, data, names } = stack.pop();
54
+ if (++nodes > MAX_NODES) {
55
+ truncated = true;
56
+ break;
57
+ }
58
+ if (typeof value === 'string') {
59
+ if (value)
60
+ parts.push(value);
61
+ continue;
62
+ }
63
+ if (value === null || typeof value !== 'object')
64
+ continue;
65
+ if (Array.isArray(value)) {
66
+ for (let i = value.length - 1; i >= 0; i--)
67
+ stack.push({ value: value[i], data, names: false });
68
+ continue;
69
+ }
70
+ const record = value;
71
+ const entries = Object.entries(record);
72
+ if (!names && !data && isPlainObject(record.properties)) {
73
+ const propertyNames = Object.keys(record.properties);
74
+ if (propertyNames.length > 0)
75
+ parts.push(`Parameters: ${propertyNames.join(', ')}`);
76
+ }
77
+ for (let i = entries.length - 1; i >= 0; i--) {
78
+ const [key, child] = entries[i];
79
+ if (names) {
80
+ stack.push({ value: child, data: false, names: false });
81
+ }
82
+ else if (typeof child === 'string') {
83
+ if (data || !STRUCTURAL_KEYS.has(key))
84
+ stack.push({ value: child, data, names: false });
85
+ }
86
+ else if (Array.isArray(child) && child.every((item) => typeof item === 'string')) {
87
+ const strings = child.filter((item) => item.length > 0);
88
+ if ((data || !STRUCTURAL_KEYS.has(key)) && strings.length > 0)
89
+ parts.push(strings.join('\n'));
90
+ }
91
+ else {
92
+ stack.push({ value: child, data: data || VALUE_KEYS.has(key), names: !data && NAME_MAPS.has(key) && isPlainObject(child) });
93
+ }
94
+ }
95
+ }
96
+ return { text: parts.join('\n\n'), truncated };
97
+ }
98
+ function isPlainObject(value) {
99
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
100
+ }
101
+ /** Synthetic path under which a live tool is reported, e.g. mcp://github/create_issue. */
102
+ export function toolPath(server, tool) {
103
+ return `mcp://${server}/${tool}`;
104
+ }
105
+ /** Runs the scanner's tool-definition rules over a tool's model-visible text. */
106
+ export function scanTool(server, definition, rules, onRuleError) {
107
+ const name = typeof definition.name === 'string' ? definition.name : '';
108
+ const path = toolPath(server, name);
109
+ const { text, truncated } = collectModelText(definition);
110
+ const file = { kind: 'tool-definition', path, displayPath: path, target: server, content: text };
111
+ const findings = scanFile(file, onRuleError ? { rules, onRuleError } : { rules });
112
+ if (truncated)
113
+ findings.push(unreadableFinding(server, path));
114
+ return findings;
115
+ }
116
+ /** A definition the scanner could not read to the end may hide text in the part it skipped, so it is treated as flagged. */
117
+ function unreadableFinding(server, path) {
118
+ return {
119
+ ruleId: 'TM-PROXY-001',
120
+ severity: 'high',
121
+ category: 'obfuscation',
122
+ title: 'Tool definition too large to inspect',
123
+ message: `The definition has more than ${MAX_NODES} values, so toolmoat cannot check all of it for hidden instructions`,
124
+ file: path,
125
+ target: server,
126
+ remediation: 'Review the definition with toolmoat diff and approve it only if the server is trusted.',
127
+ };
128
+ }
129
+ /** Runs the same rules over text a tool returned, which can carry injected instructions too. */
130
+ export function scanToolOutput(server, tool, text, rules, onRuleError) {
131
+ return scanText(server, `${toolPath(server, tool)}#output`, text, rules, onRuleError);
132
+ }
133
+ /** Scans the `instructions` of a server's initialize result, which many clients add to the model's context. */
134
+ export function scanServerInstructions(server, text, rules, onRuleError) {
135
+ return scanText(server, `mcp://${server}#instructions`, text, rules, onRuleError);
136
+ }
137
+ function scanText(server, path, text, rules, onRuleError) {
138
+ const file = { kind: 'tool-definition', path, displayPath: path, target: server, content: text };
139
+ return scanFile(file, onRuleError ? { rules, onRuleError } : { rules });
140
+ }
@@ -0,0 +1,132 @@
1
+ import type { Rule } from '../types.js';
2
+ import type { AuditSink, SecretSummary } from './audit.js';
3
+ import type { FindingSummary, PendingReason } from './lockfile.js';
4
+ import type { Mode, OnNew } from './policy.js';
5
+ export interface ProxyOptions {
6
+ /** Server id in the lockfile. */
7
+ server: string;
8
+ /** The server's command line as recorded in the lockfile. */
9
+ commandLine: string;
10
+ lockPath: string;
11
+ /** Default enforce. */
12
+ mode?: Mode;
13
+ /** Default block. */
14
+ onNew?: OnNew;
15
+ /** Secret pattern ids (see SECRET_PATTERNS) that the egress guard lets through. */
16
+ allowSecrets?: readonly string[];
17
+ /** Rules for scanning tool definitions and tool output. Default: allRules. */
18
+ rules?: readonly Rule[];
19
+ audit?: AuditSink;
20
+ /** Record tool-call arguments in the audit log, after secret redaction. */
21
+ logArgs?: boolean;
22
+ /** Receives one-line diagnostics. Default: stderr with an [toolmoat] prefix. */
23
+ diag?: (message: string) => void;
24
+ now?: () => Date;
25
+ }
26
+ /** What to do with one line read from a peer. */
27
+ export interface Routed {
28
+ /** Forward the original bytes unchanged. */
29
+ passOriginal: boolean;
30
+ /** Lines to forward after the original, or instead of it when passOriginal is false. */
31
+ onward: string[];
32
+ /** Lines to send back to the peer the line came from. */
33
+ back: string[];
34
+ }
35
+ type BlockReason = PendingReason | 'unlisted' | 'lockfile';
36
+ /**
37
+ * The protocol half of the runtime firewall. It sees every JSON-RPC line in
38
+ * both directions and decides what is forwarded, rewritten or answered
39
+ * locally: tool lists are fingerprinted against the lockfile and scanned,
40
+ * held tools are hidden from the model and their calls refused, tool-call
41
+ * arguments go through the egress guard, and tool output and the server's
42
+ * initialize instructions are scanned for injected instructions. It does no process or stream IO, so it can be driven
43
+ * directly from tests.
44
+ */
45
+ export declare class Firewall {
46
+ private readonly server;
47
+ private readonly commandLine;
48
+ private readonly lockPath;
49
+ private readonly mode;
50
+ private readonly onNew;
51
+ private readonly allowSecrets;
52
+ private readonly rules;
53
+ private readonly sink;
54
+ private readonly logArgs;
55
+ private readonly diagSink;
56
+ private readonly now;
57
+ private readonly listRequests;
58
+ private readonly callRequests;
59
+ private readonly initRequests;
60
+ private readonly lastSeen;
61
+ private readonly reported;
62
+ private blocked;
63
+ private allowed;
64
+ private startEntry;
65
+ private entryExists;
66
+ private listed;
67
+ private trustOnFirstUse;
68
+ private clientReady;
69
+ /** The lockfile could not be read during a listing and every tool was hidden because of it. */
70
+ private lockBroken;
71
+ private scans;
72
+ /** Tool calls that arrived while the first tool list was still on its way; decided once it lands. */
73
+ private held;
74
+ /** Throws LockfileError when the lockfile exists but cannot be read: the proxy must not start unverified. */
75
+ constructor(options: ProxyOptions);
76
+ /** Tools waiting for review in the lockfile stay held until they are approved. */
77
+ private holdPending;
78
+ /** Handles one line from the MCP client on its way to the server. */
79
+ fromClient(text: string): Routed;
80
+ /** Handles one line from the MCP server on its way to the client. */
81
+ fromServer(text: string): Routed;
82
+ /**
83
+ * A server line that looks like JSON but does not parse cannot be checked, and a more lenient
84
+ * reader at the other end (another JSON parser, a client that trims or repairs lines) might still
85
+ * accept it as a tool list. In enforce mode it never reaches the client.
86
+ */
87
+ private unparseable;
88
+ /** Decides the calls held during the first listing: refusals go to the client, the rest on to the server. */
89
+ private releaseHeld;
90
+ /**
91
+ * Called when the lockfile changes on disk, typically after `toolmoat
92
+ * approve`. Held tools whose current definition is now pinned are released,
93
+ * and the client is asked to reload its tool list so the model sees them.
94
+ * Returns the lines to send to the client.
95
+ */
96
+ lockChanged(): string[];
97
+ /** Resolves when background scans of tool output and server instructions have finished. */
98
+ idle(): Promise<void>;
99
+ /** Returns a reply to send back instead of forwarding, 'hold' to park the message, or undefined to forward it. */
100
+ private onClientMessage;
101
+ private onToolCall;
102
+ private callBlock;
103
+ private leakedSecrets;
104
+ private onServerMessage;
105
+ private onToolList;
106
+ private lockfileFailure;
107
+ /** Records decisions in the lockfile. A listing that changes nothing does not even take the file lock. */
108
+ private persist;
109
+ private updateState;
110
+ private report;
111
+ private reportOnce;
112
+ private scheduleOutputScan;
113
+ private scheduleInstructionsScan;
114
+ /** Queues a background scan; traffic never waits for it. Reports high and critical findings only. */
115
+ private scheduleScan;
116
+ private readEntry;
117
+ /** A rule that throws is skipped for that text; say so once, since its check is missing from every later decision. */
118
+ private readonly onRuleError;
119
+ private audit;
120
+ private diag;
121
+ private label;
122
+ }
123
+ /** The text a tool result puts in front of the model: text items, embedded text resources, resource links and structured content. */
124
+ export declare function outputText(result: unknown): string;
125
+ /** The text the model gets instead of a tool result when a call to a held tool is refused. */
126
+ export declare function blockedCallText(server: string, tool: string, block: {
127
+ reason: BlockReason;
128
+ findings: readonly FindingSummary[];
129
+ }): string;
130
+ /** The text the model gets when the egress guard refuses a call. Names the credential type, never the value. */
131
+ export declare function secretCallText(server: string, tool: string, secrets: readonly SecretSummary[]): string;
132
+ export {};