@synovitec/trellis 0.0.0-stage → 0.6.0-rc.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 (50) hide show
  1. package/CHANGELOG.md +406 -0
  2. package/LICENSE +190 -0
  3. package/README.md +206 -2
  4. package/SECURITY.md +105 -0
  5. package/bin/trellis.mjs +5 -0
  6. package/docs/attestation/v0.1.md +96 -0
  7. package/package.json +78 -4
  8. package/schema/trellis.config.schema.json +349 -0
  9. package/src/agents/adapters.mjs +155 -0
  10. package/src/agents/git.mjs +241 -0
  11. package/src/agents/glob.mjs +41 -0
  12. package/src/agents/hooks.mjs +780 -0
  13. package/src/agents/install-adapters.mjs +172 -0
  14. package/src/agents/install.mjs +302 -0
  15. package/src/agents/manifests.mjs +127 -0
  16. package/src/agents/rules.mjs +330 -0
  17. package/src/agents/shell.mjs +253 -0
  18. package/src/agents/state.mjs +233 -0
  19. package/src/agents/transcript.mjs +120 -0
  20. package/src/cli/main.mjs +191 -0
  21. package/src/commands/attest.mjs +208 -0
  22. package/src/commands/checkpoint-state.mjs +62 -0
  23. package/src/commands/doctor.mjs +98 -0
  24. package/src/commands/guide.mjs +62 -0
  25. package/src/commands/init.mjs +131 -0
  26. package/src/commands/key.mjs +31 -0
  27. package/src/commands/scan.mjs +51 -0
  28. package/src/commands/uninstall.mjs +36 -0
  29. package/src/commands/verify.mjs +510 -0
  30. package/src/core/abatty.mjs +25 -0
  31. package/src/core/agents-block.mjs +65 -0
  32. package/src/core/attestation.mjs +213 -0
  33. package/src/core/config.mjs +135 -0
  34. package/src/core/context.mjs +280 -0
  35. package/src/core/diff.mjs +189 -0
  36. package/src/core/evidence.mjs +167 -0
  37. package/src/core/mcp.mjs +51 -0
  38. package/src/index.mjs +11 -0
  39. package/src/mcp/client.mjs +164 -0
  40. package/src/mcp/launch.mjs +57 -0
  41. package/src/mcp/relay.mjs +111 -0
  42. package/src/mcp/server.mjs +210 -0
  43. package/src/mcp/trust.mjs +142 -0
  44. package/src/tools/registry.mjs +370 -0
  45. package/src/tools/roles.mjs +17 -0
  46. package/src/verify/deps.mjs +382 -0
  47. package/src/verify/gitleaks-default.toml +3211 -0
  48. package/src/verify/rules.mjs +177 -0
  49. package/src/verify/secrets.mjs +169 -0
  50. package/src/verify/test-commands.mjs +116 -0
@@ -0,0 +1,111 @@
1
+ // The servers the gateway relays: every stdio server declared in .mcp.json except Trellis itself,
2
+ // once a person has trusted it (./trust.mjs). Their tools are offered as `<server>__<tool>`; a call goes to the server as it was asked, its
3
+ // answer comes back unchanged, and when it succeeded Trellis records what kind of check it was
4
+ // (docs, schema, browser), because Trellis saw it answer.
5
+ import { appendEvent } from "../core/evidence.mjs";
6
+ import { uiBlobs } from "../agents/adapters.mjs";
7
+ import { Upstream } from "./client.mjs";
8
+ import { fingerprint, relayable, SEP, trustedServers } from "./trust.mjs";
9
+
10
+ /**
11
+ * What a successful call to a server's `tool` is evidence of, if anything. Read from what the
12
+ * entry runs (its command and arguments), never from the name it is given in .mcp.json: any
13
+ * server can be called "context7".
14
+ * @param {any} entry
15
+ * @param {string} tool
16
+ * @returns {"docs" | "schema" | "browser" | null}
17
+ */
18
+ function kindOf(entry, tool) {
19
+ const runs = [entry?.command, ...(entry?.args ?? [])].join(" ");
20
+ if (/context7/i.test(runs)) return "docs";
21
+ if (/postgres/i.test(runs)) return "schema";
22
+ if (/playwright/i.test(runs) && /^browser_(snapshot|take_screenshot)$/.test(tool)) return "browser";
23
+ return null;
24
+ }
25
+
26
+ export class Relay {
27
+ /**
28
+ * @param {import("../core/context.mjs").Context} ctx
29
+ * @param {NodeJS.ProcessEnv} env
30
+ */
31
+ constructor(ctx, env) {
32
+ this.ctx = ctx;
33
+ this.env = env;
34
+ /** @type {Map<string, Upstream>} */
35
+ this.servers = new Map();
36
+ /** @type {string[]} */
37
+ this.notRelayed = [];
38
+ /** @type {Map<string, any>} */
39
+ this.entries = new Map();
40
+ const { servers, skipped } = relayable(ctx);
41
+ this.notRelayed.push(...skipped);
42
+ const trusted = trustedServers(ctx.root, env);
43
+ for (const [name, entry] of servers) {
44
+ // Started only as a person trusted it: .mcp.json is a file a pull request can change.
45
+ if (trusted[name] !== fingerprint(entry, ctx.root)) {
46
+ this.notRelayed.push(`${name}: ${trusted[name] ? "changed since it was trusted" : "not trusted yet"}; a person runs \`npx --no-install @synovitec/trellis mcp trust\` in a terminal`);
47
+ continue;
48
+ }
49
+ this.servers.set(name, new Upstream(name, entry, { cwd: ctx.root, env }));
50
+ this.entries.set(name, entry);
51
+ }
52
+ /** @type {Map<string, any[]>} */
53
+ this.toolsBy = new Map();
54
+ }
55
+
56
+ /**
57
+ * Every relayed tool, prefixed with its server's name. A server that does not start is said in
58
+ * `notRelayed`, never waited on forever.
59
+ * @returns {Promise<any[]>}
60
+ */
61
+ async tools() {
62
+ const lists = await Promise.all(
63
+ [...this.servers].map(async ([name, up]) => {
64
+ try {
65
+ const { tools = [] } = await up.request("tools/list", {}, 60_000);
66
+ this.toolsBy.set(name, tools);
67
+ return tools.map((/** @type {any} */ t) => ({ ...t, name: `${name}${SEP}${t.name}`, description: `[${name}, relayed by Trellis] ${t.description ?? ""}`.trim() }));
68
+ } catch (e) {
69
+ this.notRelayed.push(`${name}: did not start (${e instanceof Error ? e.message : String(e)})`);
70
+ this.servers.delete(name);
71
+ up.close();
72
+ return [];
73
+ }
74
+ }),
75
+ );
76
+ return lists.flat();
77
+ }
78
+
79
+ /**
80
+ * Relays one call, or returns null when `name` is not a relayed tool.
81
+ * @param {string} name
82
+ * @param {Record<string, any>} args
83
+ * @param {string} agent
84
+ */
85
+ async call(name, args, agent) {
86
+ const i = name.indexOf(SEP);
87
+ if (i === -1) return null;
88
+ const server = name.slice(0, i);
89
+ const tool = name.slice(i + SEP.length);
90
+ const up = this.servers.get(server);
91
+ if (!up) return null;
92
+ if (!this.toolsBy.has(server)) await this.tools();
93
+ if (!(this.toolsBy.get(server) ?? []).some((t) => t.name === tool)) return null;
94
+ const result = await up.request("tools/call", { name: tool, arguments: args });
95
+ const { resultType: _, ...plain } = result ?? {};
96
+ const kind = kindOf(this.entries.get(server), tool);
97
+ if (kind && !plain.isError) {
98
+ if (kind === "browser") {
99
+ const blobs = uiBlobs(this.ctx);
100
+ if (blobs) appendEvent(this.ctx.root, { kind, tool: name, agent, blobs }, this.env);
101
+ } else {
102
+ appendEvent(this.ctx.root, { kind, tool: name, agent, subject: JSON.stringify(args).toLowerCase().slice(0, 500) }, this.env);
103
+ }
104
+ }
105
+ return plain;
106
+ }
107
+
108
+ close() {
109
+ for (const up of this.servers.values()) up.close();
110
+ }
111
+ }
@@ -0,0 +1,210 @@
1
+ // `trellis mcp`: Trellis as an MCP server, so any agent that speaks MCP gets the repository's checks
2
+ // as tools, and Trellis records the evidence itself: it ran the call and saw the answer. The
3
+ // strongest evidence there is, and it does not depend on an agent's hooks or transcript.
4
+ //
5
+ // Dual-era, as the specification allows (2026-07-28, "Versioning and Compatibility"): a modern
6
+ // client sends its version and capabilities in each request's `_meta`; a legacy client opens with
7
+ // `initialize` and gets the legacy semantics for this process. stdio framing: one JSON-RPC message
8
+ // per line on stdout, nothing else there; logs go to stderr.
9
+ import { spawnSync } from "node:child_process";
10
+ import { createInterface } from "node:readline";
11
+ import { appendEvent } from "../core/evidence.mjs";
12
+ import { VERSION } from "../core/config.mjs";
13
+ import { testOverride } from "../agents/state.mjs";
14
+ import { checkTests, escalate, verify } from "../commands/verify.mjs";
15
+ import { Relay } from "./relay.mjs";
16
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { normalise, testEnv } from "../verify/test-commands.mjs";
20
+
21
+ const MODERN = ["2026-07-28"];
22
+ const LEGACY = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
23
+ const KEY = { version: "io.modelcontextprotocol/protocolVersion", client: "io.modelcontextprotocol/clientInfo", caps: "io.modelcontextprotocol/clientCapabilities", server: "io.modelcontextprotocol/serverInfo" };
24
+ const SERVER_INFO = { name: "trellis", version: VERSION };
25
+ const INSTRUCTIONS =
26
+ "The repository's checks, run by Trellis and recorded as evidence for the commit checkpoint. Ask graph_query before changing code; run the tests with run_tests; if a test or the task is wrong, call escalate and stop; check_change says what the next commit would be refused for.";
27
+
28
+ const TOOLS = [
29
+ {
30
+ name: "graph_query",
31
+ description: "Ask the repository's code graph where something lives and what depends on it (graphify). Use it before changing code.",
32
+ inputSchema: { type: "object", properties: { question: { type: "string", description: "What you are about to change, and what you need to know about it" } }, required: ["question"] },
33
+ },
34
+ {
35
+ name: "run_tests",
36
+ description: "Run the test command; Trellis records which changed files it ran on and its exit code. A test run you only describe counts for nothing.",
37
+ inputSchema: { type: "object", properties: { command: { type: "string", description: "The test command, e.g. npm test" } }, required: ["command"] },
38
+ },
39
+ {
40
+ name: "escalate",
41
+ description: "Stop and hand the change to a person: a test that contradicts the task, a requirement you cannot meet. The change is held until a person approves it.",
42
+ inputSchema: { type: "object", properties: { reason: { type: "string" } }, required: ["reason"] },
43
+ },
44
+ {
45
+ name: "check_change",
46
+ description: "What the commit checkpoint would say about the staged change now, without committing.",
47
+ inputSchema: { type: "object", properties: {} },
48
+ },
49
+ ];
50
+
51
+ /** @typedef {{ jsonrpc: "2.0", id?: string | number, method?: string, params?: any }} Message */
52
+
53
+ /** A client's name as the evidence names an agent; self-reported, so for labels only. @param {any} info */
54
+ const agentOf = (info) => (typeof info?.name === "string" && info.name.trim() ? info.name.trim().slice(0, 60) : "mcp-client");
55
+
56
+ /** @param {string} text @param {boolean} isError */
57
+ const toolResult = (text, isError) => ({ content: [{ type: "text", text: text.slice(0, 50_000) }], isError });
58
+
59
+ /**
60
+ * One tool call. Returns the tool result, or null for an unknown tool.
61
+ * @param {string} name
62
+ * @param {Record<string, any>} args
63
+ * @param {{ ctx: import("../core/context.mjs").Context, env: NodeJS.ProcessEnv, agent: string }} c
64
+ */
65
+ async function callTool(name, args, c) {
66
+ const env = { ...c.env, TRELLIS_AGENT: c.agent };
67
+ /** @type {string[]} */
68
+ const lines = [];
69
+ const out = (/** @type {string[]} */ ...l) => void lines.push(...l);
70
+ if (name === "graph_query") {
71
+ const question = String(args.question ?? "").trim();
72
+ if (!question) return toolResult("graph_query needs a question.", true);
73
+ const custom = testOverride(c.env, "TRELLIS_GRAPHIFY");
74
+ const bin = custom ? process.execPath : c.ctx.which("graphify");
75
+ if (!bin) return toolResult("graphify is not installed here: there is no code graph to ask.", true);
76
+ const r = spawnSync(bin, custom ? [custom, "query", question] : ["query", question], { cwd: c.ctx.root, encoding: "utf8", timeout: 120_000, windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
77
+ const text = `${r.stdout ?? ""}${r.stderr ? `\n${r.stderr}` : ""}`.trim();
78
+ // Answered: graphify's own output, from graphify itself, which Trellis ran.
79
+ const answered = r.status === 0 && /^Graph: .*\bnodes?\b|^NODE /m.test(r.stdout ?? "");
80
+ if (answered) appendEvent(c.ctx.root, { kind: "graph", tool: "graphify", agent: c.agent, subject: question.slice(0, 300) }, c.env);
81
+ return toolResult(text || "graphify gave no answer.", !answered);
82
+ }
83
+ if (name === "run_tests") {
84
+ const command = String(args.command ?? "").trim();
85
+ if (!command) return toolResult("run_tests needs the test command.", true);
86
+ let output = "";
87
+ const run = () => {
88
+ // The same environment as `trellis check tests`: nothing that steers the runner.
89
+ const dir = mkdtempSync(join(tmpdir(), "trellis-empty-"));
90
+ writeFileSync(join(dir, "npmrc"), "");
91
+ writeFileSync(join(dir, "npmrc.global"), "");
92
+ // What runs is the command as it was accepted, never the raw text that matched it.
93
+ const r = spawnSync(normalise(command), { cwd: c.ctx.root, encoding: "utf8", shell: true, timeout: 30 * 60_000, windowsHide: true, maxBuffer: 64 * 1024 * 1024, env: testEnv(process.env, join(dir, "npmrc")) });
94
+ rmSync(dir, { recursive: true, force: true });
95
+ output = `${r.stdout ?? ""}${r.stderr ?? ""}`;
96
+ return r.status;
97
+ };
98
+ const code = checkTests(c.ctx, [command], out, { env, run });
99
+ // The end of a test run says what failed; keep that part when the output is long.
100
+ return toolResult(`${output.length > 40_000 ? `...\n${output.slice(-40_000)}` : output}\n${lines.join("\n")}`.trim(), code !== 0);
101
+ }
102
+ if (name === "escalate") {
103
+ const code = escalate(c.ctx, String(args.reason ?? ""), out, { env });
104
+ return toolResult(lines.join("\n"), code !== 0);
105
+ }
106
+ if (name === "check_change") {
107
+ const code = await verify(c.ctx, out, { env });
108
+ return toolResult(lines.join("\n"), code !== 0);
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * Answers one message: a response, or null for a notification.
115
+ * @param {Message} msg
116
+ * @param {{ ctx: import("../core/context.mjs").Context, env: NodeJS.ProcessEnv, legacy: { version: string, client: any } | null, relay?: Relay }} state
117
+ * @returns {Promise<object | null>}
118
+ */
119
+ export async function answer(msg, state) {
120
+ const id = msg.id;
121
+ if (id === undefined || id === null) return null;
122
+ const reply = (/** @type {object} */ result) => ({ jsonrpc: "2.0", id, result });
123
+ const fail = (/** @type {number} */ code, /** @type {string} */ message, /** @type {any} */ data) => ({ jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } });
124
+ const params = msg.params ?? {};
125
+
126
+ if (msg.method === "initialize") {
127
+ const asked = String(params.protocolVersion ?? "");
128
+ const version = LEGACY.includes(asked) ? asked : LEGACY[0];
129
+ state.legacy = { version, client: params.clientInfo };
130
+ return reply({ protocolVersion: version, capabilities: { tools: {} }, serverInfo: SERVER_INFO, instructions: INSTRUCTIONS });
131
+ }
132
+
133
+ // A modern request carries its own version; a legacy one relies on the initialize before it.
134
+ const m = params._meta ?? {};
135
+ let agent;
136
+ let modern = false;
137
+ if (m[KEY.version] !== undefined) {
138
+ if (!MODERN.includes(m[KEY.version])) return fail(-32022, "Unsupported protocol version", { supported: [...MODERN, ...LEGACY], requested: m[KEY.version] });
139
+ if (m[KEY.caps] === undefined) return fail(-32602, `Invalid params: _meta must carry ${KEY.caps}`);
140
+ modern = true;
141
+ agent = agentOf(m[KEY.client]);
142
+ } else if (state.legacy) {
143
+ agent = agentOf(state.legacy.client);
144
+ } else if (msg.method === "ping") {
145
+ return reply({});
146
+ } else {
147
+ return fail(-32602, `Invalid params: a request must carry ${KEY.version} and ${KEY.caps} in _meta, or come after initialize`);
148
+ }
149
+ const done = (/** @type {Record<string, any>} */ result) => reply(modern ? { resultType: "complete", ...result, _meta: { ...(result._meta ?? {}), [KEY.server]: SERVER_INFO } } : result);
150
+
151
+ switch (msg.method) {
152
+ case "server/discover":
153
+ return done({ supportedVersions: [...MODERN, ...LEGACY], capabilities: { tools: {} }, instructions: INSTRUCTIONS });
154
+ case "ping":
155
+ return done({});
156
+ case "tools/list": {
157
+ const relayed = state.relay ? await state.relay.tools() : [];
158
+ // What could not be relayed is said, in the listing's own metadata, never hidden.
159
+ const notRelayed = state.relay?.notRelayed ?? [];
160
+ return done({ tools: [...TOOLS, ...relayed], ...(notRelayed.length ? { _meta: { "dev.trellis/notRelayed": notRelayed } } : {}) });
161
+ }
162
+ case "tools/call": {
163
+ const name = String(params.name ?? "");
164
+ const args = params.arguments ?? {};
165
+ const result = (await callTool(name, args, { ctx: state.ctx, env: state.env, agent })) ?? (state.relay ? await state.relay.call(name, args, agent) : null);
166
+ if (!result) return fail(-32602, `Unknown tool "${name}"`);
167
+ return done(result);
168
+ }
169
+ default:
170
+ return fail(-32601, `Method not found: ${msg.method}`);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Serves MCP over stdio until stdin closes.
176
+ * @param {import("../core/context.mjs").Context} ctx
177
+ * @param {NodeJS.ProcessEnv} env
178
+ * @returns {Promise<number>}
179
+ */
180
+ export function serveMcp(ctx, env) {
181
+ /** @type {{ ctx: import("../core/context.mjs").Context, env: NodeJS.ProcessEnv, legacy: { version: string, client: any } | null, relay: Relay }} */
182
+ const state = { ctx, env, legacy: null, relay: new Relay(ctx, env) };
183
+ const write = (/** @type {object} */ m) => process.stdout.write(`${JSON.stringify(m)}\n`);
184
+ const pending = [];
185
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
186
+ rl.on("line", (line) => {
187
+ if (!line.trim()) return;
188
+ /** @type {Message} */
189
+ let msg;
190
+ try {
191
+ msg = JSON.parse(line);
192
+ } catch {
193
+ write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
194
+ return;
195
+ }
196
+ // initialize is answered before anything after it is read, so a legacy session is set first.
197
+ const p = answer(msg, state)
198
+ .then((r) => r && write(r))
199
+ .catch((e) => msg.id != null && write({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: `Internal error: ${e instanceof Error ? e.message : String(e)}` } }));
200
+ pending.push(p);
201
+ });
202
+ return new Promise((resolve) =>
203
+ rl.on("close", () =>
204
+ void Promise.allSettled(pending).then(() => {
205
+ state.relay.close();
206
+ resolve(0);
207
+ }),
208
+ ),
209
+ );
210
+ }
@@ -0,0 +1,142 @@
1
+ // Which servers of .mcp.json the gateway may start. A server is a command run on this machine,
2
+ // and .mcp.json is a file a pull request can change: the gateway starts a server only once a
3
+ // person has trusted that exact entry (command, arguments, environment, folder), as Claude Code
4
+ // asks before it starts a project's servers. An entry that changes after it was trusted is held
5
+ // again. The trust is this clone's, kept beside the evidence log, never in the repository.
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, mkdirSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
8
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
9
+ import { parseJson } from "../core/context.mjs";
10
+ import { evidenceFile } from "../core/evidence.mjs";
11
+
12
+ /** The relay's separator between a server's name and its tool's. */
13
+ export const SEP = "__";
14
+
15
+ /** An entry that runs Trellis's own server: never relayed, or the gateway would call itself. @param {any} s */
16
+ export const isTrellis = (s) => /trellis(\.mjs)?["']?,?\s*"?mcp\b/.test(JSON.stringify([s?.command, ...(s?.args ?? [])]));
17
+
18
+ /**
19
+ * The entries of .mcp.json the relay would start, and why the others are not.
20
+ * @param {import("../core/context.mjs").Context} ctx
21
+ * @returns {{ servers: Array<[string, any]>, skipped: string[] }}
22
+ */
23
+ export function relayable(ctx) {
24
+ const doc = parseJson(ctx.read(".mcp.json"));
25
+ /** @type {Array<[string, any]>} */
26
+ const servers = [];
27
+ const skipped = [];
28
+ for (const [name, entry] of Object.entries(doc?.mcpServers ?? {})) {
29
+ if (isTrellis(entry)) continue;
30
+ const type = entry?.type ?? (entry?.url ? "http" : "stdio");
31
+ if (name.includes(SEP)) skipped.push(`${name}: its name holds "${SEP}", which the relay uses to route`);
32
+ else if (type !== "stdio" || typeof entry?.command !== "string") skipped.push(`${name}: a ${type} server is not relayed yet; the agent reaches it directly`);
33
+ else servers.push([name, entry]);
34
+ }
35
+ return { servers, skipped };
36
+ }
37
+
38
+ /** What is trusted of an entry: everything that decides what runs. @param {any} entry @param {string} root */
39
+ export const fingerprint = (entry, root) =>
40
+ createHash("sha256")
41
+ .update(JSON.stringify({ command: entry?.command, args: entry?.args ?? [], env: Object.entries(entry?.env ?? {}).sort(), cwd: entry?.cwd ?? null, files: filesRun(entry, root) }))
42
+ .digest("hex");
43
+
44
+ /**
45
+ * The content of each repository file the entry names (`node scripts/mcp.js`): a pull request
46
+ * that changes the script changes what the trusted entry runs, so the trust does not hold.
47
+ * @param {any} entry @param {string} root
48
+ */
49
+ function filesRun(entry, root) {
50
+ const base = resolve(root, entry?.cwd ?? ".");
51
+ /** @type {Record<string, string>} */
52
+ const files = {};
53
+ for (const token of [entry?.command, ...(entry?.args ?? [])]) {
54
+ if (typeof token !== "string" || !token || token.startsWith("-")) continue;
55
+ const path = resolve(base, token);
56
+ const inside = relative(root, path);
57
+ if (inside.startsWith("..") || isAbsolute(inside)) continue;
58
+ try {
59
+ if (statSync(path).isFile()) files[inside.replace(/\\/g, "/")] = createHash("sha256").update(readFileSync(path)).digest("hex");
60
+ } catch {
61
+ /* not a file: an argument */
62
+ }
63
+ }
64
+ return files;
65
+ }
66
+
67
+ /**
68
+ * Beside the evidence log in the git folder, wherever an environment variable points the log: a
69
+ * gateway entry's `env` must not move what the gateway trusts to a file a pull request commits.
70
+ * @param {string} root @param {NodeJS.ProcessEnv} _env
71
+ */
72
+ const trustFile = (root, _env) => join(dirname(evidenceFile(root, {})), "mcp-trusted.json");
73
+
74
+ /**
75
+ * The trusted entries, by server name. A missing or unreadable file trusts nothing.
76
+ * @param {string} root @param {NodeJS.ProcessEnv} env
77
+ * @returns {Record<string, string>}
78
+ */
79
+ export function trustedServers(root, env) {
80
+ const file = trustFile(root, env);
81
+ const doc = existsSync(file) ? parseJson(readFileSync(file, "utf8")) : null;
82
+ return doc && typeof doc === "object" ? doc : {};
83
+ }
84
+
85
+ /**
86
+ * Records `entries` as trusted, beside those trusted already.
87
+ * @param {string} root @param {NodeJS.ProcessEnv} env @param {Array<[string, any]>} entries
88
+ */
89
+ export function trustEntries(root, env, entries) {
90
+ const trusted = trustedServers(root, env);
91
+ for (const [name, entry] of entries) trusted[name] = fingerprint(entry, root);
92
+ mkdirSync(dirname(trustFile(root, env)), { recursive: true });
93
+ writeFileSync(trustFile(root, env), `${JSON.stringify(trusted, null, 2)}\n`);
94
+ }
95
+
96
+ /** One line from the terminal. */
97
+ function askLine() {
98
+ const buf = Buffer.alloc(1);
99
+ let line = "";
100
+ try {
101
+ while (readSync(0, buf, 0, 1, null) === 1 && buf[0] !== 10) line += String.fromCharCode(buf[0]);
102
+ } catch {
103
+ /* no terminal: no answer */
104
+ }
105
+ return line.trim();
106
+ }
107
+
108
+ /**
109
+ * `trellis mcp trust`: shows what each untrusted server would run and, on a person's yes, trusts
110
+ * those exact entries. Refused without a terminal, like approve: an agent's commands run without one.
111
+ * @param {import("../core/context.mjs").Context} ctx
112
+ * @param {(...lines: string[]) => void} out
113
+ * @param {{ env?: NodeJS.ProcessEnv, interactive?: boolean, confirm?: () => boolean }} [opts]
114
+ */
115
+ export function trustCommand(ctx, out, opts = {}) {
116
+ const env = opts.env ?? process.env;
117
+ const interactive = opts.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
118
+ if (!interactive) {
119
+ out("trellis mcp trust is a person's step: run it yourself, in a terminal.");
120
+ return 1;
121
+ }
122
+ const trusted = trustedServers(ctx.root, env);
123
+ const pending = relayable(ctx).servers.filter(([name, entry]) => trusted[name] !== fingerprint(entry, ctx.root));
124
+ if (!pending.length) {
125
+ out("trellis mcp trust: every server in .mcp.json is trusted already.");
126
+ return 0;
127
+ }
128
+ out("The gateway would start these servers of .mcp.json, as commands on this machine:");
129
+ for (const [name, entry] of pending) {
130
+ out(` ${name}${trusted[name] ? " (changed since it was trusted)" : ""}: ${[entry.command, ...(entry.args ?? [])].join(" ")}`);
131
+ if (entry.env && Object.keys(entry.env).length) out(` environment: ${Object.keys(entry.env).join(", ")}`);
132
+ if (entry.cwd) out(` in: ${entry.cwd}`);
133
+ }
134
+ out("Trust them? [y/N]");
135
+ if (!(opts.confirm ?? (() => /^y(es)?$/i.test(askLine())))()) {
136
+ out("trellis mcp trust: nothing trusted.");
137
+ return 1;
138
+ }
139
+ trustEntries(ctx.root, env, pending);
140
+ out(`trellis mcp trust: ${pending.length} server(s) trusted; the gateway starts them from its next launch.`);
141
+ return 0;
142
+ }