@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.
- package/CHANGELOG.md +406 -0
- package/LICENSE +190 -0
- package/README.md +206 -2
- package/SECURITY.md +105 -0
- package/bin/trellis.mjs +5 -0
- package/docs/attestation/v0.1.md +96 -0
- package/package.json +78 -4
- package/schema/trellis.config.schema.json +349 -0
- package/src/agents/adapters.mjs +155 -0
- package/src/agents/git.mjs +241 -0
- package/src/agents/glob.mjs +41 -0
- package/src/agents/hooks.mjs +780 -0
- package/src/agents/install-adapters.mjs +172 -0
- package/src/agents/install.mjs +302 -0
- package/src/agents/manifests.mjs +127 -0
- package/src/agents/rules.mjs +330 -0
- package/src/agents/shell.mjs +253 -0
- package/src/agents/state.mjs +233 -0
- package/src/agents/transcript.mjs +120 -0
- package/src/cli/main.mjs +191 -0
- package/src/commands/attest.mjs +208 -0
- package/src/commands/checkpoint-state.mjs +62 -0
- package/src/commands/doctor.mjs +98 -0
- package/src/commands/guide.mjs +62 -0
- package/src/commands/init.mjs +131 -0
- package/src/commands/key.mjs +31 -0
- package/src/commands/scan.mjs +51 -0
- package/src/commands/uninstall.mjs +36 -0
- package/src/commands/verify.mjs +510 -0
- package/src/core/abatty.mjs +25 -0
- package/src/core/agents-block.mjs +65 -0
- package/src/core/attestation.mjs +213 -0
- package/src/core/config.mjs +135 -0
- package/src/core/context.mjs +280 -0
- package/src/core/diff.mjs +189 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/mcp.mjs +51 -0
- package/src/index.mjs +11 -0
- package/src/mcp/client.mjs +164 -0
- package/src/mcp/launch.mjs +57 -0
- package/src/mcp/relay.mjs +111 -0
- package/src/mcp/server.mjs +210 -0
- package/src/mcp/trust.mjs +142 -0
- package/src/tools/registry.mjs +370 -0
- package/src/tools/roles.mjs +17 -0
- package/src/verify/deps.mjs +382 -0
- package/src/verify/gitleaks-default.toml +3211 -0
- package/src/verify/rules.mjs +177 -0
- package/src/verify/secrets.mjs +169 -0
- package/src/verify/test-commands.mjs +116 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Attaching a commit's attestation, from the git hooks that run after a commit is made:
|
|
2
|
+
// trellis checkpoint post-commit: attest HEAD, start the next evidence window
|
|
3
|
+
// trellis checkpoint --merge post-merge: attest the merge commit just made (never a fast-forward)
|
|
4
|
+
// trellis checkpoint --rewritten <amend|rebase>
|
|
5
|
+
// post-rewrite: a rebased or amended commit keeps its attestation
|
|
6
|
+
// only when its change is exactly the one attested
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { commitDiff, parentsOf } from "../core/diff.mjs";
|
|
11
|
+
import { checkpoint, evidenceFile, readWindow } from "../core/evidence.mjs";
|
|
12
|
+
import { buildStatement, existingPublicKeyPem, keyIdOf, readNotes, signEnvelope, trustedKeys, verifyEnvelope, writeNote } from "../core/attestation.mjs";
|
|
13
|
+
import { loadConfig, validateConfig } from "../core/config.mjs";
|
|
14
|
+
import { scanCommit } from "../verify/secrets.mjs";
|
|
15
|
+
import { checkDependencies, dependencyChanges } from "../verify/deps.mjs";
|
|
16
|
+
import { evaluate, localModes } from "../verify/rules.mjs";
|
|
17
|
+
import { testCommands, testScriptChanges } from "../verify/test-commands.mjs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} JudgeOptions
|
|
21
|
+
* @property {import("../verify/deps.mjs").Http} [http]
|
|
22
|
+
* @property {(root: string, commit: string) => import("../verify/secrets.mjs").SecretScan} [scanCommit]
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Which agent is at work, if any: one the evidence names (every agent's hooks record its first
|
|
27
|
+
* call of a window), or one whose own environment marks its shell. An agent running with neither
|
|
28
|
+
* Trellis's hooks nor its gateway, and no marker, reads as a person, locally and in CI: what
|
|
29
|
+
* holds for it are the rules that apply to everyone.
|
|
30
|
+
* @param {import("../core/evidence.mjs").Event[]} events
|
|
31
|
+
* @param {NodeJS.ProcessEnv} env
|
|
32
|
+
* @returns {string | null}
|
|
33
|
+
*/
|
|
34
|
+
export function agentAtWork(events, env) {
|
|
35
|
+
const named = [...events].reverse().find((e) => e.agent && e.agent !== "person");
|
|
36
|
+
if (named) return named.agent;
|
|
37
|
+
if (env.TRELLIS_AGENT) return env.TRELLIS_AGENT;
|
|
38
|
+
if (env.CLAUDECODE === "1") return "claude-code";
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Where verify leaves its verdict for the post-commit. @param {string} root @param {NodeJS.ProcessEnv} env */
|
|
43
|
+
export const lastVerifyFile = (root, env) => join(dirname(evidenceFile(root, env)), "last-verify.json");
|
|
44
|
+
|
|
45
|
+
/** @param {string} root @param {string[]} args */
|
|
46
|
+
const git = (root, args) => spawnSync("git", args, { cwd: root, encoding: "utf8", windowsHide: true }).stdout?.trim() ?? "";
|
|
47
|
+
|
|
48
|
+
/** Whether a commit already carries an attestation that is not `unverified`. @param {string} root @param {string} commit */
|
|
49
|
+
const attested = (root, commit) =>
|
|
50
|
+
readNotes(root, commit).some((env) => {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(Buffer.from(String(env?.payload), "base64").toString("utf8"))?.predicate?.verdict !== "unverified";
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The checkpoint run after the fact, on a commit no pre-commit saw (a conflicted rebase, a
|
|
60
|
+
* cherry-pick, a revert, a commit made with --no-verify): the same rules, on the commit's own
|
|
61
|
+
* change, with this window's evidence. CI judges it again either way.
|
|
62
|
+
* @param {import("../core/context.mjs").Context} ctx
|
|
63
|
+
* @param {string} commit
|
|
64
|
+
* @param {string} tree
|
|
65
|
+
* @param {import("../core/evidence.mjs").Event[]} events
|
|
66
|
+
* @param {NodeJS.ProcessEnv} env
|
|
67
|
+
* @param {JudgeOptions} opts
|
|
68
|
+
*/
|
|
69
|
+
async function judgeCommit(ctx, commit, tree, events, env, opts) {
|
|
70
|
+
const agent = agentAtWork(events, env);
|
|
71
|
+
const actor = agent ? "agent" : "person";
|
|
72
|
+
const raw = ctx.read("trellis.config.json");
|
|
73
|
+
const config = loadConfig(ctx);
|
|
74
|
+
const errors = raw?.trim() && config == null ? ["trellis.config.json is not valid JSON"] : config ? validateConfig(config) : [];
|
|
75
|
+
const preset = config?.verify?.preset ?? "recommended";
|
|
76
|
+
if (errors.length) return { actor, agent, preset, findings: [{ rule: "config-invalid", mode: "block", message: errors.join("; ") }], verdict: "fail" };
|
|
77
|
+
const changes = commitDiff(ctx.root, commit);
|
|
78
|
+
const deps = dependencyChanges(ctx.root, changes);
|
|
79
|
+
const secrets = (opts.scanCommit ?? scanCommit)(ctx.root, commit);
|
|
80
|
+
const findings = evaluate({
|
|
81
|
+
changes,
|
|
82
|
+
events,
|
|
83
|
+
actor,
|
|
84
|
+
preset,
|
|
85
|
+
modes: localModes(config, secrets),
|
|
86
|
+
secrets,
|
|
87
|
+
deps: deps.length ? await checkDependencies(deps, { http: opts.http }) : { status: "clean", findings: [], unpinned: [], unchecked: [] },
|
|
88
|
+
depNames: deps.map((d) => d.name),
|
|
89
|
+
testCommands: testCommands((p) => ctx.read(p)),
|
|
90
|
+
tree,
|
|
91
|
+
testScripts: testScriptChanges(ctx.root, changes),
|
|
92
|
+
});
|
|
93
|
+
return { actor, agent, preset, findings, verdict: findings.some((f) => f.mode === "block") ? "fail" : "pass" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The statement for `commit`: the checkpoint's verdict when it ran on exactly this tree, else
|
|
98
|
+
* the checkpoint run now, on the commit itself; `unverified` only when the evidence log no
|
|
99
|
+
* longer verifies, so nothing can be judged from it.
|
|
100
|
+
* @param {import("../core/context.mjs").Context} ctx
|
|
101
|
+
* @param {string} commit
|
|
102
|
+
* @param {NodeJS.ProcessEnv} env
|
|
103
|
+
* @param {Record<string, unknown>} [extra] added to the predicate
|
|
104
|
+
* @param {JudgeOptions} [opts]
|
|
105
|
+
*/
|
|
106
|
+
async function statementFor(ctx, commit, env, extra = {}, opts = {}) {
|
|
107
|
+
const tree = git(ctx.root, ["rev-parse", `${commit}^{tree}`]);
|
|
108
|
+
if (!tree) throw new Error("no commit to attest");
|
|
109
|
+
const window = readWindow(ctx.root, env);
|
|
110
|
+
/** @type {any} */
|
|
111
|
+
let last = null;
|
|
112
|
+
try {
|
|
113
|
+
const file = lastVerifyFile(ctx.root, env);
|
|
114
|
+
last = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
|
|
115
|
+
} catch {
|
|
116
|
+
last = null;
|
|
117
|
+
}
|
|
118
|
+
const verified = last?.tree === tree && window.status === "ok";
|
|
119
|
+
const judged = verified ? last : window.status === "ok" ? await judgeCommit(ctx, commit, tree, window.events, env, opts) : null;
|
|
120
|
+
const agent = judged ? judged.agent : agentAtWork([], env);
|
|
121
|
+
const statement = buildStatement({
|
|
122
|
+
commit,
|
|
123
|
+
tree,
|
|
124
|
+
actor: judged ? judged.actor : agent ? "agent" : "person",
|
|
125
|
+
agent,
|
|
126
|
+
preset: judged ? judged.preset : "unknown",
|
|
127
|
+
verdict: judged ? judged.verdict : "unverified",
|
|
128
|
+
findings: judged ? judged.findings : [],
|
|
129
|
+
events: window.status === "ok" ? window.events : [],
|
|
130
|
+
});
|
|
131
|
+
Object.assign(statement.predicate, extra);
|
|
132
|
+
return { statement, verified: Boolean(judged) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether a rebase is under way: its commits are attested once, by post-rewrite. @param {string} root */
|
|
136
|
+
const rebasing = (root) => ["rebase-merge", "rebase-apply"].some((d) => existsSync(join(root, git(root, ["rev-parse", "--git-path", d]))));
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The hooks after a commit. post-commit attests HEAD; post-merge attests the merge commit it made
|
|
140
|
+
* (a fast-forward makes none: HEAD is someone else's commit); post-rewrite carries attestations
|
|
141
|
+
* to rebased and amended commits. A commit that already carries an attestation keeps it.
|
|
142
|
+
* @param {import("../core/context.mjs").Context} ctx
|
|
143
|
+
* @param {(...lines: string[]) => void} out
|
|
144
|
+
* @param {{ env?: NodeJS.ProcessEnv, merge?: boolean, rewritten?: string, input?: string } & JudgeOptions} [opts]
|
|
145
|
+
* @returns {Promise<number>}
|
|
146
|
+
*/
|
|
147
|
+
export async function commitCheckpoint(ctx, out, opts = {}) {
|
|
148
|
+
const env = opts.env ?? process.env;
|
|
149
|
+
try {
|
|
150
|
+
if (opts.rewritten) return await carry(ctx, opts.input ?? "", env, opts);
|
|
151
|
+
if (rebasing(ctx.root)) return 0;
|
|
152
|
+
const head = git(ctx.root, ["rev-parse", "HEAD"]);
|
|
153
|
+
if (opts.merge) {
|
|
154
|
+
const parents = head ? parentsOf(ctx.root, head) : [];
|
|
155
|
+
if (parents.length < 2 || parents[0] !== git(ctx.root, ["rev-parse", "-q", "--verify", "ORIG_HEAD"])) return 0;
|
|
156
|
+
}
|
|
157
|
+
if (!head || !readNotes(ctx.root, head).length) writeNote(ctx.root, head, signEnvelope((await statementFor(ctx, head, env, {}, opts)).statement, env));
|
|
158
|
+
} catch (e) {
|
|
159
|
+
// The commit is made; a missing attestation is what CI will report. Say it now too.
|
|
160
|
+
out(`trellis checkpoint: the commit's attestation could not be written (${e instanceof Error ? e.message : String(e)}).`);
|
|
161
|
+
}
|
|
162
|
+
checkpoint(ctx.root, env);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The change a commit makes, as content, for comparing two commits. @param {string} root @param {string} commit */
|
|
167
|
+
const changeKey = (root, commit) =>
|
|
168
|
+
commitDiff(root, commit)
|
|
169
|
+
.map((c) => `${c.path}\0${c.status}\0${c.newBlob}`)
|
|
170
|
+
.sort()
|
|
171
|
+
.join("\n");
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* post-rewrite: `<old> <new>` per line. A rewritten commit whose change is exactly the old one's
|
|
175
|
+
* (same files, same content) carries the old attestation, re-signed for the new commit and tree
|
|
176
|
+
* and marked `rewrittenFrom`; the evidence still holds, since it is tied to the content. Any
|
|
177
|
+
* other rewritten commit is attested as verify last saw it, or `unverified`.
|
|
178
|
+
* @param {import("../core/context.mjs").Context} ctx
|
|
179
|
+
* @param {string} input
|
|
180
|
+
* @param {NodeJS.ProcessEnv} env
|
|
181
|
+
* @param {JudgeOptions} opts
|
|
182
|
+
*/
|
|
183
|
+
async function carry(ctx, input, env, opts) {
|
|
184
|
+
const keys = trustedKeys(ctx.root);
|
|
185
|
+
const own = existingPublicKeyPem(env);
|
|
186
|
+
if (own) keys.set(keyIdOf(own), own);
|
|
187
|
+
let usedWindow = false;
|
|
188
|
+
for (const [from, to] of input.split(/\r?\n/).map((l) => l.trim().split(/\s+/))) {
|
|
189
|
+
if (!from || !to || attested(ctx.root, to)) continue;
|
|
190
|
+
const old = readNotes(ctx.root, from)
|
|
191
|
+
.map((e) => verifyEnvelope(e, keys))
|
|
192
|
+
.find((v) => v.ok && v.statement.subject?.[0]?.digest?.gitCommit === from && v.statement.predicate?.verdict === "pass");
|
|
193
|
+
let statement;
|
|
194
|
+
if (old?.ok && changeKey(ctx.root, from) === changeKey(ctx.root, to)) {
|
|
195
|
+
statement = old.statement;
|
|
196
|
+
statement.subject = [{ name: "git commit", digest: { gitCommit: to, gitTree: git(ctx.root, ["rev-parse", `${to}^{tree}`]) } }];
|
|
197
|
+
statement.predicate.rewrittenFrom = from;
|
|
198
|
+
} else {
|
|
199
|
+
const made = await statementFor(ctx, to, env, { rewrittenFrom: from }, opts);
|
|
200
|
+
statement = made.statement;
|
|
201
|
+
usedWindow ||= made.verified;
|
|
202
|
+
}
|
|
203
|
+
writeNote(ctx.root, to, signEnvelope(statement, env));
|
|
204
|
+
}
|
|
205
|
+
// A rebase that only moved commits leaves the work in progress its evidence.
|
|
206
|
+
if (usedWindow) checkpoint(ctx.root, env);
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// What doctor shows first: whether the commit checkpoint is in place, from the pre-commit that
|
|
2
|
+
// runs it to the CI step that is its authority. Read only: nothing here creates or changes a file.
|
|
3
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { commandsIn } from "../agents/shell.mjs";
|
|
6
|
+
import { preCommitPath } from "../agents/install.mjs";
|
|
7
|
+
import { parseJson } from "../core/context.mjs";
|
|
8
|
+
import { existingPublicKeyPem, trustedKeys } from "../core/attestation.mjs";
|
|
9
|
+
import { fingerprint, relayable, trustedServers } from "../mcp/trust.mjs";
|
|
10
|
+
|
|
11
|
+
/** @typedef {{ name: string, status: "ok" | "warn" | "fail", detail: string, fix: string | null }} CheckpointItem */
|
|
12
|
+
|
|
13
|
+
/** The CI files Trellis knows how to read. @param {string} root */
|
|
14
|
+
function ciFiles(root) {
|
|
15
|
+
const files = [".gitlab-ci.yml", ".woodpecker.yml", "azure-pipelines.yml", "Jenkinsfile", "bitbucket-pipelines.yml"].filter((f) => existsSync(join(root, f)));
|
|
16
|
+
for (const dir of [".github/workflows", ".woodpecker"]) {
|
|
17
|
+
try {
|
|
18
|
+
files.push(...readdirSync(join(root, dir)).filter((f) => /\.ya?ml$/.test(f)).map((f) => `${dir}/${f}`));
|
|
19
|
+
} catch {
|
|
20
|
+
/* none */
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return files;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {import("../core/context.mjs").Context} ctx
|
|
28
|
+
* @param {any} config
|
|
29
|
+
* @param {NodeJS.ProcessEnv} env
|
|
30
|
+
* @returns {CheckpointItem[]}
|
|
31
|
+
*/
|
|
32
|
+
export function checkpointState(ctx, config, env) {
|
|
33
|
+
/** @type {CheckpointItem[]} */
|
|
34
|
+
const items = [];
|
|
35
|
+
// The hooks are what init --agents installs: once it has run, a missing one is a failure.
|
|
36
|
+
const expected = config?.agents ? "fail" : "warn";
|
|
37
|
+
const pre = preCommitPath(ctx);
|
|
38
|
+
const runs = (/** @type {string | null} */ path, /** @type {string} */ sub) => !!path && commandsIn(ctx.read(path) ?? "").some((c) => c.prog === "trellis" && c.args[0] === sub);
|
|
39
|
+
items.push(runs(pre, "verify") ? { name: "pre-commit", status: "ok", detail: "runs trellis verify on every commit", fix: null } : { name: "pre-commit", status: expected, detail: "does not run trellis verify: commits are not checked", fix: "npx @synovitec/trellis init --agents (or add `trellis verify` to your own pre-commit)" });
|
|
40
|
+
const post = pre ? pre.replace(/pre-commit$/, "post-commit") : null;
|
|
41
|
+
items.push(runs(post, "checkpoint") ? { name: "post-commit", status: "ok", detail: "attests every commit", fix: null } : { name: "post-commit", status: expected, detail: "does not run trellis checkpoint: commits carry no attestation", fix: "npx @synovitec/trellis init --agents (or add `trellis checkpoint` to your own post-commit)" });
|
|
42
|
+
const hook = (/** @type {string} */ name) => (pre ? pre.replace(/pre-commit$/, name) : null);
|
|
43
|
+
const merges = runs(hook("pre-merge-commit"), "verify") && runs(hook("post-merge"), "checkpoint") && runs(hook("post-rewrite"), "checkpoint");
|
|
44
|
+
items.push(merges ? { name: "merges and rebases", status: "ok", detail: "merge commits are checked and attested; rebased commits keep their attestation", fix: null } : { name: "merges and rebases", status: "warn", detail: "pre-merge-commit, post-merge or post-rewrite does not run Trellis: merge commits and rebased commits reach CI unattested", fix: "npx @synovitec/trellis init --agents" });
|
|
45
|
+
const push = hook("pre-push");
|
|
46
|
+
items.push(push && /refs\/notes\/trellis/.test(ctx.read(push) ?? "") ? { name: "pre-push", status: "ok", detail: "sends the attestations with each push", fix: null } : { name: "pre-push", status: "warn", detail: "the attestations (refs/notes/trellis) are not pushed: CI will not find them", fix: "npx @synovitec/trellis init --agents" });
|
|
47
|
+
const mcp = parseJson(ctx.read(".mcp.json"))?.mcpServers ?? {};
|
|
48
|
+
const gateway = Object.values(mcp).some((s) => /trellis(\.mjs)?["']?,?\s*"?mcp\b/.test(JSON.stringify([/** @type {any} */ (s)?.command, ...(/** @type {any} */ (s)?.args ?? [])])));
|
|
49
|
+
items.push(gateway ? { name: "MCP gateway", status: "ok", detail: "declared in .mcp.json", fix: null } : { name: "MCP gateway", status: "warn", detail: "not declared: agents do not get the checks as tools", fix: "npx @synovitec/trellis init --agents" });
|
|
50
|
+
const trustedMcp = trustedServers(ctx.root, env);
|
|
51
|
+
const untrusted = relayable(ctx).servers.filter(([name, entry]) => trustedMcp[name] !== fingerprint(entry, ctx.root)).map(([name]) => name);
|
|
52
|
+
if (gateway && untrusted.length) items.push({ name: "relayed servers", status: "warn", detail: `not trusted on this clone, so the gateway does not start them: ${untrusted.join(", ")}`, fix: "npx --no-install @synovitec/trellis mcp trust (in a terminal)" });
|
|
53
|
+
const mine = existingPublicKeyPem(env);
|
|
54
|
+
const trusted = mine != null && [...trustedKeys(ctx.root).values()].some((k) => k.trim() === mine.trim());
|
|
55
|
+
items.push(trusted ? { name: "signing key", status: "ok", detail: "this developer's key is trusted under .trellis/keys/", fix: null } : { name: "signing key", status: "warn", detail: "this developer's attestations are signed by a key CI does not trust", fix: "npx @synovitec/trellis key --add, then commit .trellis/keys/" });
|
|
56
|
+
const ci = ciFiles(ctx.root).find((f) => /Synovitec\/trellis@|trellis(\.mjs)?\s+verify\s+--ci/.test(ctx.read(f) ?? ""));
|
|
57
|
+
// On GitHub, `pull_request` runs the pull request's own copy of the workflow: it can drop the step.
|
|
58
|
+
const editable = ci?.startsWith(".github/workflows/") && !/pull_request_target/.test(ctx.read(ci) ?? "");
|
|
59
|
+
if (ci && editable) items.push({ name: "CI", status: "warn", detail: `${ci} runs the check, but on pull_request a pull request runs its own copy of it and can remove the step`, fix: "run it on pull_request_target, as the README's workflow does, and require the check in the branch's ruleset" });
|
|
60
|
+
else items.push(ci ? { name: "CI", status: "ok", detail: `${ci} runs the authority check`, fix: null } : { name: "CI", status: "warn", detail: "no CI step runs trellis verify --ci: nothing checks commits where it cannot be skipped", fix: "add `- uses: Synovitec/trellis@<commit>` after a checkout with fetch-depth: 0" });
|
|
61
|
+
return items;
|
|
62
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// `trellis doctor`: every enabled tool, installed or not, wired or not. "Installed but not wired"
|
|
2
|
+
// is reported as its own state, because it is the one that looks fine and protects nothing.
|
|
3
|
+
import { enabledTools, loadConfig, validateConfig, CONFIG_FILE } from "../core/config.mjs";
|
|
4
|
+
import { installHint } from "../tools/registry.mjs";
|
|
5
|
+
import { ROLES } from "../tools/roles.mjs";
|
|
6
|
+
import { hookState } from "../agents/install.mjs";
|
|
7
|
+
import { checkpointState } from "./checkpoint-state.mjs";
|
|
8
|
+
|
|
9
|
+
/** @typedef {import("../core/context.mjs").Context} Context */
|
|
10
|
+
/** @typedef {"ok" | "warn" | "unwired" | "missing"} Status */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {Context} ctx
|
|
14
|
+
* @returns {{ configured: boolean, errors: string[], results: Array<{ id: string, name: string, role: string, status: Status, detail: string, warnings: string[], fix: string | null }>, agents: ReturnType<typeof hookState> | null }}
|
|
15
|
+
*/
|
|
16
|
+
export function diagnose(ctx) {
|
|
17
|
+
const config = loadConfig(ctx);
|
|
18
|
+
const raw = ctx.read(CONFIG_FILE);
|
|
19
|
+
const errors = raw != null && config == null ? [`${CONFIG_FILE} is not valid JSON`] : config ? validateConfig(config) : [];
|
|
20
|
+
const results = enabledTools(ctx, errors.length ? null : config).map(({ tool, opts }) => {
|
|
21
|
+
const c = tool.check(ctx, opts);
|
|
22
|
+
/** @type {Status} */
|
|
23
|
+
// Installed with one known step left (a graph to build): a warning carrying that step.
|
|
24
|
+
const status = c.installed === false ? "missing" : !c.wired && c.todo ? "warn" : !c.wired ? "unwired" : c.warnings.length ? "warn" : "ok";
|
|
25
|
+
const fix = status === "missing" ? installHint(tool, ctx.platform) : status === "unwired" ? `wire it: ${tool.wire}` : c.todo && !c.wired ? c.todo : null;
|
|
26
|
+
return { id: tool.id, name: tool.name, role: tool.role, status, detail: c.detail, warnings: c.warnings, fix };
|
|
27
|
+
});
|
|
28
|
+
// The agent rules are a tool like the others: configured but with no hook to run them, they
|
|
29
|
+
// are exactly the inert setup this command exists to find.
|
|
30
|
+
const agents = config?.agents && !errors.length ? hookState(ctx) : null;
|
|
31
|
+
return { configured: config != null, errors, results, agents };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MARK = { ok: "✓", warn: "!", unwired: "✗", missing: "✗" };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {Context} ctx
|
|
38
|
+
* @param {{ json?: boolean, strict?: boolean, all?: boolean, env?: NodeJS.ProcessEnv }} flags
|
|
39
|
+
* @param {(line: string) => void} out
|
|
40
|
+
* @returns {number} exit code: 1 when a tool is missing or unwired (or warns, with --strict)
|
|
41
|
+
*/
|
|
42
|
+
export function doctor(ctx, flags, out) {
|
|
43
|
+
const report = diagnose(ctx);
|
|
44
|
+
const config = report.configured && !report.errors.length ? loadConfig(ctx) : null;
|
|
45
|
+
const checkpoint = config ? checkpointState(ctx, config, flags.env ?? process.env) : [];
|
|
46
|
+
const failing = report.results.filter((r) => r.status === "missing" || r.status === "unwired" || (flags.strict && r.status === "warn"));
|
|
47
|
+
const cpFailing = checkpoint.filter((i) => i.status === "fail" || (flags.strict && i.status === "warn"));
|
|
48
|
+
const a = report.agents;
|
|
49
|
+
const hooksBad = a ? a.missing.length + a.broken.length + a.outdated.length : 0;
|
|
50
|
+
const code = !report.configured || report.errors.length || failing.length || cpFailing.length || hooksBad ? 1 : 0;
|
|
51
|
+
if (flags.json) {
|
|
52
|
+
out(JSON.stringify({ ...report, checkpoint, exitCode: code }, null, 2));
|
|
53
|
+
return code;
|
|
54
|
+
}
|
|
55
|
+
for (const e of report.errors) out(`✗ ${e}`);
|
|
56
|
+
if (!report.configured && !flags.all) {
|
|
57
|
+
// A first run: one diagnosis and one command, not every tool that could apply.
|
|
58
|
+
out("Trellis is not set up in this repository.", " → npx @synovitec/trellis init --agents (add --dry-run to see what it writes first)", " `trellis doctor --all` lists every tool that would apply here.");
|
|
59
|
+
return code;
|
|
60
|
+
}
|
|
61
|
+
if (!report.configured) out(`(no ${CONFIG_FILE}: checking every tool that applies to this stack; \`trellis init\` writes one)`);
|
|
62
|
+
if (checkpoint.length) {
|
|
63
|
+
out("", "Checkpoint");
|
|
64
|
+
const width = Math.max(16, ...checkpoint.map((i) => i.name.length));
|
|
65
|
+
for (const i of checkpoint) {
|
|
66
|
+
out(` ${i.status === "ok" ? "✓" : i.status === "warn" ? "!" : "✗"} ${i.name.padEnd(width)} ${i.detail}`);
|
|
67
|
+
if (i.fix) out(` → ${i.fix}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const role of ROLES) {
|
|
71
|
+
const rows = report.results.filter((r) => r.role === role.id);
|
|
72
|
+
if (!rows.length) continue;
|
|
73
|
+
out("", `${role.title}`);
|
|
74
|
+
for (const r of rows) {
|
|
75
|
+
out(` ${MARK[r.status]} ${r.name.padEnd(16)} ${r.status.padEnd(8)} ${r.detail}`);
|
|
76
|
+
for (const w of r.warnings) out(` ! ${w}`);
|
|
77
|
+
if (r.fix) out(` → ${r.fix}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (report.agents) {
|
|
81
|
+
out("", "Agents");
|
|
82
|
+
for (const e of report.agents.present) out(` ✓ ${e.padEnd(16)} hooked the hook runs the installed trellis`);
|
|
83
|
+
for (const e of report.agents.broken) out(` ✗ ${e.padEnd(16)} broken the hook is set, but trellis is not installed where it looks: it fails on every event`, " → npm i -D @synovitec/trellis (or -g for a repository without node_modules)");
|
|
84
|
+
for (const e of report.agents.outdated) out(` ✗ ${e.padEnd(16)} outdated the hook is an older trellis command`, " → npx @synovitec/trellis init");
|
|
85
|
+
for (const e of report.agents.missing) out(` ✗ ${e.padEnd(16)} unwired the agent rules are configured, but no ${e} hook runs them`, " → npx @synovitec/trellis init --agents");
|
|
86
|
+
}
|
|
87
|
+
// One count per section, so the summary says what the screen shows.
|
|
88
|
+
const n = (/** @type {Status} */ s) => report.results.filter((r) => r.status === s).length;
|
|
89
|
+
const parts = [];
|
|
90
|
+
if (checkpoint.length) parts.push(`checkpoint ${checkpoint.filter((i) => i.status === "ok").length} of ${checkpoint.length} in place`);
|
|
91
|
+
parts.push(`tools ${n("ok")} ok, ${n("warn")} with warnings, ${n("unwired")} not wired, ${n("missing")} missing`);
|
|
92
|
+
if (report.agents) {
|
|
93
|
+
const a = report.agents;
|
|
94
|
+
parts.push(`agent hooks ${a.present.length} of ${a.present.length + a.broken.length + a.outdated.length + a.missing.length} working`);
|
|
95
|
+
}
|
|
96
|
+
out("", `${parts.join("; ")}.`);
|
|
97
|
+
return code;
|
|
98
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// `trellis guide [tool|role]`: which tool answers which question. Without an argument, the whole
|
|
2
|
+
// routing for this repository; with one, that tool's use, rule, install line and current state.
|
|
3
|
+
import { enabledTools, loadConfig } from "../core/config.mjs";
|
|
4
|
+
import { installHint, toolById } from "../tools/registry.mjs";
|
|
5
|
+
import { ROLES, roleById } from "../tools/roles.mjs";
|
|
6
|
+
import { diagnose } from "./doctor.mjs";
|
|
7
|
+
|
|
8
|
+
/** @typedef {import("../core/context.mjs").Context} Context */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Context} ctx
|
|
12
|
+
* @param {string | undefined} topic
|
|
13
|
+
* @param {(line: string) => void} out
|
|
14
|
+
* @returns {number}
|
|
15
|
+
*/
|
|
16
|
+
export function guide(ctx, topic, out) {
|
|
17
|
+
const enabled = enabledTools(ctx, loadConfig(ctx));
|
|
18
|
+
if (topic) {
|
|
19
|
+
const tool = toolById(topic);
|
|
20
|
+
if (tool) return guideTool(ctx, tool, out);
|
|
21
|
+
const role = roleById(topic);
|
|
22
|
+
if (!role) {
|
|
23
|
+
out(`Unknown topic "${topic}". Topics: ${ROLES.map((r) => r.id).join(", ")}, or a tool id from \`trellis tools\`.`);
|
|
24
|
+
return 2;
|
|
25
|
+
}
|
|
26
|
+
return printRoles([role], enabled, out);
|
|
27
|
+
}
|
|
28
|
+
return printRoles(ROLES, enabled, out);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {ReadonlyArray<{ id: string, title: string, question: string }>} roles
|
|
33
|
+
* @param {ReturnType<typeof enabledTools>} enabled
|
|
34
|
+
* @param {(line: string) => void} out
|
|
35
|
+
*/
|
|
36
|
+
function printRoles(roles, enabled, out) {
|
|
37
|
+
for (const role of roles) {
|
|
38
|
+
out(`${role.title}: ${role.question}`);
|
|
39
|
+
const tools = enabled.filter((e) => e.tool.role === role.id);
|
|
40
|
+
if (!tools.length) out(" (no tool enabled for this role)");
|
|
41
|
+
for (const { tool } of tools) out(` ${tool.name.padEnd(16)} ${tool.use}`);
|
|
42
|
+
out("");
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {Context} ctx
|
|
49
|
+
* @param {import("../tools/registry.mjs").Tool} tool
|
|
50
|
+
* @param {(line: string) => void} out
|
|
51
|
+
*/
|
|
52
|
+
function guideTool(ctx, tool, out) {
|
|
53
|
+
const state = diagnose(ctx).results.find((r) => r.id === tool.id);
|
|
54
|
+
out(`${tool.name} (${roleById(tool.role)?.title}${tool.ai ? ", used by agents" : ""})`, "");
|
|
55
|
+
out(`Use it when: ${tool.use}`);
|
|
56
|
+
if (tool.rule) out(`Rule: ${tool.rule}`);
|
|
57
|
+
out(`Install: ${installHint(tool, ctx.platform)}`);
|
|
58
|
+
out(`Wired when: ${tool.wire}`);
|
|
59
|
+
out(`Here: ${state ? `${state.status}, ${state.detail}` : "disabled in trellis.config.json"}`);
|
|
60
|
+
for (const w of state?.warnings ?? []) out(` ! ${w}`);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// `trellis init`: writes the config, the MCP entries and the AGENTS.md block, and adds the
|
|
2
|
+
// git-ignore lines the tools need. With `--agents` (or once the config has an `agents` section)
|
|
3
|
+
// it also puts the agent hooks in place. It installs no binary: it prints how, because installing
|
|
4
|
+
// software on a machine is a person's decision. Run twice, it changes nothing the second time.
|
|
5
|
+
import { CONFIG_FILE, defaultConfig, enabledTools, loadConfig, proposals, validateConfig } from "../core/config.mjs";
|
|
6
|
+
import { installHint } from "../tools/registry.mjs";
|
|
7
|
+
import { planMcp } from "../core/mcp.mjs";
|
|
8
|
+
import { renderBlock, upsertBlock } from "../core/agents-block.mjs";
|
|
9
|
+
import { installKind, planGitHooks, planSettings, SCRIPT, SETTINGS } from "../agents/install.mjs";
|
|
10
|
+
import { detectAgents, planAdapterHooks, planAgentMcp } from "../agents/install-adapters.mjs";
|
|
11
|
+
import { ADAPTED } from "../agents/adapters.mjs";
|
|
12
|
+
import { trustEntries } from "../mcp/trust.mjs";
|
|
13
|
+
|
|
14
|
+
/** @typedef {import("../core/context.mjs").Context} Context */
|
|
15
|
+
/** @typedef {{ path: string, text: string, why: string, executable?: boolean }} Write */
|
|
16
|
+
|
|
17
|
+
/** Paths a tool writes that can hold real data or are rebuilt on every run. */
|
|
18
|
+
const IGNORES = /** @type {Record<string, string>} */ ({ "playwright-mcp": ".playwright-mcp/", graphify: "graphify-out/" });
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every file init would write, with its new text. Nothing is written here.
|
|
22
|
+
* @param {Context} ctx
|
|
23
|
+
* @param {{ agents?: boolean, agentList?: string[] }} [flags]
|
|
24
|
+
* @returns {{ writes: Write[], notes: string[] }}
|
|
25
|
+
*/
|
|
26
|
+
export function planInit(ctx, flags = {}) {
|
|
27
|
+
const existing = loadConfig(ctx);
|
|
28
|
+
if (ctx.read(CONFIG_FILE) != null) {
|
|
29
|
+
const errors = existing == null ? [`${CONFIG_FILE} is not valid JSON`] : validateConfig(existing);
|
|
30
|
+
if (errors.length) throw new Error(errors.join("; "));
|
|
31
|
+
}
|
|
32
|
+
const config = existing ?? defaultConfig(ctx, { detected: true });
|
|
33
|
+
const turnOnAgents = flags.agents && !config.agents;
|
|
34
|
+
if (turnOnAgents) config.agents = { mode: "warn" };
|
|
35
|
+
const enabled = enabledTools(ctx, config);
|
|
36
|
+
/** @type {Write[]} */
|
|
37
|
+
const writes = [];
|
|
38
|
+
const notes = [];
|
|
39
|
+
if (!existing) {
|
|
40
|
+
for (const t of proposals(ctx)) notes.push(`${t.name} would fit this stack but is not here, so it is off. To add it: ${installHint(t, ctx.platform)}; then set "${t.id}": true in ${CONFIG_FILE}`);
|
|
41
|
+
}
|
|
42
|
+
if (!existing || turnOnAgents) {
|
|
43
|
+
writes.push({ path: CONFIG_FILE, text: `${JSON.stringify(config, null, 2)}\n`, why: turnOnAgents ? "agent rules on, in warn mode" : "which tools this repository uses" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const kind = installKind(ctx);
|
|
47
|
+
const gateway = kind === "local" ? { type: "stdio", command: "node", args: [SCRIPT, "mcp"] } : kind === "global" ? { type: "stdio", command: "trellis", args: ["mcp"] } : null;
|
|
48
|
+
const mcp = planMcp(ctx, enabled, gateway);
|
|
49
|
+
if (mcp.text) writes.push({ path: ".mcp.json", text: mcp.text, why: `MCP servers added: ${mcp.added.join(", ")}` });
|
|
50
|
+
if (mcp.kept.length) notes.push(`kept the existing MCP entries for ${mcp.kept.join(", ")}`);
|
|
51
|
+
|
|
52
|
+
const agents = ctx.read("AGENTS.md");
|
|
53
|
+
const next = upsertBlock(agents, renderBlock(enabled));
|
|
54
|
+
if (next !== agents) writes.push({ path: "AGENTS.md", text: next, why: "the tool routing block" });
|
|
55
|
+
|
|
56
|
+
const gitignore = ctx.read(".gitignore") ?? "";
|
|
57
|
+
const missing = enabled.map((e) => IGNORES[e.tool.id]).filter((p) => p && !gitignore.split(/\r?\n/).includes(p));
|
|
58
|
+
if (missing.length) {
|
|
59
|
+
const sep = gitignore === "" || gitignore.endsWith("\n") ? "" : "\n";
|
|
60
|
+
writes.push({ path: ".gitignore", text: `${gitignore}${sep}# trellis: tool output\n${missing.join("\n")}\n`, why: `ignored: ${missing.join(", ")}` });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (config.agents) planAgentHooks(ctx, enabled, writes, notes, flags.agentList);
|
|
64
|
+
// The servers Trellis itself adds (pinned, its own choice) are trusted as it writes them; a
|
|
65
|
+
// server already in .mcp.json is left for a person to trust.
|
|
66
|
+
const doc = mcp.text ? JSON.parse(mcp.text) : null;
|
|
67
|
+
/** @type {Array<[string, any]>} */
|
|
68
|
+
const trust = mcp.added.map((name) => /** @type {[string, any]} */ ([name, doc?.mcpServers?.[name]])).filter(([, entry]) => entry);
|
|
69
|
+
return { writes, notes, trust };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {Context} ctx
|
|
74
|
+
* @param {ReturnType<typeof enabledTools>} enabled
|
|
75
|
+
* @param {Write[]} writes
|
|
76
|
+
* @param {string[]} notes
|
|
77
|
+
* @param {string[] | undefined} agentList agents other than Claude Code to hook; absent, those detected here
|
|
78
|
+
*/
|
|
79
|
+
function planAgentHooks(ctx, enabled, writes, notes, agentList) {
|
|
80
|
+
if (!installKind(ctx)) {
|
|
81
|
+
notes.push("trellis is neither in node_modules nor on PATH: install it (npm i -D @synovitec/trellis, or npm i -g @synovitec/trellis for a repository without node_modules) and run init again; until then the agent hooks would fail on every event, so none were written");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const settings = planSettings(ctx);
|
|
85
|
+
if (settings.text) {
|
|
86
|
+
const what = [
|
|
87
|
+
settings.added.length ? `hooks added: ${settings.added.join(", ")}` : "",
|
|
88
|
+
settings.upgraded.length ? `hooks upgraded: ${settings.upgraded.join(", ")}` : "",
|
|
89
|
+
settings.denies.length ? `${settings.denies.length} deny rule(s) protecting the evidence` : "",
|
|
90
|
+
].filter(Boolean);
|
|
91
|
+
writes.push({ path: SETTINGS, text: settings.text, why: what.join("; ") });
|
|
92
|
+
}
|
|
93
|
+
// The commit checkpoint holds with or without gitleaks: without it, commits are not scanned for
|
|
94
|
+
// secrets here (CI scans them), and the note says how to add it.
|
|
95
|
+
if (!enabled.some((e) => e.tool.id === "gitleaks")) notes.push('gitleaks is off, so commits are not scanned for secrets on this machine (CI scans them): install it from github.com/gitleaks/gitleaks, then set "gitleaks": true in trellis.config.json');
|
|
96
|
+
const git = planGitHooks(ctx);
|
|
97
|
+
for (const w of git.writes) writes.push({ ...w, executable: true });
|
|
98
|
+
notes.push(...git.notes);
|
|
99
|
+
const others = (agentList ?? detectAgents(ctx)).filter((a) => a !== "claude-code" && a !== "claude");
|
|
100
|
+
const unknown = others.filter((a) => !ADAPTED.includes(a));
|
|
101
|
+
if (unknown.length) notes.push(`no hooks for ${unknown.join(", ")}: Trellis adapts ${ADAPTED.join(", ")} (and Claude Code); the commit checkpoint still holds them`);
|
|
102
|
+
const adapted = others.filter((a) => ADAPTED.includes(a));
|
|
103
|
+
for (const plan of [planAdapterHooks(ctx, adapted), planAgentMcp(ctx, adapted)]) {
|
|
104
|
+
writes.push(...plan.writes);
|
|
105
|
+
notes.push(...plan.notes);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {Context} ctx
|
|
111
|
+
* @param {{ dryRun?: boolean, agents?: boolean, agentList?: string[] }} flags
|
|
112
|
+
* @param {(...lines: string[]) => void} out
|
|
113
|
+
* @param {(path: string, text: string, executable?: boolean) => void} write
|
|
114
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
115
|
+
* @returns {number}
|
|
116
|
+
*/
|
|
117
|
+
export function init(ctx, flags, out, write, env = process.env) {
|
|
118
|
+
const { writes, notes, trust } = planInit(ctx, flags);
|
|
119
|
+
for (const n of notes) out(`· ${n}`);
|
|
120
|
+
if (!writes.length) {
|
|
121
|
+
out("Nothing to change: this repository is already framed. `npx @synovitec/trellis doctor` checks the tools themselves.");
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
for (const w of writes) {
|
|
125
|
+
if (!flags.dryRun) write(w.path, w.text, w.executable);
|
|
126
|
+
out(`${flags.dryRun ? "would write" : "wrote"} ${w.path.padEnd(22)} ${w.why}`);
|
|
127
|
+
}
|
|
128
|
+
if (!flags.dryRun && trust.length) trustEntries(ctx.root, env, trust);
|
|
129
|
+
out("", flags.dryRun ? "Dry run: nothing written." : "Next: `npx @synovitec/trellis doctor` lists what is still to install or wire, with the command for each.");
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// `trellis key`: this developer's public key, the one CI checks attestations against. With --add,
|
|
2
|
+
// written under .trellis/keys/, to be committed: a key the repository does not hold is not trusted.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { publicKeyPem, trustedKeys } from "../core/attestation.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {import("../core/context.mjs").Context} ctx
|
|
10
|
+
* @param {{ add?: boolean }} flags
|
|
11
|
+
* @param {(...lines: string[]) => void} out
|
|
12
|
+
* @param {NodeJS.ProcessEnv} env
|
|
13
|
+
* @returns {number}
|
|
14
|
+
*/
|
|
15
|
+
export function keyCommand(ctx, flags, out, env) {
|
|
16
|
+
const pem = publicKeyPem(env);
|
|
17
|
+
if (!flags.add) {
|
|
18
|
+
out(pem.trim());
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
if ([...trustedKeys(ctx.root).values()].some((known) => known.trim() === pem.trim())) {
|
|
22
|
+
out("trellis key: this key is already trusted here.");
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
// Named after the key itself: the same key always lands in the same file.
|
|
26
|
+
const rel = `.trellis/keys/${createHash("sha256").update(pem.trim()).digest("hex").slice(0, 16)}.pub`;
|
|
27
|
+
mkdirSync(join(ctx.root, ".trellis", "keys"), { recursive: true });
|
|
28
|
+
writeFileSync(join(ctx.root, rel), pem);
|
|
29
|
+
out(`trellis key: wrote ${rel}. Commit it: CI checks the commits' attestations against the keys under .trellis/keys/.`);
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// `trellis scan`: runs every enabled scanner and reports each one as passed, failed or not run.
|
|
2
|
+
// A scanner that could not run is never counted as a pass: a skip that reads as green is how a
|
|
3
|
+
// check ends up protecting nothing. It fails the scan unless --allow-missing says otherwise.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { enabledTools, loadConfig } from "../core/config.mjs";
|
|
6
|
+
import { installHint } from "../tools/registry.mjs";
|
|
7
|
+
|
|
8
|
+
/** @typedef {import("../core/context.mjs").Context} Context */
|
|
9
|
+
/** @typedef {(cmd: string, args: string[], cwd: string) => { status: number | null, error?: Error }} Runner */
|
|
10
|
+
|
|
11
|
+
/** @type {Runner} */
|
|
12
|
+
export const spawnRunner = (cmd, args, cwd) => {
|
|
13
|
+
// npx is a .cmd on Windows, which spawn only finds through a shell.
|
|
14
|
+
const r = spawnSync(cmd, args, { cwd, stdio: "inherit", shell: process.platform === "win32" && cmd === "npx" });
|
|
15
|
+
return { status: r.status, error: r.error };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Context} ctx
|
|
20
|
+
* @param {{ history?: boolean, allowMissing?: boolean, only?: string[] }} flags
|
|
21
|
+
* @param {(line: string) => void} out
|
|
22
|
+
* @param {Runner} [run]
|
|
23
|
+
* @returns {number}
|
|
24
|
+
*/
|
|
25
|
+
export function scan(ctx, flags, out, run = spawnRunner) {
|
|
26
|
+
const scanners = enabledTools(ctx, loadConfig(ctx)).filter(({ tool }) => tool.scan && (!flags.only?.length || flags.only.includes(tool.id)));
|
|
27
|
+
if (!scanners.length) {
|
|
28
|
+
out("No scanner is enabled: nothing was checked.");
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
/** @type {Array<{ name: string, result: "passed" | "failed" | "not run" | "nothing to scan", why?: string }>} */
|
|
32
|
+
const results = [];
|
|
33
|
+
for (const { tool } of scanners) {
|
|
34
|
+
const { cmd, args, nothingToScan } = /** @type {NonNullable<typeof tool.scan>} */ (tool.scan)({ history: flags.history });
|
|
35
|
+
if (cmd !== "npx" && !ctx.which(cmd)) {
|
|
36
|
+
results.push({ name: tool.name, result: "not run", why: `not installed: ${installHint(tool, ctx.platform)}` });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
out(`── ${tool.name}: ${cmd} ${args.join(" ")}`);
|
|
40
|
+
const r = run(cmd, args, ctx.root);
|
|
41
|
+
if (r.error || r.status == null) results.push({ name: tool.name, result: "not run", why: r.error?.message ?? "killed" });
|
|
42
|
+
else if (nothingToScan && r.status === nothingToScan.code) results.push({ name: tool.name, result: "nothing to scan", why: nothingToScan.why });
|
|
43
|
+
else results.push({ name: tool.name, result: r.status === 0 ? "passed" : "failed", why: r.status === 0 ? undefined : `exit ${r.status}` });
|
|
44
|
+
}
|
|
45
|
+
out("");
|
|
46
|
+
const mark = (/** @type {string} */ result) => (result === "passed" ? "✓" : result === "nothing to scan" ? "-" : "✗");
|
|
47
|
+
for (const r of results) out(`${mark(r.result)} ${r.name.padEnd(14)} ${r.result}${r.why ? ` (${r.why})` : ""}`);
|
|
48
|
+
const failed = results.some((r) => r.result === "failed");
|
|
49
|
+
const notRun = results.some((r) => r.result === "not run");
|
|
50
|
+
return failed || (notRun && !flags.allowMissing) ? 1 : 0;
|
|
51
|
+
}
|