@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,36 @@
1
+ // `trellis uninstall`: takes out what Trellis put in that would break once the package is gone
2
+ // (the agent hooks, its pre-commit hook) and its AGENTS.md block. trellis.config.json and the
3
+ // MCP servers stay: they are the team's configuration now, and removing them is the team's call.
4
+ import { unlinkSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import { planUninstallHooks } from "../agents/install.mjs";
7
+ import { removeBlock } from "../core/agents-block.mjs";
8
+
9
+ /** @typedef {import("../core/context.mjs").Context} Context */
10
+
11
+ /**
12
+ * @param {Context} ctx
13
+ * @param {{ dryRun?: boolean }} flags
14
+ * @param {(...lines: string[]) => void} out
15
+ * @param {(path: string, text: string) => void} write
16
+ * @returns {number}
17
+ */
18
+ export function uninstall(ctx, flags, out, write) {
19
+ const { writes, deletes } = planUninstallHooks(ctx);
20
+ const agents = removeBlock(ctx.read("AGENTS.md"));
21
+ if (agents != null) writes.push({ path: "AGENTS.md", text: agents });
22
+ if (!writes.length && !deletes.length) {
23
+ out("Nothing to remove: no Trellis hook or block is installed here.");
24
+ return 0;
25
+ }
26
+ for (const w of writes) {
27
+ if (!flags.dryRun) write(w.path, w.text);
28
+ out(`${flags.dryRun ? "would update" : "updated"} ${w.path}`);
29
+ }
30
+ for (const d of deletes) {
31
+ if (!flags.dryRun) unlinkSync(resolve(ctx.root, d));
32
+ out(`${flags.dryRun ? "would delete" : "deleted"} ${d}`);
33
+ }
34
+ out("", "Kept: trellis.config.json and the MCP servers in .mcp.json, which are yours to remove.");
35
+ return 0;
36
+ }
@@ -0,0 +1,510 @@
1
+ // The commit checkpoint and the commands that feed its evidence:
2
+ // trellis verify the staged change against the rules (pre-commit)
3
+ // trellis check tests -- <cmd> run the tests and record what they ran on
4
+ // trellis escalate "<why>" an agent stops and hands the change to a person
5
+ // trellis approve a person approves the change as it is now (interactive only)
6
+ // trellis checkpoint after a commit: attests it (./attest.mjs)
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { commitDiff, parentsOf, stagedDiff, treeDiff, treeOf, worktreeDiff, worktreeTree } from "../core/diff.mjs";
11
+ import { appendEvent, readWindow, resetEvidence } from "../core/evidence.mjs";
12
+ import { NOTES_REF, PREDICATE_TYPE, readNotes, trustedKeys, verifyEnvelope } from "../core/attestation.mjs";
13
+ import { agentAtWork, lastVerifyFile } from "./attest.mjs";
14
+
15
+ export { agentAtWork, commitCheckpoint } from "./attest.mjs";
16
+ import { createHash } from "node:crypto";
17
+ import { VERSION } from "../core/config.mjs";
18
+ import { loadConfig, validateConfig } from "../core/config.mjs";
19
+ import { parseJson } from "../core/context.mjs";
20
+ import { scanChanges, scanCommit as scanCommitSecrets, scanStaged } from "../verify/secrets.mjs";
21
+ import { checkDependencies, dependencyChanges } from "../verify/deps.mjs";
22
+ import { evaluate, localModes } from "../verify/rules.mjs";
23
+ import { isTestCommand, RUNNER_SETTINGS, testCommands, testEnv, testScriptChanges } from "../verify/test-commands.mjs";
24
+ import { matchesAny } from "../agents/glob.mjs";
25
+ import { tmpdir } from "node:os";
26
+
27
+ /** Runs `fn` with the path of an empty file, removed after. @template T @param {(path: string) => T} fn @returns {T} */
28
+ function withEmptyFile(fn) {
29
+ const dir = mkdtempSync(join(tmpdir(), "trellis-empty-"));
30
+ try {
31
+ writeFileSync(join(dir, "npmrc"), "");
32
+ writeFileSync(join(dir, "npmrc.global"), "");
33
+ return fn(join(dir, "npmrc"));
34
+ } finally {
35
+ rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ }
38
+
39
+ /** The files as they are now (work tree and index) that differ from HEAD, with their blobs. @param {string} root */
40
+ function currentChanges(root) {
41
+ let base;
42
+ try {
43
+ base = treeOf(root, "HEAD");
44
+ } catch {
45
+ return stagedDiff(root);
46
+ }
47
+ return worktreeDiff(root, base);
48
+ }
49
+
50
+ /**
51
+ * @typedef {object} VerifyOptions
52
+ * @property {NodeJS.ProcessEnv} [env]
53
+ * @property {import("../verify/deps.mjs").Http} [http]
54
+ * @property {(root: string) => import("../verify/secrets.mjs").SecretScan} [scanSecrets]
55
+ */
56
+
57
+ /**
58
+ * The staged change against the rules. Exit 1 when a blocking rule is not met.
59
+ * @param {import("../core/context.mjs").Context} ctx
60
+ * @param {(...lines: string[]) => void} out
61
+ * @param {VerifyOptions} [opts]
62
+ * @returns {Promise<number>}
63
+ */
64
+ export async function verify(ctx, out, opts = {}) {
65
+ const env = opts.env ?? process.env;
66
+ const changes = stagedDiff(ctx.root);
67
+ if (!changes.length) {
68
+ // Nothing of its own to judge (a clean merge, an empty commit): it passes, and says so for
69
+ // the post-commit to attest.
70
+ recordVerdict(ctx.root, env, { actor: "person", agent: null, preset: "recommended", findings: [], verdict: "pass" });
71
+ out("trellis verify: nothing staged.");
72
+ return 0;
73
+ }
74
+ const window = readWindow(ctx.root, env);
75
+ if (window.status === "tampered") {
76
+ out("✗ evidence-intact: the evidence log or its checkpoint was altered. Nothing in it can be trusted; tell the person.");
77
+ return 1;
78
+ }
79
+ const config = loadConfig(ctx);
80
+ // A broken config fails closed: rules read from it could be anything, and one that does not
81
+ // parse is not the absence of one.
82
+ const raw = ctx.read("trellis.config.json");
83
+ const errors = raw?.trim() && config == null ? ["trellis.config.json is not valid JSON"] : config ? validateConfig(config) : [];
84
+ if (errors.length) {
85
+ out(`✗ config-invalid: ${errors.join("; ")}. Fix trellis.config.json; nothing was checked.`);
86
+ return 1;
87
+ }
88
+ const deps = dependencyChanges(ctx.root, changes);
89
+ const agent = agentAtWork(window.events, env);
90
+ const preset = config?.verify?.preset ?? "recommended";
91
+ const secrets = (opts.scanSecrets ?? scanStaged)(ctx.root);
92
+ const findings = evaluate({
93
+ changes,
94
+ events: window.events,
95
+ actor: agent ? "agent" : "person",
96
+ preset,
97
+ modes: localModes(config, secrets),
98
+ secrets,
99
+ deps: deps.length ? await checkDependencies(deps, { http: opts.http }) : { status: "clean", findings: [], unpinned: [], unchecked: [] },
100
+ depNames: deps.map((d) => d.name),
101
+ testCommands: testCommands((p) => ctx.read(p)),
102
+ tree: spawnSync("git", ["write-tree"], { cwd: ctx.root, encoding: "utf8", windowsHide: true }).stdout?.trim() || undefined,
103
+ testScripts: testScriptChanges(ctx.root, changes),
104
+ });
105
+ for (const f of findings) out(`${f.mode === "block" ? "✗" : "!"} ${f.rule}: ${f.message}`);
106
+ const blocked = findings.filter((f) => f.mode === "block").length;
107
+ recordVerdict(ctx.root, env, { actor: agent ? "agent" : "person", agent, preset, findings, verdict: blocked ? "fail" : "pass" });
108
+ out(blocked ? `trellis verify: ${blocked} blocking finding(s); the commit is refused.` : `trellis verify: ok${findings.length ? ` (${findings.length} warning(s))` : ""}.`);
109
+ return blocked ? 1 : 0;
110
+ }
111
+
112
+ /**
113
+ * What the post-commit attests: this verdict, for exactly the tree the index holds now.
114
+ * @param {string} root @param {NodeJS.ProcessEnv} env @param {object} verdict
115
+ */
116
+ function recordVerdict(root, env, verdict) {
117
+ const tree = spawnSync("git", ["write-tree"], { cwd: root, encoding: "utf8", windowsHide: true }).stdout?.trim();
118
+ if (!tree) return;
119
+ mkdirSync(dirname(lastVerifyFile(root, env)), { recursive: true });
120
+ writeFileSync(lastVerifyFile(root, env), JSON.stringify({ tree, ...verdict }));
121
+ }
122
+
123
+ /**
124
+ * Runs the test command and records, with its exit code, the content of every changed file it ran
125
+ * on. Trellis runs it, so a test run the agent only claims counts for nothing.
126
+ * @param {import("../core/context.mjs").Context} ctx
127
+ * @param {string[]} command
128
+ * @param {(...lines: string[]) => void} out
129
+ * @param {{ env?: NodeJS.ProcessEnv, run?: (cmd: string[]) => number | null }} [opts]
130
+ */
131
+ export function checkTests(ctx, command, out, opts = {}) {
132
+ const env = opts.env ?? process.env;
133
+ if (!command.length) {
134
+ out('Usage: trellis check tests -- <test command> (for example: trellis check tests -- npm test)');
135
+ return 2;
136
+ }
137
+ // Only the project's own tests count: a run of `true` proves nothing.
138
+ const accepted = testCommands((p) => ctx.read(p));
139
+ if (!isTestCommand(command.join(" "), accepted)) {
140
+ out(`trellis check tests: \`${command.join(" ")}\` is not one of this project's test commands (${(accepted ?? []).join(", ")}); nothing recorded. Name others in trellis.config.json, verify.tests.`);
141
+ return 1;
142
+ }
143
+ // A runner setting git ignores is in no tree: present, it would change what runs unseen.
144
+ const hidden = spawnSync("git", ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"], { cwd: ctx.root, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024 })
145
+ .stdout.split("\0")
146
+ .filter((p) => p && !p.includes("node_modules/") && matchesAny(p, RUNNER_SETTINGS));
147
+ if (hidden.length) {
148
+ out(`trellis check tests: ${hidden.slice(0, 4).join(", ")} ${hidden.length > 1 ? "are" : "is"} ignored by git but present, and would change what the tests run; nothing recorded. Commit ${hidden.length > 1 ? "them" : "it"}, or remove ${hidden.length > 1 ? "them" : "it"}.`);
149
+ return 1;
150
+ }
151
+ const before = currentChanges(ctx.root);
152
+ const treeBefore = worktreeTree(ctx.root);
153
+ // What runs is the accepted command itself, through a shell as a team writes it: never the
154
+ // tokens that matched it, which could carry `&` or `;` past a shell (`npm test "&" exit 0`).
155
+ const line = command.join(" ").trim().replace(/\s+/g, " ");
156
+ const run =
157
+ opts.run ??
158
+ ((/** @type {string[]} */ cmd) =>
159
+ withEmptyFile((empty) => (accepted ? spawnSync(line, { cwd: ctx.root, stdio: "inherit", shell: true, env: testEnv(process.env, empty) }) : spawnSync(cmd[0], cmd.slice(1), { cwd: ctx.root, stdio: "inherit", shell: process.platform === "win32", env: testEnv(process.env, empty) })).status));
160
+ const code = run(command);
161
+ // The content tested is the content before and after the run; a file the run itself changed was not tested as it is now.
162
+ const after = new Map(currentChanges(ctx.root).map((c) => [c.path, c.newBlob]));
163
+ /** @type {Record<string, string>} */
164
+ const blobs = {};
165
+ for (const c of before) if (c.newBlob && after.get(c.path) === c.newBlob) blobs[c.path] = c.newBlob;
166
+ // The whole tree tested, when the run left it as it found it: a test run counts only for a
167
+ // commit of exactly that tree (a test loosened for the run, then restored, is not that tree).
168
+ const tree = worktreeTree(ctx.root) === treeBefore ? treeBefore : null;
169
+ appendEvent(ctx.root, { kind: "tests", tool: command.join(" ").slice(0, 200), agent: agentAtWork([], env) ?? "person", blobs, ...(tree ? { tree } : {}), result: code ?? -1 }, env);
170
+ out(`trellis check tests: exit ${code ?? "killed"}, recorded for ${Object.keys(blobs).length} changed file(s).`);
171
+ return code === 0 ? 0 : 1;
172
+ }
173
+
174
+ /**
175
+ * An agent stops and hands the change to a person, with its reason.
176
+ * @param {import("../core/context.mjs").Context} ctx
177
+ * @param {string} reason
178
+ * @param {(...lines: string[]) => void} out
179
+ * @param {{ env?: NodeJS.ProcessEnv }} [opts]
180
+ */
181
+ export function escalate(ctx, reason, out, opts = {}) {
182
+ const env = opts.env ?? process.env;
183
+ if (!reason.trim()) {
184
+ out('Usage: trellis escalate "<why a person must look>"');
185
+ return 2;
186
+ }
187
+ appendEvent(ctx.root, { kind: "escalation", tool: "trellis escalate", agent: agentAtWork([], env) ?? "agent", subject: reason.slice(0, 500) }, env);
188
+ out("trellis escalate: recorded. The change waits for a person, who reviews it and runs `trellis approve`. Stop here.");
189
+ return 0;
190
+ }
191
+
192
+ /**
193
+ * A person approves the change as it is now: every changed file at its current content, and any
194
+ * escalation before it. Refused without a terminal: an agent's commands run without one.
195
+ * @param {import("../core/context.mjs").Context} ctx
196
+ * @param {(...lines: string[]) => void} out
197
+ * @param {{ env?: NodeJS.ProcessEnv, interactive?: boolean }} [opts]
198
+ */
199
+ export function approve(ctx, out, opts = {}) {
200
+ const env = opts.env ?? process.env;
201
+ const interactive = opts.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
202
+ if (!interactive) {
203
+ out("trellis approve is a person's step: run it yourself, in a terminal.");
204
+ return 1;
205
+ }
206
+ /** @type {Record<string, string>} */
207
+ const blobs = {};
208
+ for (const c of currentChanges(ctx.root)) blobs[c.path] = c.newBlob ?? "deleted";
209
+ appendEvent(ctx.root, { kind: "approval", tool: "trellis approve", agent: "person", blobs }, env);
210
+ out(`trellis approve: approved ${Object.keys(blobs).length} changed file(s) as they are now.`);
211
+ return 0;
212
+ }
213
+
214
+ /**
215
+ * The tree the pull request lands as: what merging it into the base gives (`git merge-tree`, the
216
+ * merge a host makes), or null when it does not merge cleanly or git is too old to tell.
217
+ * @param {string} root @param {string} base @param {string} head
218
+ */
219
+ function landing(root, base, head) {
220
+ const r = spawnSync("git", ["merge-tree", "--write-tree", "--no-messages", base, head], { cwd: root, encoding: "utf8", windowsHide: true });
221
+ const tree = r.stdout.split(/\r?\n/)[0]?.trim();
222
+ return r.status === 0 && /^[0-9a-f]{40,64}$/.test(tree ?? "") ? tree : null;
223
+ }
224
+
225
+ /** @param {{ path: string, newBlob: string | null }} c */
226
+ const madeKey = (c) => `${c.path}\0${c.newBlob ?? "deleted"}`;
227
+
228
+ /**
229
+ * What lands, as changes against the base: the landed tree's files that differ from the base's,
230
+ * each with the head's own blob for that path (what a commit of the range had to make).
231
+ * Without a clean landing, the head against every merge base.
232
+ * @param {string} root @param {string} base @param {string} head
233
+ * @returns {Array<import("../core/diff.mjs").Change & { headBlob: string | null }>}
234
+ */
235
+ function netChanges(root, base, head) {
236
+ const show = (/** @type {string[]} */ args) => spawnSync("git", args, { cwd: root, encoding: "utf8", windowsHide: true });
237
+ const at = (/** @type {string} */ rev, /** @type {string} */ path) => show(["rev-parse", "-q", "--verify", `${rev}:${path}`]).stdout.trim() || null;
238
+ const landed = landing(root, base, head);
239
+ /** @type {Map<string, import("../core/diff.mjs").Change & { headBlob: string | null }>} */
240
+ const out = new Map();
241
+ if (landed) {
242
+ for (const c of treeDiff(root, base, landed)) out.set(c.path, { ...c, headBlob: at(head, c.path) });
243
+ return [...out.values()];
244
+ }
245
+ // With criss-cross history, content one merge base held while another had moved on is a revert
246
+ // the landing merge may keep: read against every one.
247
+ const bases = show(["merge-base", "--all", base, head]).stdout.split(/\r?\n/).filter(Boolean);
248
+ for (const from of bases.length ? bases : [base]) {
249
+ for (const c of treeDiff(root, from, head)) {
250
+ const old = at(base, c.path);
251
+ if (old === at(head, c.path)) continue; // as the base holds it: lands as nothing
252
+ out.set(c.path, { ...c, oldBlob: old, status: old ? (c.newBlob ? "M" : "D") : "A", headBlob: c.newBlob });
253
+ }
254
+ }
255
+ return [...out.values()];
256
+ }
257
+
258
+ /**
259
+ * The files of the net change whose content no commit of the range produced: each commit's own
260
+ * change (a merge's own part) is what CI judged, so anything else landed unjudged.
261
+ * @param {ReturnType<typeof netChanges>} net
262
+ * @param {Map<string, Set<string>>} madeBy path and blob -> who made it
263
+ * @returns {string[]}
264
+ */
265
+ const unjudgedContent = (net, madeBy) => net.filter((c) => !madeBy.has(madeKey({ path: c.path, newBlob: c.headBlob }))).map((c) => c.path);
266
+
267
+ /** The rules no evidence can meet for a change no one made as such: off for the net change. */
268
+ const EVIDENCE_RULES = ["tests-run", "graph-before-edit", "docs-looked-up", "ui-verified", "schema-before-change", "review", "plan-before-change", "escalation"];
269
+
270
+ /**
271
+ * The blocking findings of the net change, judged as one change against the base under the base's
272
+ * rules: the deterministic ones over all of it, and the agent rules over the files an agent's
273
+ * commit made (or no attested person's), with the approvals the range recorded.
274
+ * @param {string} root
275
+ * @param {{ net: ReturnType<typeof netChanges>, madeBy: Map<string, Set<string>>, base: string, preset: string, modes: Record<string, any>, tests: string[] | null, events: import("../core/evidence.mjs").Event[], http?: import("../verify/deps.mjs").Http, scan: (root: string, changes: import("../core/diff.mjs").Change[], rulesAt: string) => import("../verify/secrets.mjs").SecretScan }} o
276
+ */
277
+ async function judgeNetChange(root, o) {
278
+ const list = o.net.map(({ headBlob: _, ...c }) => c);
279
+ if (!list.length) return [];
280
+ const modes = { ...o.modes, ...Object.fromEntries(EVIDENCE_RULES.map((r) => [r, "off"])) };
281
+ const deps = dependencyChanges(root, list);
282
+ const clean = { status: /** @type {const} */ ("clean"), findings: [], unpinned: [], unchecked: [] };
283
+ const common = { events: o.events, preset: o.preset, modes, depNames: deps.map((d) => d.name), testCommands: o.tests };
284
+ const everything = evaluate({ ...common, changes: list, actor: "person", testScripts: testScriptChanges(root, list), secrets: o.scan(root, list, o.base), deps: deps.length ? await checkDependencies(deps, { http: o.http }) : clean });
285
+ // Who made each file: a person's own test edit stays a person's, whatever else the range holds.
286
+ const agents = o.net.filter((c) => [...(o.madeBy.get(madeKey({ path: c.path, newBlob: c.headBlob })) ?? ["agent"])].some((who) => who !== "person")).map(({ headBlob: _, ...c }) => c);
287
+ const byAgents = agents.length ? evaluate({ ...common, changes: agents, actor: "agent", testScripts: testScriptChanges(root, agents), secrets: { status: "clean" }, deps: clean }) : [];
288
+ const seen = new Set();
289
+ return [...everything, ...byAgents].filter((f) => {
290
+ const id = `${f.rule}\0${f.message}`;
291
+ if (f.mode !== "block" || seen.has(id)) return false;
292
+ seen.add(id);
293
+ return true;
294
+ });
295
+ }
296
+
297
+ /**
298
+ * The attestation CI goes by, among the envelopes a commit's note holds (two developers' notes
299
+ * merge line by line): the first one signed by a trusted key about exactly this commit and tree,
300
+ * a checked one before an `unverified` one. Else the reason the best candidate failed.
301
+ * @param {any[]} envelopes
302
+ * @param {Map<string, string>} keys
303
+ * @param {string} commit
304
+ * @param {string} tree
305
+ * @returns {{ statement: any, envelope: any, problem: string | null }}
306
+ */
307
+ function pickAttestation(envelopes, keys, commit, tree) {
308
+ if (!envelopes.length) return { statement: null, envelope: null, problem: "no attestation: this commit was made without Trellis's hooks" };
309
+ let problem = "";
310
+ /** @type {{ statement: any, envelope: any, verdict: string } | null} */
311
+ let unverified = null;
312
+ for (const envelope of envelopes) {
313
+ const v = verifyEnvelope(envelope, keys);
314
+ if (!v.ok) problem ||= v.reason;
315
+ else if (v.statement.subject?.[0]?.digest?.gitCommit !== commit || v.statement.subject?.[0]?.digest?.gitTree !== tree) problem ||= "it is about another commit or tree";
316
+ // Only a checkpoint that passed vouches: `fail` is a commit the checkpoint refused, made anyway.
317
+ else if (v.statement.predicate?.verdict !== "pass") unverified ??= { statement: v.statement, envelope, verdict: String(v.statement.predicate?.verdict) };
318
+ else return { statement: v.statement, envelope, problem: null };
319
+ }
320
+ if (unverified?.verdict === "unverified") return { statement: unverified.statement, envelope: unverified.envelope, problem: "unverified: the commit skipped the checkpoint (git commit --no-verify, or a tool that skips hooks)" };
321
+ if (unverified) return { statement: unverified.statement, envelope: unverified.envelope, problem: `the checkpoint refused this commit (verdict ${unverified.verdict}), and it was committed anyway` };
322
+ return { statement: null, envelope: envelopes[0], problem };
323
+ }
324
+
325
+ /**
326
+ * @typedef {object} CiOptions
327
+ * @property {string} [base] the commit the range starts after (a PR's base)
328
+ * @property {string} [head] the commit the range ends at (a PR's head; default HEAD)
329
+ * @property {string} [summary] where to write the SLSA verification summary
330
+ * @property {string} [checksums] where to write each commit as `<sha256 of its raw object> git-commit-<id>`
331
+ * @property {NodeJS.ProcessEnv} [env]
332
+ * @property {import("../verify/deps.mjs").Http} [http]
333
+ * @property {(root: string, commit: string, opts: { base: string }) => import("../verify/secrets.mjs").SecretScan} [scanCommit]
334
+ * @property {(root: string, changes: import("../core/diff.mjs").Change[], rulesAt: string) => import("../verify/secrets.mjs").SecretScan} [scanChanges]
335
+ */
336
+
337
+ /**
338
+ * The authority: every commit after `base`, judged on another machine. Its attestation must be
339
+ * there, signed by a key the repository trusts, about exactly that commit and tree; the
340
+ * deterministic checks are run again here; and the rules are judged again here from the change
341
+ * and the evidence the attestation carries. The local verdict is read, never believed.
342
+ * @param {import("../core/context.mjs").Context} ctx
343
+ * @param {(...lines: string[]) => void} out
344
+ * @param {CiOptions} [opts]
345
+ * @returns {Promise<number>}
346
+ */
347
+ export async function verifyCi(ctx, out, opts = {}) {
348
+ const git = (/** @type {string[]} */ args) => spawnSync("git", args, { cwd: ctx.root, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
349
+ const base = opts.base ?? "HEAD~1";
350
+ // A pull request is judged at its head: the merge commit a CI checkout builds was made by no one.
351
+ const head = opts.head ?? "HEAD";
352
+ if (git(["rev-parse", "-q", "--verify", `${base}^{commit}`]).status !== 0) {
353
+ if (git(["rev-parse", "-q", "--verify", `${head}^{commit}`]).status === 0 && !git(["rev-list", "--parents", "-n", "1", head]).stdout.trim().includes(" ")) {
354
+ // Nothing stood before a repository's first commit: its own keys and rules would vouch for it.
355
+ out(`! trellis verify --ci: ${head} is the repository's first commit; there is no base to judge it by, so it is not judged.`);
356
+ return 0;
357
+ }
358
+ out(`✗ trellis verify --ci: the base ${base} is not in this clone. Check out the whole history (actions/checkout with fetch-depth: 0), or pass --base.`);
359
+ return 1;
360
+ }
361
+ const list = git(["rev-list", "--reverse", `${base}..${head}`]);
362
+ if (list.status !== 0) {
363
+ out(`✗ trellis verify --ci: the range ${base}..${head} could not be read (${(list.stderr || "").trim()}). Fetch the base, with its history.`);
364
+ return 1;
365
+ }
366
+ const commits = list.stdout.split(/\r?\n/).filter(Boolean);
367
+ // What judges a pull request is what stood before it: the keys and the rules at the base. A key
368
+ // or a config the range itself adds takes effect once merged, never for its own commits.
369
+ const keys = trustedKeys(ctx.root, base);
370
+ const baseText = git(["show", `${base}:trellis.config.json`]).stdout || "";
371
+ const baseConfig = parseJson(baseText || "null");
372
+ // A config that does not parse is no reason to fall back to the default preset.
373
+ if (baseText.trim() && baseConfig == null) {
374
+ out(`✗ trellis verify --ci: trellis.config.json at ${base} is not valid JSON; nothing was judged.`);
375
+ return 1;
376
+ }
377
+ if (baseConfig && validateConfig(baseConfig).length) {
378
+ out(`✗ trellis verify --ci: trellis.config.json at ${base} is invalid (${validateConfig(baseConfig).join("; ")}); nothing was judged.`);
379
+ return 1;
380
+ }
381
+ const preset = baseConfig?.verify?.preset ?? "recommended";
382
+ const modes = baseConfig?.verify?.rules ?? {};
383
+ const unattested = baseConfig?.verify?.unattested ?? "refuse";
384
+ // What counts as the tests is the base's too.
385
+ const tests = testCommands((p) => {
386
+ const r = git(["show", `${base}:${p}`]);
387
+ return r.status === 0 ? r.stdout : null;
388
+ });
389
+ const keysAdded = git(["diff", "--name-only", "--diff-filter=AM", base, head, "--", ".trellis/keys"]).stdout.trim();
390
+ if (keysAdded) out(`! keys: this range adds or changes ${keysAdded.split(/\r?\n/).join(", ")}; they are trusted once merged, not for these commits.`);
391
+ const scan = opts.scanCommit ?? scanCommitSecrets;
392
+ let failed = 0;
393
+ /** @type {Array<{ name: string, digest: Record<string, string> }>} */
394
+ const subjects = [];
395
+ /** @type {Array<{ uri: string, digest: Record<string, string> }>} */
396
+ const inputs = [];
397
+ /** @type {import("../core/evidence.mjs").Event[]} */
398
+ const rangeEvents = [];
399
+ /** @type {Map<string, Set<string>>} each file's content, as a commit of the range made it, by who */
400
+ const madeBy = new Map();
401
+ for (const commit of commits) {
402
+ const tree = git(["rev-parse", `${commit}^{tree}`]).stdout.trim();
403
+ subjects.push({ name: `git commit ${commit.slice(0, 12)}`, digest: { gitCommit: commit, gitTree: tree } });
404
+ /** @type {string[]} */
405
+ const problems = [];
406
+ // Re-judged here: the change itself, Trellis's own scans, and the evidence the attestation carries.
407
+ const changes = commitDiff(ctx.root, commit);
408
+ const { statement, envelope, problem } = pickAttestation(readNotes(ctx.root, commit), keys, commit, tree);
409
+ // A merge with nothing of its own (the host's merge button, a clean merge) vouches for
410
+ // nothing: each side is judged on its own commits. A squash merge has content, and is not one.
411
+ if (problem && !changes.length && parentsOf(ctx.root, commit).length > 1) problems.push("warn attestation: a merge with nothing of its own; its sides are judged on their own commits");
412
+ // verify.unattested "judge" (the base's): a commit made without Trellis (a fork's
413
+ // contributor, Dependabot, the web editor) is judged on its own, as an agent's, not refused.
414
+ else if (problem && unattested === "judge" && !statement) problems.push(`warn attestation: ${problem}; judged here on its own, as an agent's (verify.unattested)`);
415
+ else if (problem) problems.push(`block attestation: ${problem}`);
416
+ if (envelope) inputs.push({ uri: `git+notes:${NOTES_REF}@${commit}`, digest: { sha256: createHash("sha256").update(JSON.stringify(envelope)).digest("hex") } });
417
+ const deps = dependencyChanges(ctx.root, changes);
418
+ const findings = evaluate({
419
+ changes,
420
+ events: statement?.predicate?.events ?? [],
421
+ // Who made it is only known from the attestation, as strong as the developer's key. The
422
+ // rules are the base's, never the attestation's.
423
+ actor: statement?.predicate?.actor === "person" ? "person" : "agent",
424
+ preset,
425
+ modes,
426
+ // The allowlist is the base's, like the keys and the rules.
427
+ secrets: scan(ctx.root, commit, { base }),
428
+ deps: deps.length ? await checkDependencies(deps, { http: opts.http }) : { status: "clean", findings: [], unpinned: [], unchecked: [] },
429
+ depNames: deps.map((x) => x.name),
430
+ testCommands: tests,
431
+ tree,
432
+ testScripts: testScriptChanges(ctx.root, changes),
433
+ });
434
+ for (const f of findings) problems.push(`${f.mode} ${f.rule}: ${f.message}`);
435
+ rangeEvents.push(...(statement?.predicate?.events ?? []));
436
+ const who = statement?.predicate?.actor === "person" ? "person" : "agent";
437
+ for (const c of changes) {
438
+ if (!madeBy.has(madeKey(c))) madeBy.set(madeKey(c), new Set());
439
+ madeBy.get(madeKey(c))?.add(who);
440
+ }
441
+ const blocking = problems.filter((p) => p.startsWith("block "));
442
+ if (blocking.length) failed++;
443
+ out(`${blocking.length ? "✗" : "✓"} ${commit.slice(0, 12)} ${git(["log", "-1", "--format=%s", commit]).stdout.trim().slice(0, 72)}`);
444
+ for (const p of problems) out(` ${p.startsWith("block ") ? "✗" : "!"} ${p.replace(/^(block|warn) /, "")}`);
445
+ }
446
+ // What lands is the range's net change, against where it left the base. Each file of it must be
447
+ // as a commit of the range made it, and was judged as: content a merge took from elsewhere (an
448
+ // old ancestor, a branch outside the range) was judged by no one.
449
+ const net = netChanges(ctx.root, base, head);
450
+ const unjudged = unjudgedContent(net, madeBy);
451
+ if (unjudged.length) {
452
+ failed++;
453
+ out(`✗ net change: ${unjudged.slice(0, 6).join(", ")}${unjudged.length > 6 ? `, and ${unjudged.length - 6} more` : ""} land as no commit of the range made them (a merge took them from elsewhere). Make the change in a commit of its own.`);
454
+ }
455
+ // And the net change judged as one change: each commit is judged against its own parent, so a
456
+ // branch from an old ancestor can "add" a test the base already has, or keep a dependency the
457
+ // base upgraded, and a merge then lands it with nothing of its own. Against the base, that is
458
+ // an edit and a downgrade. The deterministic rules and the protected tests hold for it, as an
459
+ // agent's unless every commit is a person's, with the approvals the range recorded.
460
+ const netFindings = await judgeNetChange(ctx.root, { net, madeBy, base, preset, modes, tests, events: rangeEvents, http: opts.http, scan: opts.scanChanges ?? scanChanges });
461
+ if (netFindings.length) {
462
+ failed++;
463
+ out("✗ net change, judged as one change against the base:");
464
+ for (const f of netFindings) out(` ✗ ${f.rule}: ${f.message}`);
465
+ }
466
+ const passed = failed === 0;
467
+ if (opts.checksums) {
468
+ // actions/attest takes SHA-256 subjects; a commit's own id is not one. The SHA-256 of its raw
469
+ // object is, and anyone with the repository recomputes it: git cat-file commit <id> | sha256sum.
470
+ const lines = commits.map((c) => `${createHash("sha256").update(spawnSync("git", ["cat-file", "commit", c], { cwd: ctx.root, windowsHide: true }).stdout).digest("hex")} git-commit-${c}`);
471
+ writeFileSync(opts.checksums, lines.map((l) => `${l}\n`).join(""));
472
+ }
473
+ out(`trellis verify --ci: ${commits.length} commit(s), ${failed} refused.`);
474
+ if (opts.summary) {
475
+ const vsa = {
476
+ _type: "https://in-toto.io/Statement/v1",
477
+ subject: subjects,
478
+ predicateType: "https://slsa.dev/verification_summary/v1",
479
+ predicate: {
480
+ verifier: { id: "https://github.com/Synovitec/trellis", version: { trellis: VERSION } },
481
+ timeVerified: new Date().toISOString(),
482
+ resourceUri: `git+${git(["remote", "get-url", "origin"]).stdout.trim() || ctx.root}`,
483
+ policy: { uri: PREDICATE_TYPE },
484
+ inputAttestations: inputs,
485
+ verificationResult: passed ? "PASSED" : "FAILED",
486
+ verifiedLevels: ["SLSA_BUILD_LEVEL_UNEVALUATED"],
487
+ },
488
+ };
489
+ writeFileSync(opts.summary, `${JSON.stringify(vsa, null, 2)}\n`);
490
+ }
491
+ return passed ? 0 : 1;
492
+ }
493
+
494
+ /**
495
+ * `trellis evidence reset`: a person sets an unusable evidence log aside and starts a new one.
496
+ * Refused without a terminal, like approve: an agent's commands run without one.
497
+ * @param {import("../core/context.mjs").Context} ctx
498
+ * @param {(...lines: string[]) => void} out
499
+ * @param {{ env?: NodeJS.ProcessEnv, interactive?: boolean }} [opts]
500
+ */
501
+ export function evidenceReset(ctx, out, opts = {}) {
502
+ const interactive = opts.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
503
+ if (!interactive) {
504
+ out("trellis evidence reset is a person's step: run it yourself, in a terminal.");
505
+ return 1;
506
+ }
507
+ const kept = resetEvidence(ctx.root, opts.env ?? process.env);
508
+ out(kept ? `trellis evidence reset: the old log is kept as ${kept}; a new one starts now. The checks recorded before it no longer count: run them again.` : "trellis evidence reset: there was no log; nothing to set aside.");
509
+ return 0;
510
+ }
@@ -0,0 +1,25 @@
1
+ // The guard engine. `trellis gate`, `trellis explain` and `trellis prove` are abatty's commands,
2
+ // run as they are: Trellis frames the work around the gate, it does not reimplement it.
3
+ import { spawnSync } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ /** The abatty commands Trellis passes through unchanged. */
8
+ export const PASSTHROUGH = Object.freeze(["gate", "explain", "prove", "ratchet", "status", "rules"]);
9
+
10
+ /**
11
+ * Runs `abatty <args>` from the repository, preferring its own installed copy.
12
+ * @param {string} root
13
+ * @param {string[]} args
14
+ * @param {(line: string) => void} out
15
+ * @returns {number}
16
+ */
17
+ export function runAbatty(root, args, out) {
18
+ const local = join(root, "node_modules", "abatty", "bin", "abatty.mjs");
19
+ if (!existsSync(local)) {
20
+ out("abatty is not installed in this repository: `npm i -D abatty && npx abatty init` sets up the gate.");
21
+ return 1;
22
+ }
23
+ const r = spawnSync(process.execPath, [local, ...args], { cwd: root, stdio: "inherit" });
24
+ return r.status ?? 1;
25
+ }
@@ -0,0 +1,65 @@
1
+ // The managed block in AGENTS.md: the tool routing, generated from the config, between two
2
+ // markers. Trellis writes only between its markers; everything else in the file is the team's.
3
+ import { ROLES } from "../tools/roles.mjs";
4
+
5
+ /** What identifies the opening marker, whatever text follows it in an older version. */
6
+ const START_TAG = "<!-- trellis:start";
7
+ export const START = "<!-- trellis:start (generated by `trellis init`; edit trellis.config.json, not this block) -->";
8
+ export const END = "<!-- trellis:end -->";
9
+
10
+ /** @typedef {import("../tools/registry.mjs").Tool} Tool */
11
+
12
+ /**
13
+ * The block's text for the enabled tools, grouped by role.
14
+ * @param {Array<{ tool: Tool }>} enabled
15
+ */
16
+ export function renderBlock(enabled) {
17
+ const lines = [START, "## Tools (Trellis)", ""];
18
+ for (const role of ROLES) {
19
+ const tools = enabled.filter((e) => e.tool.role === role.id);
20
+ if (!tools.length) continue;
21
+ lines.push(`**${role.title}** - ${role.question}`, "");
22
+ for (const { tool } of tools) {
23
+ lines.push(`- **${tool.name}**: ${tool.use}${tool.rule ? ` ${tool.rule}` : ""}`);
24
+ }
25
+ lines.push("");
26
+ }
27
+ // What the commit checkpoint counts is what Trellis saw run: say how to be seen, and the way out.
28
+ lines.push(
29
+ "**Checks (Trellis)** - each commit is judged on what Trellis saw run, not on what is said.",
30
+ "",
31
+ "- Use the `trellis` MCP server's tools when they are offered: `graph_query` before changing code, `run_tests` for the tests, `check_change` to see what the next commit would be refused for.",
32
+ "- Without them: `npx --no-install @synovitec/trellis check tests -- <the project's test command>`.",
33
+ '- If a test or the task itself is wrong, do not edit the test: stop with the `escalate` tool, or `npx --no-install @synovitec/trellis escalate "<why>"`, and hand the change to the person.',
34
+ "",
35
+ );
36
+ lines.push("`npx @synovitec/trellis doctor` says which of these is installed and actually wired; `npx @synovitec/trellis guide <tool>` explains one.", END);
37
+ return lines.join("\n");
38
+ }
39
+
40
+ /**
41
+ * The file without the block, or null when it has none.
42
+ * @param {string | null} existing
43
+ */
44
+ export function removeBlock(existing) {
45
+ if (existing == null) return null;
46
+ const s = existing.indexOf(START_TAG);
47
+ const e = existing.indexOf(END);
48
+ if (s === -1 || e < s) return null;
49
+ return `${existing.slice(0, s).replace(/\s*$/, "")}\n${existing.slice(e + END.length).replace(/^\s*/, "\n")}`.replace(/\n{3,}/g, "\n\n");
50
+ }
51
+
52
+ /**
53
+ * The file with the block put in place: replaced between existing markers, else appended.
54
+ * @param {string | null} existing
55
+ * @param {string} block
56
+ * @returns {string}
57
+ */
58
+ export function upsertBlock(existing, block) {
59
+ if (existing == null || existing.trim() === "") return `# AGENTS.md\n\n${block}\n`;
60
+ const s = existing.indexOf(START_TAG);
61
+ const e = existing.indexOf(END);
62
+ if (s !== -1 && e > s) return existing.slice(0, s) + block + existing.slice(e + END.length);
63
+ if (s !== -1 || e !== -1) throw new Error("AGENTS.md has a trellis marker without its pair; restore both or remove both");
64
+ return `${existing.replace(/\s*$/, "")}\n\n${block}\n`;
65
+ }