@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,172 @@
1
+ // Writes the hooks of Cursor, Codex and Copilot, each in its own file and format (each agent's hook
2
+ // reference, read 2026-10-30), merged into what the team already has. Every command fails closed:
3
+ // with Trellis gone it blocks, never passes.
4
+ import { parseJson } from "../core/context.mjs";
5
+ import { installKind, SCRIPT } from "./install.mjs";
6
+
7
+ /** Where each agent reads its project hooks. */
8
+ export const ADAPTER_FILES = Object.freeze({ cursor: ".cursor/hooks.json", codex: ".codex/hooks.json", copilot: ".github/hooks/trellis.json" });
9
+
10
+ /**
11
+ * The agents this repository or machine shows signs of: the agent's folder or file in the
12
+ * repository, or its command on the PATH.
13
+ * @param {import("../core/context.mjs").Context} ctx
14
+ * @returns {string[]}
15
+ */
16
+ export function detectAgents(ctx) {
17
+ const found = [];
18
+ if (ctx.exists(".cursor") || ctx.which("cursor") || ctx.which("cursor-agent")) found.push("cursor");
19
+ if (ctx.exists(".codex") || ctx.which("codex")) found.push("codex");
20
+ if (ctx.exists(".github/copilot-instructions.md") || ctx.exists(".github/hooks") || ctx.which("copilot")) found.push("copilot");
21
+ return found;
22
+ }
23
+
24
+ const GONE = "Trellis is not installed here any more, so this agent cannot be checked. Reinstall it (npm i -D @synovitec/trellis).";
25
+
26
+ /** @param {"local" | "global"} how @param {string} event @param {string} agent */
27
+ const shellCommand = (how, event, agent, root = "") => {
28
+ const run = how === "global" ? `trellis hook ${event} --agent ${agent}` : `node "${root}${SCRIPT}" hook ${event} --agent ${agent}`;
29
+ const present = how === "global" ? "command -v trellis >/dev/null 2>&1" : `[ -f "${root}${SCRIPT}" ]`;
30
+ // Before a tool call, a check that could not run (node missing: 127; a crash: 1) refuses it.
31
+ const guarded = event === "pre-tool" ? `${run}; c=$?; if [ $c -ne 0 ] && [ $c -ne 2 ]; then echo "[trellis] the check could not run (exit $c), so this call is refused." >&2; exit 2; fi; exit $c` : run;
32
+ return `if ${present}; then ${guarded}; else echo '[trellis] ${GONE}' >&2; exit 2; fi`;
33
+ };
34
+
35
+ /** @param {"local" | "global"} how @param {string} event @param {string} agent */
36
+ const powershellCommand = (how, event, agent) => {
37
+ const run = how === "global" ? `trellis hook ${event} --agent ${agent}` : `node '${SCRIPT}' hook ${event} --agent ${agent}`;
38
+ const present = how === "global" ? "Get-Command trellis -ErrorAction SilentlyContinue" : `Test-Path '${SCRIPT}'`;
39
+ // A missing node leaves no exit code, a crash another than 0 or 2: before a tool call, both refuse.
40
+ const after = event === "pre-tool" ? `$c = if ($?) { $LASTEXITCODE } else { 1 }; if ($c -ne 0 -and $c -ne 2) { [Console]::Error.WriteLine("[trellis] the check could not run (exit $c), so this call is refused."); exit 2 }; exit $c` : "exit $LASTEXITCODE";
41
+ return `if (${present}) { ${run}; ${after} } else { [Console]::Error.WriteLine('[trellis] ${GONE}'); exit 2 }`;
42
+ };
43
+
44
+ /**
45
+ * The same hook as one `node -e` line, for Codex on Windows (`commandWindows`), whose shell the
46
+ * docs do not name: the script holds no `"`, `$`, `%` or backtick, so cmd.exe and PowerShell
47
+ * pass it alike. It finds the repository through git, fails closed when Trellis is missing (a
48
+ * JSON block on stdout: Windows PowerShell turns an exit 2 into 1), and hands the hook its stdin.
49
+ * @param {"local" | "global"} how @param {string} event @param {string} agent
50
+ */
51
+ const nodeCommand = (how, event, agent) => {
52
+ const fail = `console.log(JSON.stringify({decision:'block',reason:'[trellis] ${GONE}'}));console.error('[trellis] ${GONE}');process.exit(0)`;
53
+ const args = `['hook','${event}','--agent','${agent}']`;
54
+ const js =
55
+ how === "global"
56
+ ? `const c=require('child_process');try{c.execFileSync('where',['trellis'],{stdio:'ignore'})}catch(e){${fail}}const x=c.spawnSync('trellis',${args},{stdio:'inherit',shell:true});if(x.status!==0){${fail}}process.exit(0)`
57
+ : `const c=require('child_process'),f=require('fs'),p=require('path');let r='';try{r=c.execFileSync('git',['rev-parse','--show-toplevel'],{encoding:'utf8'}).trim()}catch(e){}const s=p.join(r,'${SCRIPT}');if(!r||!f.existsSync(s)){${fail}}const x=c.spawnSync(process.execPath,[s,...${args}],{stdio:'inherit'});if(x.status!==0){${fail}}process.exit(0)`;
58
+ return `node -e "${js}"`;
59
+ };
60
+
61
+ /** An entry is Trellis's when it runs `hook <event> --agent`, in any version's spelling. @param {any} entry */
62
+ const isTrellis = (entry) => /trellis(\.mjs)?["']?\s+hook\s+(pre|post)-tool\s+--agent\b/.test(JSON.stringify(entry ?? {}));
63
+
64
+ /**
65
+ * Trellis's entries for one agent, by event key.
66
+ * @param {string} agent
67
+ * @param {"local" | "global"} how
68
+ * @returns {Record<string, any>}
69
+ */
70
+ function entriesFor(agent, how) {
71
+ if (agent === "cursor") {
72
+ // Cursor runs project hooks from the project root.
73
+ const run = (/** @type {string} */ event) => (how === "global" ? `trellis hook ${event} --agent cursor` : `node ${SCRIPT} hook ${event} --agent cursor`);
74
+ return { preToolUse: { command: run("pre-tool"), timeout: 30, failClosed: true }, postToolUse: { command: run("post-tool"), timeout: 30 } };
75
+ }
76
+ if (agent === "codex") {
77
+ // Codex runs a hook in the session's folder: reach the repository root through git. On
78
+ // Windows it runs `commandWindows` instead, which needs no POSIX shell.
79
+ const cmd = (/** @type {string} */ event) => `R="$(git rev-parse --show-toplevel)/"; ${shellCommand(how, event, "codex", "$R")}`;
80
+ const hook = (/** @type {string} */ event) => ({ type: "command", command: cmd(event), commandWindows: nodeCommand(how, event, "codex"), timeout: 30 });
81
+ return { PreToolUse: { hooks: [hook("pre-tool")] }, PostToolUse: { hooks: [hook("post-tool")] } };
82
+ }
83
+ // Copilot: PascalCase events get the Claude-shaped payload; one command per shell.
84
+ const entry = (/** @type {string} */ event) => ({ type: "command", bash: shellCommand(how, event, "copilot"), powershell: powershellCommand(how, event, "copilot"), timeoutSec: 30 });
85
+ return { PreToolUse: entry("pre-tool"), PostToolUse: entry("post-tool") };
86
+ }
87
+
88
+ /**
89
+ * The hook files to write for `agents`: Trellis's entries added or brought up to date, the rest of
90
+ * each file kept as the team wrote it. An invalid file is left alone, with a note.
91
+ * @param {import("../core/context.mjs").Context} ctx
92
+ * @param {string[]} agents
93
+ * @returns {{ writes: Array<{ path: string, text: string, why: string }>, notes: string[] }}
94
+ */
95
+ export function planAdapterHooks(ctx, agents) {
96
+ const how = installKind(ctx) === "global" ? "global" : "local";
97
+ const writes = [];
98
+ const notes = [];
99
+ for (const agent of agents) {
100
+ const path = ADAPTER_FILES[/** @type {keyof typeof ADAPTER_FILES} */ (agent)];
101
+ if (!path) continue;
102
+ const raw = ctx.read(path);
103
+ const existing = raw == null ? {} : parseJson(raw);
104
+ if (existing == null || typeof existing !== "object" || Array.isArray(existing)) {
105
+ notes.push(`${path} is not valid JSON: left as it is, and no ${agent} hook written. Fix it and run init again.`);
106
+ continue;
107
+ }
108
+ const next = structuredClone(existing);
109
+ if (agent !== "codex") next.version ??= 1;
110
+ next.hooks ??= {};
111
+ for (const [event, entry] of Object.entries(entriesFor(agent, how))) {
112
+ const list = Array.isArray(next.hooks[event]) ? next.hooks[event].filter((e) => !isTrellis(e)) : [];
113
+ next.hooks[event] = [...list, entry];
114
+ }
115
+ // Compared as content: a file that already says this is left as it is, formatting included.
116
+ if (raw != null && JSON.stringify(next) === JSON.stringify(existing)) continue;
117
+ writes.push({ path, text: `${JSON.stringify(next, null, 2)}\n`, why: `${agent}: Trellis's guard and evidence on every tool call` });
118
+ }
119
+ return { writes, notes };
120
+ }
121
+
122
+ /**
123
+ * The gateway's entry for a shared, committed config: found through npm from wherever the agent
124
+ * launches it (npm walks up to the project's node_modules), and never downloaded: `--no-install`
125
+ * fails rather than fetch something else, and the name is always the scoped one.
126
+ * @param {import("../core/context.mjs").Context} ctx
127
+ */
128
+ const gatewayCommand = (ctx) => (installKind(ctx) === "global" ? { command: "trellis", args: ["mcp"] } : { command: "npx", args: ["--no-install", "@synovitec/trellis", "mcp"] });
129
+
130
+ /** Whether a server entry already runs Trellis's gateway. @param {any} s */
131
+ const runsGateway = (s) => /trellis(\.mjs)?["']?,?\s*"?mcp\b/.test(JSON.stringify([s?.command, ...(s?.args ?? [])]));
132
+
133
+ /**
134
+ * The gateway declared in each agent's own MCP config: Cursor's .cursor/mcp.json, Codex's
135
+ * .codex/config.toml (read in trusted projects), and VS Code's .vscode/mcp.json for Copilot (still
136
+ * read, though VS Code's guided setup labels it deprecated). Merged into what is there.
137
+ * @param {import("../core/context.mjs").Context} ctx
138
+ * @param {string[]} agents
139
+ * @returns {{ writes: Array<{ path: string, text: string, why: string }>, notes: string[] }}
140
+ */
141
+ export function planAgentMcp(ctx, agents) {
142
+ const { command, args } = gatewayCommand(ctx);
143
+ const writes = [];
144
+ const notes = [];
145
+ const why = "the Trellis gateway: the repository's checks as tools, recorded as evidence";
146
+ for (const [agent, path, key] of /** @type {const} */ ([["cursor", ".cursor/mcp.json", "mcpServers"], ["copilot", ".vscode/mcp.json", "servers"]])) {
147
+ if (!agents.includes(agent)) continue;
148
+ const raw = ctx.read(path);
149
+ const doc = raw == null ? {} : parseJson(raw);
150
+ if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
151
+ notes.push(`${path} is not valid JSON: left as it is, and the gateway not declared for ${agent}.`);
152
+ continue;
153
+ }
154
+ const servers = doc[key] && typeof doc[key] === "object" ? doc[key] : {};
155
+ if (Object.values(servers).some(runsGateway)) continue;
156
+ doc[key] = { ...servers, [servers.trellis ? "trellis-gateway" : "trellis"]: { type: "stdio", command, args } };
157
+ writes.push({ path, text: `${JSON.stringify(doc, null, 2)}\n`, why });
158
+ }
159
+ if (agents.includes("codex")) {
160
+ const path = ".codex/config.toml";
161
+ const raw = ctx.read(path) ?? "";
162
+ // Appended as its own table: the rest of the file is the team's, and is not rewritten.
163
+ // Declared already: our table, or any server whose args run the gateway (scoped name or script).
164
+ const declared = /^\s*\[mcp_servers\.trellis\]\s*$/m.test(raw) || /"@synovitec\/trellis",\s*"mcp"|trellis\.mjs",\s*"mcp"/.test(raw);
165
+ if (!declared) {
166
+ const sep = raw === "" || raw.endsWith("\n") ? "" : "\n";
167
+ const table = `[mcp_servers.trellis]\ncommand = ${JSON.stringify(command)}\nargs = [${args.map((a) => JSON.stringify(a)).join(", ")}]\n`;
168
+ writes.push({ path, text: `${raw}${sep}${raw ? "\n" : ""}${table}`, why });
169
+ }
170
+ }
171
+ return { writes, notes };
172
+ }
@@ -0,0 +1,302 @@
1
+ // Putting the agent hooks in place, upgrading them, and taking them out: four entries and a few
2
+ // deny rules in .claude/settings.json, and, when there is none, a pre-commit hook running the
3
+ // secret scan. Only Trellis's own entries are ever rewritten; a settings file that cannot be
4
+ // parsed is refused, not replaced.
5
+ //
6
+ // The hooks call the installed script with node, or a global `trellis` in a repository with no
7
+ // node_modules, never through npx: `npx trellis` resolves to an unrelated package on the registry
8
+ // when this one is not installed, and a hook must not download anything.
9
+ import { commandsIn, flagValue } from "./shell.mjs";
10
+ import { isAbsolute, relative, resolve } from "node:path";
11
+ import { git, hooksDir } from "./git.mjs";
12
+ import { parseJson } from "../core/context.mjs";
13
+
14
+ /** @typedef {import("../core/context.mjs").Context} Context */
15
+
16
+ export const SETTINGS = ".claude/settings.json";
17
+ export const SCRIPT = "node_modules/@synovitec/trellis/bin/trellis.mjs";
18
+ export const PRE_COMMIT_MARK = "Written by `trellis init --agents`";
19
+
20
+ /** @type {Record<string, { event: string, matcher?: string, timeout: number }>} */
21
+ export const HOOKS = Object.freeze({
22
+ SessionStart: { event: "session-start", timeout: 60 },
23
+ PreToolUse: { event: "pre-tool", matcher: "Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell|mcp__.*", timeout: 30 },
24
+ PostToolUse: { event: "post-tool", matcher: "Bash|PowerShell|Grep|Glob|mcp__.*", timeout: 30 },
25
+ // Stop runs git, gitleaks (40 s at most) and AgentShield (60 s at most); its own budget is
26
+ // larger than both together, so a slow scan is reported, not cut off into a silent pass.
27
+ Stop: { event: "stop", timeout: 180 },
28
+ });
29
+
30
+ /**
31
+ * The evidence the agent must not write, and the key it must not read. Deny rules make tampering
32
+ * a deliberate act through the shell, which the PreToolUse guard then refuses too.
33
+ */
34
+ export const DENY = Object.freeze([
35
+ "Edit(./.git/trellis/**)",
36
+ "Edit(./.git/info/exclude)",
37
+ "Read(~/.trellis/**)",
38
+ "Edit(~/.trellis/**)",
39
+ "Edit(~/.claude/projects/**/*.jsonl)",
40
+ "Edit(./node_modules/@synovitec/trellis/**)",
41
+ "Edit(./.git/logs/**)",
42
+ ]);
43
+
44
+ const GONE = "echo '[trellis] Trellis is not installed here any more, so this session cannot be checked. Ask the person to reinstall it (npm i -D @synovitec/trellis).' >&2; exit 2";
45
+
46
+ /**
47
+ * The command a hook runs: the project's installed script, from the project root whatever the
48
+ * session's working folder; or a global install for a repository without node_modules. When
49
+ * Trellis is gone (`git clean -fdx`, `npm ci --omit=dev`), the command exits 2, which Claude
50
+ * Code treats as blocking: node's own "module not found" exits 1, which it ignores, and the
51
+ * session would end unchecked.
52
+ * @param {string} event
53
+ * @param {"local" | "global"} [how]
54
+ */
55
+ export const hookCommand = (event, how = "local") => {
56
+ const run = how === "global" ? `trellis hook ${event}` : `node "$CLAUDE_PROJECT_DIR/${SCRIPT}" hook ${event}`;
57
+ // Before a tool call, a check that could not run (node missing: 127; a crash: 1) refuses the
58
+ // call rather than let it through. Only there: a 2 from Stop would keep the session open.
59
+ const guarded = event === "pre-tool" ? `${run}; c=$?; if [ $c -ne 0 ] && [ $c -ne 2 ]; then echo "[trellis] the check could not run (exit $c), so this call is refused." >&2; exit 2; fi; exit $c` : run;
60
+ return how === "global"
61
+ ? `if command -v trellis >/dev/null 2>&1; then ${guarded}; else ${GONE}; fi`
62
+ : `if [ -f "$CLAUDE_PROJECT_DIR/${SCRIPT}" ]; then ${guarded}; else ${GONE}; fi`;
63
+ };
64
+
65
+ /** How this repository reaches Trellis. @param {Context} ctx @returns {"local" | "global" | null} */
66
+ export function installKind(ctx) {
67
+ if (ctx.exists(SCRIPT)) return "local";
68
+ if (ctx.which("trellis")) return "global";
69
+ return null;
70
+ }
71
+
72
+ /** A settings hook entry that is Trellis's, for one event, in any version's spelling. @param {any} h @param {string} event */
73
+ const isTrellisHook = (h, event) => new RegExp(`trellis(\\.mjs)?"?\\s+hook\\s+${event}\\b`).test(`${h?.command ?? ""} ${(h?.args ?? []).join(" ")}`);
74
+
75
+ /**
76
+ * The state of each hook: current, outdated (an older Trellis spelling, matcher or timeout),
77
+ * broken (current, but what it runs is not installed, so it fails on every event), or missing.
78
+ * @param {Context} ctx
79
+ */
80
+ export function hookState(ctx) {
81
+ const settings = parseJson(ctx.read(SETTINGS)) ?? {};
82
+ /** @type {{ present: string[], outdated: string[], broken: string[], missing: string[] }} */
83
+ const out = { present: [], outdated: [], broken: [], missing: [] };
84
+ const kind = installKind(ctx);
85
+ for (const [name, spec] of Object.entries(HOOKS)) {
86
+ const groups = settings?.hooks?.[name] ?? [];
87
+ const ours = groups.flatMap((/** @type {any} */ g) => (g?.hooks ?? []).filter((/** @type {any} */ h) => isTrellisHook(h, spec.event)).map((/** @type {any} */ h) => ({ g, h })));
88
+ const current = ours.find(({ g, h }) => [hookCommand(spec.event, "local"), hookCommand(spec.event, "global")].includes(h.command) && (g.matcher ?? undefined) === spec.matcher && h.timeout === spec.timeout);
89
+ if (!ours.length) out.missing.push(name);
90
+ else if (!current) out.outdated.push(name);
91
+ else if (current.h.command === hookCommand(spec.event, "local") ? kind !== "local" : !kind) out.broken.push(name);
92
+ else out.present.push(name);
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /** Deny rules Trellis wants that the settings do not have. @param {any} settings */
98
+ const missingDenies = (settings) => DENY.filter((d) => !(settings?.permissions?.deny ?? []).includes(d));
99
+
100
+ /**
101
+ * @param {any} settings
102
+ * @param {(name: string) => boolean} keep whether Trellis's entry for a hook stays
103
+ */
104
+ function withoutTrellis(settings, keep) {
105
+ for (const [name, spec] of Object.entries(HOOKS)) {
106
+ if (keep(name) || !Array.isArray(settings?.hooks?.[name])) continue;
107
+ settings.hooks[name] = settings.hooks[name]
108
+ .map((/** @type {any} */ g) => ({ ...g, hooks: (g?.hooks ?? []).filter((/** @type {any} */ h) => !isTrellisHook(h, spec.event)) }))
109
+ .filter((/** @type {any} */ g) => g.hooks.length);
110
+ if (!settings.hooks[name].length) delete settings.hooks[name];
111
+ }
112
+ if (settings.hooks && !Object.keys(settings.hooks).length) delete settings.hooks;
113
+ }
114
+
115
+ /** Keep the file's own indentation, so the diff is the hooks and nothing else. @param {string | null} raw @param {any} value */
116
+ const serialise = (raw, value) => `${JSON.stringify(value, null, raw && /\n\t/.test(raw) ? "\t" : 2)}\n`;
117
+
118
+ /**
119
+ * The settings with Trellis's hooks and deny rules current: missing ones added, outdated replaced.
120
+ * @param {Context} ctx
121
+ * @returns {{ text: string | null, added: string[], upgraded: string[], denies: string[] }}
122
+ */
123
+ export function planSettings(ctx) {
124
+ const raw = ctx.read(SETTINGS);
125
+ const settings = raw == null ? {} : parseJson(raw);
126
+ if (settings == null) throw new Error(`${SETTINGS} is not valid JSON; fix it before trellis adds its hooks`);
127
+ const state = hookState(ctx);
128
+ const todo = [...state.missing, ...state.outdated];
129
+ const denies = missingDenies(settings);
130
+ if (!todo.length && !denies.length) return { text: null, added: [], upgraded: [], denies: [] };
131
+ const how = installKind(ctx) === "global" ? "global" : "local";
132
+ withoutTrellis(settings, (name) => !state.outdated.includes(name));
133
+ settings.hooks ??= {};
134
+ for (const name of todo) {
135
+ const spec = HOOKS[name];
136
+ (settings.hooks[name] ??= []).push({ ...(spec.matcher ? { matcher: spec.matcher } : {}), hooks: [{ type: "command", command: hookCommand(spec.event, how), timeout: spec.timeout }] });
137
+ }
138
+ if (denies.length) {
139
+ settings.permissions ??= {};
140
+ settings.permissions.deny = [...(settings.permissions.deny ?? []), ...denies];
141
+ }
142
+ return { text: serialise(raw, settings), added: state.missing, upgraded: state.outdated, denies };
143
+ }
144
+
145
+ /** The pre-commit path git actually runs, relative to the root when inside it. @param {Context} ctx */
146
+ export function preCommitPath(ctx) {
147
+ const dir = hooksDir(ctx.root);
148
+ return dir && !hooksOutside(ctx, dir) ? `${dir}/pre-commit` : null;
149
+ }
150
+
151
+ /**
152
+ * Whether git's hooks folder lies outside both the work tree and the git folder: a machine-wide
153
+ * `core.hooksPath` that every repository on the machine runs. A hook written there would run a
154
+ * path that only exists here, and fail every commit elsewhere.
155
+ * @param {Context} ctx
156
+ * @param {string} dir as `git rev-parse --git-path hooks` gives it
157
+ */
158
+ function hooksOutside(ctx, dir) {
159
+ const abs = resolve(ctx.root, dir);
160
+ const common = git(ctx.root, ["rev-parse", "--path-format=absolute", "--git-common-dir"])?.trim();
161
+ return ![ctx.root, common].filter(Boolean).some((base) => {
162
+ const rel = relative(resolve(/** @type {string} */ (base)), abs);
163
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
164
+ });
165
+ }
166
+
167
+ /** Whether a hook script runs the secret scan, read as commands (a comment does not count). @param {string} text */
168
+ export function runsSecretScan(text) {
169
+ return commandsIn(text).some((c) => {
170
+ if (c.prog === "gitleaks") return ["git", "dir", "detect", "protect", "stdin"].includes(c.args[0] ?? "");
171
+ if (c.prog === "trellis" && c.args[0] === "verify") return true;
172
+ if (c.prog !== "trellis" || c.args[0] !== "scan") return false;
173
+ const only = flagValue(c.args, "--only");
174
+ return only == null || only.split(",").includes("gitleaks");
175
+ });
176
+ }
177
+
178
+ /** How a git hook reaches Trellis, and what it says when Trellis is missing. @param {"local" | "global"} how */
179
+ const hookPrelude = (how) =>
180
+ how === "global"
181
+ ? `if ! command -v trellis >/dev/null 2>&1; then\n echo "trellis is not installed. Install it, or remove this hook." >&2\n exit 1\nfi`
182
+ : `if [ ! -f ${SCRIPT} ]; then\n echo "trellis: ${SCRIPT} is missing. Run npm install, or remove this hook with npx @synovitec/trellis uninstall." >&2\n exit 1\nfi`;
183
+ /** @param {"local" | "global"} how @param {string} command */
184
+ const trellisRun = (how, command) => (how === "global" ? `trellis ${command}` : `node ${SCRIPT} ${command}`);
185
+
186
+ /**
187
+ * The pre-commit hook Trellis writes: the commit checkpoint, `trellis verify`, which also runs the
188
+ * secret scan. The script is named literally so the hook reads as what it runs.
189
+ * @param {"local" | "global"} how
190
+ */
191
+ export function preCommitText(how) {
192
+ return `#!/bin/sh\n# ${PRE_COMMIT_MARK}: the commit checkpoint on every commit, whoever writes it.\n# Remove with \`npx @synovitec/trellis uninstall\`.\n${hookPrelude(how)}\nexec ${trellisRun(how, "verify")}\n`;
193
+ }
194
+
195
+ /**
196
+ * A hook that runs one Trellis command, with what it is for. post-rewrite passes its stdin (the
197
+ * old and new commit ids) through, since `exec` keeps it.
198
+ * @param {string} why @param {string} command @param {"local" | "global"} how
199
+ */
200
+ const hookText = (why, command, how) => `#!/bin/sh\n# ${PRE_COMMIT_MARK}: ${why}.\n# Remove with \`npx @synovitec/trellis uninstall\`.\n${hookPrelude(how)}\nexec ${trellisRun(how, command)}\n`;
201
+
202
+ /**
203
+ * The post-commit hook: attests the commit, and the next change starts a new evidence window. It
204
+ * cannot fail the commit (git ignores a post-commit's exit), so a missing Trellis only says so.
205
+ * @param {"local" | "global"} how
206
+ */
207
+ export const postCommitText = (how) => hookText("start the next change's evidence window", "checkpoint", how);
208
+
209
+ /**
210
+ * The pre-push hook: pushes the attestations (refs/notes/trellis) to the same remote, since git
211
+ * does not push notes by default. When a colleague pushed theirs first, it merges them in line by
212
+ * line (each line is one signed envelope, so nobody's is lost) and pushes again. Its own pushes
213
+ * skip hooks (no loop); a failure says so and lets the push go on: CI reports a commit whose
214
+ * attestation did not arrive.
215
+ */
216
+ export function prePushText() {
217
+ return `#!/bin/sh
218
+ # ${PRE_COMMIT_MARK}: the commits' attestations travel with the push.
219
+ # Remove with \`npx @synovitec/trellis uninstall\`.
220
+ if git rev-parse -q --verify refs/notes/trellis >/dev/null; then
221
+ if ! git push --no-verify --quiet "$1" refs/notes/trellis 2>/dev/null; then
222
+ { git fetch --quiet "$1" "+refs/notes/trellis:refs/notes/trellis-incoming" &&
223
+ git notes --ref trellis merge --quiet -s cat_sort_uniq refs/notes/trellis-incoming &&
224
+ git push --no-verify --quiet "$1" refs/notes/trellis; } 2>/dev/null ||
225
+ echo "trellis: the attestations (refs/notes/trellis) could not be pushed to $1; CI will report those commits." >&2
226
+ fi
227
+ fi
228
+ exit 0
229
+ `;
230
+ }
231
+
232
+ /**
233
+ * Every git hook of the commit checkpoint. A merge runs pre-merge-commit and post-merge, never
234
+ * pre-commit or post-commit; a rebase runs post-commit per commit and then post-rewrite.
235
+ * @type {Array<{ name: string, command: string, run?: string, why: string, text: (how: "local" | "global") => string }>}
236
+ */
237
+ const GIT_HOOKS = [
238
+ { name: "pre-commit", command: "verify", why: "the commit checkpoint on every commit", text: preCommitText },
239
+ { name: "pre-merge-commit", command: "verify", why: "the commit checkpoint on every merge commit", text: (how) => hookText("the commit checkpoint on every merge commit, whoever writes it", "verify", how) },
240
+ { name: "post-commit", command: "checkpoint", why: "attests the commit and starts the next change's evidence window", text: postCommitText },
241
+ { name: "post-merge", command: "checkpoint", run: "checkpoint --merge", why: "attests a merge commit (never a fast-forward)", text: (how) => hookText("attest the merge commit just made", "checkpoint --merge", how) },
242
+ { name: "post-rewrite", command: "checkpoint", run: 'checkpoint --rewritten "$1"', why: "a rebased or amended commit keeps its attestation when its change is the same", text: (how) => hookText("carry attestations to rebased and amended commits", 'checkpoint --rewritten "$1"', how) },
243
+ { name: "pre-push", command: "push", why: "sends the commits' attestations with the push", text: prePushText },
244
+ ];
245
+
246
+ /**
247
+ * The git hooks of the commit checkpoint: pre-commit runs `trellis verify`, post-commit
248
+ * `trellis checkpoint`. A hook Trellis wrote is brought up to date; a team's own hook is left to
249
+ * the team, and the note says the line to add to it.
250
+ * @param {Context} ctx
251
+ * @returns {{ writes: Array<{ path: string, text: string, why: string }>, notes: string[] }}
252
+ */
253
+ export function planGitHooks(ctx) {
254
+ const pre = preCommitPath(ctx);
255
+ if (!pre) {
256
+ const dir = hooksDir(ctx.root);
257
+ if (dir) return { writes: [], notes: [`git runs hooks from ${dir}, outside this repository (core.hooksPath): no hook written there, since every repository on the machine would run it. Add \`trellis verify\` to this repository's own pre-commit and pre-merge-commit, \`trellis checkpoint\` to its post-commit, \`trellis checkpoint --merge\` to its post-merge and \`trellis checkpoint --rewritten "$1"\` to its post-rewrite.`] };
258
+ return { writes: [], notes: ["not a git repository: no git hook written"] };
259
+ }
260
+ const how = installKind(ctx) === "global" ? "global" : "local";
261
+ const writes = [];
262
+ const notes = [];
263
+ for (const { name, text: textOf, command, run, why } of GIT_HOOKS) {
264
+ const path = pre.replace(/pre-commit$/, name);
265
+ const text = textOf(how);
266
+ const existing = ctx.read(path);
267
+ if (existing === text) continue;
268
+ if (existing == null || existing.includes(PRE_COMMIT_MARK)) writes.push({ path, text, why });
269
+ else if (command === "push") {
270
+ if (!/refs\/notes\/trellis/.test(existing)) notes.push(`${path} is the team's own: add \`git push --no-verify --quiet "$1" refs/notes/trellis\` to it, so the attestations reach CI`);
271
+ } else if (!commandsIn(existing).some((c) => c.prog === "trellis" && c.args[0] === command)) notes.push(`${path} is the team's own: add \`${trellisRun(how, run ?? command)}\` to it`);
272
+ }
273
+ return { writes, notes };
274
+ }
275
+
276
+ /**
277
+ * Everything `trellis uninstall` changes: Trellis's hook entries and deny rules out of the
278
+ * settings, its pre-commit hook deleted (only when it is the one Trellis wrote).
279
+ * @param {Context} ctx
280
+ * @returns {{ writes: Array<{ path: string, text: string }>, deletes: string[] }}
281
+ */
282
+ export function planUninstallHooks(ctx) {
283
+ const writes = [];
284
+ const deletes = [];
285
+ const raw = ctx.read(SETTINGS);
286
+ const settings = parseJson(raw);
287
+ if (settings) {
288
+ const before = JSON.stringify(settings);
289
+ withoutTrellis(settings, () => false);
290
+ if (Array.isArray(settings.permissions?.deny)) {
291
+ settings.permissions.deny = settings.permissions.deny.filter((/** @type {string} */ d) => !DENY.includes(d));
292
+ if (!settings.permissions.deny.length) delete settings.permissions.deny;
293
+ if (!Object.keys(settings.permissions).length) delete settings.permissions;
294
+ }
295
+ if (JSON.stringify(settings) !== before) writes.push({ path: SETTINGS, text: serialise(raw, settings) });
296
+ }
297
+ const pre = preCommitPath(ctx);
298
+ for (const path of pre ? GIT_HOOKS.map((h) => pre.replace(/pre-commit$/, h.name)) : []) {
299
+ if ((ctx.read(path) ?? "").includes(PRE_COMMIT_MARK)) deletes.push(path);
300
+ }
301
+ return { writes, deletes };
302
+ }
@@ -0,0 +1,127 @@
1
+ // Dependencies a manifest gained between two versions of it, for the ecosystems a repository is
2
+ // likely to use. A dependency written into a manifest by hand is added all the same as one added
3
+ // by a command, so both go through the same check.
4
+
5
+ /**
6
+ * @param {string} path
7
+ * @param {string | null} before
8
+ * @param {string | null} after
9
+ * @returns {string[]}
10
+ */
11
+ export function addedDependencies(path, before, after) {
12
+ const read = readerFor(path);
13
+ if (!read || after == null) return [];
14
+ const had = new Set(read(before ?? ""));
15
+ return [...new Set(read(after))].filter((d) => !had.has(d));
16
+ }
17
+
18
+ /**
19
+ * Every dependency a manifest declares, or null when the file is not a manifest Trellis reads.
20
+ * @param {string} path
21
+ * @param {string} text
22
+ */
23
+ export function dependenciesOf(path, text) {
24
+ const read = readerFor(path);
25
+ return read ? read(text) : null;
26
+ }
27
+
28
+ /** Manifest files Trellis reads, at a repository's root or a workspace's. */
29
+ export const MANIFESTS = Object.freeze(["package.json", "requirements.txt", "requirements-dev.txt", "pyproject.toml", "Cargo.toml", "go.mod", "composer.json", "Gemfile"]);
30
+
31
+ /** @param {string} path @returns {((text: string) => string[]) | null} */
32
+ function readerFor(path) {
33
+ const file = path.slice(path.lastIndexOf("/") + 1);
34
+ if (file === "package.json" || file === "composer.json") return jsonDeps;
35
+ if (/^requirements.*\.txt$/.test(file)) return requirements;
36
+ if (file === "pyproject.toml") return pyproject;
37
+ if (file === "Cargo.toml") return (t) => tomlTable(t, /^\[(.*\.)?(dev-|build-)?dependencies\]$/);
38
+ if (file === "go.mod") return goMod;
39
+ if (file === "Gemfile") return (t) => [...t.matchAll(/^\s*gem\s+['"]([^'"]+)['"]/gm)].map((m) => m[1]);
40
+ return null;
41
+ }
42
+
43
+ /** @param {string} text */
44
+ function jsonDeps(text) {
45
+ let p;
46
+ try {
47
+ p = JSON.parse(text || "{}");
48
+ } catch {
49
+ return [];
50
+ }
51
+ return ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies", "require", "require-dev"].flatMap((k) => Object.keys(p?.[k] ?? {}));
52
+ }
53
+
54
+ /** `name==1.0`, `name[extra]>=2`, comments and options skipped. @param {string} text */
55
+ function requirements(text) {
56
+ return text
57
+ .split(/\r?\n/)
58
+ .map((l) => l.replace(/#.*/, "").trim())
59
+ .filter((l) => l && !l.startsWith("-"))
60
+ .map((l) => l.split(/[\s\[<>=!~;@]/)[0].toLowerCase())
61
+ .filter(Boolean);
62
+ }
63
+
64
+ /**
65
+ * The dependency arrays of a pyproject.toml, and nothing else: `[project] dependencies`, every
66
+ * array of `[project.optional-dependencies]` and `[dependency-groups]`, and Poetry's tables.
67
+ * `classifiers`, `keywords` or a linter's `select = [...]` are lists, not dependencies.
68
+ * @param {string} text
69
+ */
70
+ function pyproject(text) {
71
+ const names = [];
72
+ let table = "";
73
+ const lines = text.split(/\r?\n/);
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const line = lines[i].replace(/#.*$/, "").trim();
76
+ const header = line.match(/^\[([^\]]+)\]$/);
77
+ if (header) {
78
+ table = header[1].trim();
79
+ continue;
80
+ }
81
+ const m = line.match(/^([\w.\-"']+)\s*=\s*\[(.*)$/);
82
+ if (!m) continue;
83
+ const key = m[1].replace(/["']/g, "");
84
+ const wanted = (table === "project" && key === "dependencies") || table === "project.optional-dependencies" || table === "dependency-groups";
85
+ if (!wanted) continue;
86
+ // The array may span lines: read up to its closing bracket.
87
+ let body = m[2];
88
+ while (!body.includes("]") && i + 1 < lines.length) body += `\n${lines[++i].replace(/#.*$/, "")}`;
89
+ for (const s of body.matchAll(/["']\s*([A-Za-z0-9][A-Za-z0-9_.\-]*)/g)) names.push(s[1].toLowerCase());
90
+ }
91
+ names.push(...tomlTable(text, /^\[tool\.poetry\.(group\.[\w-]+\.)?(dev-)?dependencies\]$/).filter((n) => n !== "python"));
92
+ return names;
93
+ }
94
+
95
+ /** Keys of the TOML tables whose header matches. @param {string} text @param {RegExp} header */
96
+ function tomlTable(text, header) {
97
+ const names = [];
98
+ let inside = false;
99
+ for (const raw of text.split(/\r?\n/)) {
100
+ const line = raw.trim();
101
+ if (line.startsWith("[")) inside = header.test(line);
102
+ else if (inside) {
103
+ const m = line.match(/^([A-Za-z0-9_.\-]+)\s*=/);
104
+ if (m) names.push(m[1].toLowerCase());
105
+ }
106
+ }
107
+ return names;
108
+ }
109
+
110
+ /** @param {string} text */
111
+ function goMod(text) {
112
+ const names = [];
113
+ let block = false;
114
+ for (const raw of text.split(/\r?\n/)) {
115
+ // An `// indirect` requirement came with another one; it was not added by hand.
116
+ if (/\/\/\s*indirect\b/.test(raw)) continue;
117
+ const line = raw.replace(/\/\/.*/, "").trim();
118
+ if (/^require\s*\($/.test(line)) block = true;
119
+ else if (block && line === ")") block = false;
120
+ else if (block && line) names.push(line.split(/\s+/)[0]);
121
+ else {
122
+ const m = line.match(/^require\s+(\S+)/);
123
+ if (m) names.push(m[1]);
124
+ }
125
+ }
126
+ return names;
127
+ }