@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,370 @@
|
|
|
1
|
+
// Every tool Trellis knows, as data: its role, what it is for, how to install it, and how to tell
|
|
2
|
+
// that it is not only installed but actually wired. The last part is the point. A tool that is
|
|
3
|
+
// installed and configured but called from nowhere looks set up and protects nothing, so each
|
|
4
|
+
// `check` decides "wired" from a call site or a live config entry, never from a file's presence.
|
|
5
|
+
import { dependencyNames, parseJson } from "../core/context.mjs";
|
|
6
|
+
import { hookState } from "../agents/install.mjs";
|
|
7
|
+
|
|
8
|
+
/** @typedef {import("../core/context.mjs").Context} Context */
|
|
9
|
+
/** @typedef {import("./roles.mjs").Role} Role */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} Check
|
|
13
|
+
* @property {boolean | "on-demand"} installed "on-demand": fetched by npx or docker when used
|
|
14
|
+
* @property {boolean} wired
|
|
15
|
+
* @property {string[]} warnings wired, but risky or incomplete
|
|
16
|
+
* @property {string} detail one line on what was found
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} Tool
|
|
21
|
+
* @property {string} id
|
|
22
|
+
* @property {string} name
|
|
23
|
+
* @property {Role} role
|
|
24
|
+
* @property {boolean} ai used by a coding agent (as opposed to a classic check)
|
|
25
|
+
* @property {string} use when to reach for it
|
|
26
|
+
* @property {string} [rule] the rule an agent follows with it
|
|
27
|
+
* @property {{ win?: string, mac?: string, linux?: string, any?: string }} install
|
|
28
|
+
* @property {string} wire what makes it wired
|
|
29
|
+
* @property {(ctx: Context) => boolean} applies whether init enables it by default here
|
|
30
|
+
* @property {(ctx: Context, opts: Record<string, any>) => Check} check
|
|
31
|
+
* @property {(opts: Record<string, any>) => Record<string, any>} [mcp] its .mcp.json entry
|
|
32
|
+
* @property {(opts: { history?: boolean }) => { cmd: string, args: string[], nothingToScan?: { code: number, why: string } }} [scan]
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** A front end, in any ecosystem Trellis reads: a page a browser check can open. */
|
|
36
|
+
const WEB_DEPS = [
|
|
37
|
+
"react", "vue", "svelte", "vite", "next", "@angular/core", "astro", "solid-js", "nuxt", "preact", "@sveltejs/kit", "@remix-run/react", "gatsby",
|
|
38
|
+
"django", "flask", "rails", "laravel/framework", "symfony/twig-bundle", "phoenix_live_view",
|
|
39
|
+
];
|
|
40
|
+
/** A Postgres client, in any ecosystem Trellis reads. */
|
|
41
|
+
const PG_DEPS = [
|
|
42
|
+
"pg", "postgres", "sequelize", "prisma", "@prisma/client", "knex", "typeorm", "drizzle-orm", "@neondatabase/serverless",
|
|
43
|
+
"psycopg", "psycopg2", "psycopg2-binary", "asyncpg", "github.com/lib/pq", "github.com/jackc/pgx/v5", "github.com/jackc/pgx/v4", "tokio-postgres", "sqlx",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** The image the Postgres MCP runs, pinned by digest so a re-tag upstream cannot change it. */
|
|
47
|
+
export const POSTGRES_MCP_IMAGE =
|
|
48
|
+
"crystaldba/postgres-mcp@sha256:dbbd346860d29f1543e991f30f3284bf4ab5f096d049ecc3426528f20b1b6e6b";
|
|
49
|
+
export const CONTEXT7_MCP = "@upstash/context7-mcp@4.4.4";
|
|
50
|
+
export const PLAYWRIGHT_MCP = "@playwright/mcp@0.0.83";
|
|
51
|
+
|
|
52
|
+
/** @param {Context} ctx @param {string[]} names */
|
|
53
|
+
const hasAnyDep = (ctx, names) => {
|
|
54
|
+
const deps = dependencyNames(ctx);
|
|
55
|
+
return names.some((n) => deps.has(n));
|
|
56
|
+
};
|
|
57
|
+
const always = () => true;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The repository's MCP servers whose command line matches `pattern`.
|
|
61
|
+
* @param {Context} ctx
|
|
62
|
+
* @param {RegExp} pattern
|
|
63
|
+
* @returns {Array<[string, any]>}
|
|
64
|
+
*/
|
|
65
|
+
export function mcpServers(ctx, pattern) {
|
|
66
|
+
const servers = parseJson(ctx.read(".mcp.json"))?.mcpServers ?? {};
|
|
67
|
+
return Object.entries(servers).filter(([, s]) => pattern.test([s.command, ...(s.args ?? [])].join(" ")));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** An entry whose package is not pinned to an exact version can change under you. @param {any} server @param {string} pkg */
|
|
71
|
+
const unpinned = (server, pkg) => {
|
|
72
|
+
const arg = (server.args ?? []).find((/** @type {string} */ a) => a.startsWith(pkg));
|
|
73
|
+
return !arg || !/@\d+\.\d+\.\d+/.test(arg.slice(pkg.length));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A binary on PATH, wired when some script, hook or CI file calls it.
|
|
78
|
+
* @param {Context} ctx
|
|
79
|
+
* @param {string} bin
|
|
80
|
+
* @param {(c: import("../agents/shell.mjs").Command) => boolean} call the command that runs it
|
|
81
|
+
* @returns {Check}
|
|
82
|
+
*/
|
|
83
|
+
function binaryCalled(ctx, bin, call) {
|
|
84
|
+
const at = ctx.which(bin);
|
|
85
|
+
const callers = ctx.callers(call);
|
|
86
|
+
return {
|
|
87
|
+
installed: at != null,
|
|
88
|
+
wired: callers.length > 0,
|
|
89
|
+
warnings: [],
|
|
90
|
+
detail: callers.length ? `called from ${callers.slice(0, 3).join(", ")}` : "no script, hook or CI file calls it",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @type {ReadonlyArray<Tool>} */
|
|
95
|
+
/** `trellis scan` covering `tool` (no --only, or --only naming it). @param {import("../agents/shell.mjs").Command} c @param {string} tool */
|
|
96
|
+
const trellisScans = (c, tool) =>
|
|
97
|
+
c.prog === "trellis" && c.args[0] === "scan" && (!c.args.includes("--only") || String(c.args[c.args.indexOf("--only") + 1] ?? "").split(",").includes(tool));
|
|
98
|
+
|
|
99
|
+
export const TOOLS = Object.freeze([
|
|
100
|
+
{
|
|
101
|
+
id: "abatty",
|
|
102
|
+
name: "abatty",
|
|
103
|
+
role: "guard",
|
|
104
|
+
ai: false,
|
|
105
|
+
use: "Before a push: the gate, the debt ratchet, and proof that every check can still go red.",
|
|
106
|
+
rule: "The gate runs before a push and is never bypassed; a failure is explained with `abatty explain <ID>`.",
|
|
107
|
+
install: { any: "npm i -D abatty && npx abatty init" },
|
|
108
|
+
wire: "abatty.config.json exists and a hook, script or CI file calls abatty",
|
|
109
|
+
applies: always,
|
|
110
|
+
check(ctx) {
|
|
111
|
+
const installed = dependencyNames(ctx).has("abatty") && ctx.exists("node_modules/abatty/package.json");
|
|
112
|
+
const configured = ctx.exists("abatty.config.json");
|
|
113
|
+
const callers = ctx.callers((c) => c.prog === "abatty");
|
|
114
|
+
return {
|
|
115
|
+
installed,
|
|
116
|
+
wired: configured && callers.length > 0,
|
|
117
|
+
warnings: [],
|
|
118
|
+
detail: !configured ? "no abatty.config.json" : callers.length ? `called from ${callers.slice(0, 3).join(", ")}` : "configured, but nothing calls it",
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "agentshield",
|
|
124
|
+
name: "AgentShield",
|
|
125
|
+
role: "frame",
|
|
126
|
+
ai: true,
|
|
127
|
+
use: "When agents, hooks, skills or MCP servers change: scans the AI setup itself for unsafe permissions and injection paths.",
|
|
128
|
+
install: { any: "nothing to install: npx ecc-agentshield@1.6.0 scan" },
|
|
129
|
+
wire: "a script, hook or CI file runs `ecc-agentshield scan`, or the agent hooks demand it",
|
|
130
|
+
applies: always,
|
|
131
|
+
check(ctx) {
|
|
132
|
+
const callers = ctx.callers((c) => (c.prog === "ecc-agentshield" && c.args.includes("scan")) || trellisScans(c, "agentshield"));
|
|
133
|
+
// With the agent hooks in place, the Stop rule makes the agent run it whenever the setup changes.
|
|
134
|
+
const demanded = hookState(ctx).present.includes("Stop");
|
|
135
|
+
return {
|
|
136
|
+
installed: "on-demand",
|
|
137
|
+
wired: callers.length > 0 || demanded,
|
|
138
|
+
warnings: [],
|
|
139
|
+
detail: callers.length ? `called from ${callers.slice(0, 3).join(", ")}` : demanded ? "run by agents when the agent setup changes" : "allowed perhaps, but run by nothing",
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
// Pinned: an unpinned npx fetches whatever is newest and runs it, in CI with secrets present.
|
|
143
|
+
// `--path .` (the scan runs in the repository): without it AgentShield scans the user's ~/.claude.
|
|
144
|
+
scan: () => ({ cmd: "npx", args: ["--yes", "ecc-agentshield@1.6.0", "scan", "--path", "."] }),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "graphify",
|
|
148
|
+
name: "graphify",
|
|
149
|
+
role: "guide",
|
|
150
|
+
ai: true,
|
|
151
|
+
use: "Any question about this codebase: `graphify query \"<question>\"` before grepping or reading files one by one.",
|
|
152
|
+
rule: "Local backend only on a repository holding personal data (GRAPHIFY_BACKEND=none); `graphify update .` after a change.",
|
|
153
|
+
install: { any: "uv tool install graphifyy && graphify install" },
|
|
154
|
+
wire: "a built graph (graphify-out/graph.json) and the agent skill (.claude/skills/graphify)",
|
|
155
|
+
applies: always,
|
|
156
|
+
check(ctx) {
|
|
157
|
+
const graph = ctx.exists("graphify-out/graph.json");
|
|
158
|
+
const skill = ctx.exists(".claude/skills/graphify");
|
|
159
|
+
const warnings = [];
|
|
160
|
+
if (graph && !skill) warnings.push("the graph is built but no agent skill points at it: run `graphify install`");
|
|
161
|
+
if (graph && !(ctx.read(".gitignore") ?? "").includes("graphify-out")) warnings.push("graphify-out/ is not git-ignored");
|
|
162
|
+
return {
|
|
163
|
+
installed: ctx.which("graphify") != null,
|
|
164
|
+
wired: graph,
|
|
165
|
+
warnings,
|
|
166
|
+
detail: graph ? "graph built" : "no graph built yet",
|
|
167
|
+
// One known step left, not a gap: building the graph.
|
|
168
|
+
todo: graph ? undefined : "build the graph: graphify update .",
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "context7",
|
|
174
|
+
name: "Context7",
|
|
175
|
+
role: "guide",
|
|
176
|
+
ai: true,
|
|
177
|
+
use: "Any question about a third-party library's API: current docs instead of memory.",
|
|
178
|
+
rule: "Library names and versions only in a query, never this repository's code or data.",
|
|
179
|
+
install: { any: "nothing to install: an MCP server run by npx" },
|
|
180
|
+
wire: "a context7 entry in .mcp.json",
|
|
181
|
+
applies: always,
|
|
182
|
+
mcp: () => ({ type: "stdio", command: "npx", args: [CONTEXT7_MCP], env: {} }),
|
|
183
|
+
check(ctx) {
|
|
184
|
+
const found = mcpServers(ctx, /context7-mcp/);
|
|
185
|
+
const warnings = found.length && unpinned(found[0][1], "@upstash/context7-mcp") ? ["not pinned to an exact version"] : [];
|
|
186
|
+
return { installed: "on-demand", wired: found.length > 0, warnings, detail: found.length ? `MCP server "${found[0][0]}"` : "no MCP entry" };
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: "postgres-mcp",
|
|
191
|
+
name: "Postgres MCP",
|
|
192
|
+
role: "guide",
|
|
193
|
+
ai: true,
|
|
194
|
+
use: "Questions about the database schema: tables, columns, indexes.",
|
|
195
|
+
rule: "Structure only, through a read-only role on a schema-only database; never pointed at a database holding real rows.",
|
|
196
|
+
install: { any: "docker (the server runs as a pinned image)" },
|
|
197
|
+
wire: "a postgres-mcp entry in .mcp.json, restricted, reading its URI from an environment variable",
|
|
198
|
+
applies: (ctx) => hasAnyDep(ctx, PG_DEPS),
|
|
199
|
+
mcp: (opts) => ({
|
|
200
|
+
type: "stdio",
|
|
201
|
+
command: "docker",
|
|
202
|
+
args: ["run", "-i", "--rm", "-e", "DATABASE_URI", POSTGRES_MCP_IMAGE, "--access-mode=restricted"],
|
|
203
|
+
env: { DATABASE_URI: `\${${opts.uriEnv ?? "MCP_PG_URI"}}` },
|
|
204
|
+
}),
|
|
205
|
+
check(ctx) {
|
|
206
|
+
const found = mcpServers(ctx, /postgres-mcp/);
|
|
207
|
+
if (!found.length) return { installed: ctx.which("docker") != null, wired: false, warnings: [], detail: "no MCP entry" };
|
|
208
|
+
const [name, s] = found[0];
|
|
209
|
+
const args = (s.args ?? []).join(" ");
|
|
210
|
+
const warnings = [];
|
|
211
|
+
if (!args.includes("--access-mode=restricted")) warnings.push("unrestricted access mode: the agent can write to the database");
|
|
212
|
+
if (/:\/\/[^$\s]+:[^@\s]+@/.test(JSON.stringify(s))) warnings.push("a connection string with a password is written in .mcp.json: read it from an environment variable");
|
|
213
|
+
if (s.command === "docker" && !/@sha256:/.test(args)) warnings.push("the image is not pinned by digest");
|
|
214
|
+
// The URI comes from a variable (`${MCP_PG_URI}`): unset, the server starts with no database.
|
|
215
|
+
for (const [, name] of JSON.stringify(s.env ?? {}).matchAll(/\$\{(\w+)\}/g)) {
|
|
216
|
+
if (!ctx.env[name]) warnings.push(`${name} is not set in this environment: the server Claude Code starts would have no database to read`);
|
|
217
|
+
}
|
|
218
|
+
return { installed: s.command === "docker" ? ctx.which("docker") != null : "on-demand", wired: true, warnings, detail: `MCP server "${name}"` };
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: "find-skills",
|
|
223
|
+
name: "Find Skills",
|
|
224
|
+
role: "guide",
|
|
225
|
+
ai: true,
|
|
226
|
+
use: "Looking for a capability: `npx skills find <topic>` lists skills with their source and install count.",
|
|
227
|
+
rule: "Installing a skill is a person's decision: show the package, its source and its install count first; never `-y`.",
|
|
228
|
+
install: { any: "npx skills add vercel-labs/skills --skill find-skills (after a person has agreed)" },
|
|
229
|
+
wire: "the skill is present for the agent (.claude/skills, .agents/skills, or ~/.claude/skills)",
|
|
230
|
+
applies: always,
|
|
231
|
+
check(ctx) {
|
|
232
|
+
const where = [".claude/skills/find-skills", ".agents/skills/find-skills"].find((p) => ctx.exists(p)) ??
|
|
233
|
+
(ctx.existsHome(".claude/skills/find-skills") ? "~/.claude/skills/find-skills" : null);
|
|
234
|
+
return { installed: where != null, wired: where != null, warnings: [], detail: where ?? "not present" };
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: "ecc",
|
|
239
|
+
name: "ECC",
|
|
240
|
+
role: "help",
|
|
241
|
+
ai: true,
|
|
242
|
+
use: "Planning and review: /ecc:plan, the code-reviewer and security-reviewer agents, and its hooks.",
|
|
243
|
+
install: { any: "install the ECC plugin in Claude Code (/plugin), then enable it for the project" },
|
|
244
|
+
wire: "\"ecc@...\": true under enabledPlugins in .claude/settings.json",
|
|
245
|
+
applies: always,
|
|
246
|
+
check(ctx) {
|
|
247
|
+
const plugins = parseJson(ctx.read(".claude/settings.json"))?.enabledPlugins ?? {};
|
|
248
|
+
const key = Object.keys(plugins).find((k) => k.startsWith("ecc@") && plugins[k] === true);
|
|
249
|
+
return {
|
|
250
|
+
installed: ctx.existsHome(".claude/plugins/cache/ecc"),
|
|
251
|
+
wired: key != null,
|
|
252
|
+
warnings: [],
|
|
253
|
+
detail: key ? `enabled as ${key}` : "installed, not enabled for this project",
|
|
254
|
+
// One known step left: enabling the installed plugin here.
|
|
255
|
+
todo: key ? undefined : "enable it for this project: /plugin in Claude Code, or \"ecc@<marketplace>\": true under enabledPlugins in .claude/settings.json",
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: "playwright-mcp",
|
|
261
|
+
name: "Playwright MCP",
|
|
262
|
+
role: "help",
|
|
263
|
+
ai: true,
|
|
264
|
+
use: "A UI change: the agent opens the page in a browser and verifies it before calling the task done.",
|
|
265
|
+
rule: "Isolated and headless, allowed on the local dev origin only; its snapshot folder is git-ignored and emptied after use.",
|
|
266
|
+
install: { any: "nothing to install: an MCP server run by npx" },
|
|
267
|
+
wire: "a @playwright/mcp entry in .mcp.json",
|
|
268
|
+
applies: (ctx) => hasAnyDep(ctx, WEB_DEPS),
|
|
269
|
+
mcp: (opts) => ({
|
|
270
|
+
type: "stdio",
|
|
271
|
+
command: "npx",
|
|
272
|
+
args: [PLAYWRIGHT_MCP, "--isolated", "--headless", "--allowed-origins", opts.origin ?? "http://localhost:3000"],
|
|
273
|
+
env: {},
|
|
274
|
+
}),
|
|
275
|
+
check(ctx) {
|
|
276
|
+
const found = mcpServers(ctx, /@playwright\/mcp/);
|
|
277
|
+
if (!found.length) return { installed: "on-demand", wired: false, warnings: [], detail: "no MCP entry" };
|
|
278
|
+
const [name, s] = found[0];
|
|
279
|
+
const args = s.args ?? [];
|
|
280
|
+
const warnings = [];
|
|
281
|
+
if (!args.includes("--isolated")) warnings.push("not --isolated: the agent shares a browser profile, cookies included");
|
|
282
|
+
if (!args.includes("--allowed-origins")) warnings.push("no --allowed-origins: the agent can browse anywhere");
|
|
283
|
+
if (unpinned(s, "@playwright/mcp")) warnings.push("not pinned to an exact version");
|
|
284
|
+
if (!(ctx.read(".gitignore") ?? "").includes(".playwright-mcp")) warnings.push(".playwright-mcp/ is not git-ignored: page snapshots can hold real data");
|
|
285
|
+
return { installed: "on-demand", wired: true, warnings, detail: `MCP server "${name}"` };
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
id: "playwright",
|
|
290
|
+
name: "Playwright E2E",
|
|
291
|
+
role: "guard",
|
|
292
|
+
ai: false,
|
|
293
|
+
use: "Behaviour end to end, in a real browser, on every push or nightly.",
|
|
294
|
+
install: { any: "npm i -D @playwright/test && npx playwright install" },
|
|
295
|
+
wire: "a playwright config and a script, hook or CI file that runs `playwright test`",
|
|
296
|
+
applies: (ctx) => hasAnyDep(ctx, WEB_DEPS),
|
|
297
|
+
check(ctx) {
|
|
298
|
+
const config = ctx.list(".").some((f) => /^playwright\.config\.[cm]?[jt]s$/.test(f));
|
|
299
|
+
const callers = ctx.callers((c) => c.prog === "playwright" && c.args[0] === "test");
|
|
300
|
+
return {
|
|
301
|
+
installed: dependencyNames(ctx).has("@playwright/test"),
|
|
302
|
+
wired: config && callers.length > 0,
|
|
303
|
+
warnings: [],
|
|
304
|
+
detail: !config ? "no playwright.config" : callers.length ? `called from ${callers.slice(0, 3).join(", ")}` : "configured, but nothing runs it",
|
|
305
|
+
};
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
id: "gitleaks",
|
|
310
|
+
name: "Gitleaks",
|
|
311
|
+
role: "guard",
|
|
312
|
+
ai: false,
|
|
313
|
+
use: "Before any commit that could carry a secret, and over the history in CI.",
|
|
314
|
+
install: { win: "winget install Gitleaks.Gitleaks", mac: "brew install gitleaks", linux: "download a release from github.com/gitleaks/gitleaks" },
|
|
315
|
+
wire: "a hook, script or CI file calls gitleaks",
|
|
316
|
+
applies: always,
|
|
317
|
+
// A run, not a mention: `gitleaks version` or `echo gitleaks` scans nothing.
|
|
318
|
+
// `trellis verify` (the commit checkpoint) runs gitleaks itself.
|
|
319
|
+
check: (ctx) => binaryCalled(ctx, "gitleaks", (c) => (c.prog === "gitleaks" && ["git", "dir", "detect", "protect", "stdin"].includes(c.args[0])) || trellisScans(c, "gitleaks") || (c.prog === "trellis" && c.args[0] === "verify")),
|
|
320
|
+
scan: ({ history }) => ({
|
|
321
|
+
cmd: "gitleaks",
|
|
322
|
+
args: history ? ["git", "--redact", "--no-banner"] : ["git", "--pre-commit", "--staged", "--redact", "--no-banner"],
|
|
323
|
+
}),
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
id: "osv-scanner",
|
|
327
|
+
name: "OSV-Scanner",
|
|
328
|
+
role: "guard",
|
|
329
|
+
ai: false,
|
|
330
|
+
use: "Known vulnerabilities in the dependency tree, from the lockfiles.",
|
|
331
|
+
install: { win: "winget install Google.OSVScanner", mac: "brew install osv-scanner", linux: "a pinned release from github.com/google/osv-scanner/releases (or your distribution's package)" },
|
|
332
|
+
wire: "a hook, script or CI file calls osv-scanner",
|
|
333
|
+
applies: always,
|
|
334
|
+
check: (ctx) => binaryCalled(ctx, "osv-scanner", (c) => c.prog === "osv-scanner"),
|
|
335
|
+
// Exit 128 is OSV-Scanner's "no package sources found": nothing to scan, not a finding.
|
|
336
|
+
scan: () => ({ cmd: "osv-scanner", args: ["scan", "source", "-r", "."], nothingToScan: { code: 128, why: "no lockfile or manifest found" } }),
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
id: "lighthouse",
|
|
340
|
+
name: "Lighthouse",
|
|
341
|
+
role: "guard",
|
|
342
|
+
ai: false,
|
|
343
|
+
use: "Performance, accessibility and best-practice budgets on the built front end.",
|
|
344
|
+
install: { any: "npm i -D @lhci/cli and a lighthouserc file" },
|
|
345
|
+
wire: "a lighthouserc file and a script or CI file that runs lhci",
|
|
346
|
+
applies: (ctx) => hasAnyDep(ctx, WEB_DEPS),
|
|
347
|
+
check(ctx) {
|
|
348
|
+
const config = ctx.list(".").some((f) => /^\.?lighthouserc(\.[cm]?js|\.json|\.ya?ml)?$/.test(f));
|
|
349
|
+
const callers = ctx.callers((c) => c.prog === "lhci" && ["autorun", "collect", "assert"].includes(c.args[0]));
|
|
350
|
+
return {
|
|
351
|
+
installed: dependencyNames(ctx).has("@lhci/cli"),
|
|
352
|
+
wired: config && callers.length > 0,
|
|
353
|
+
warnings: [],
|
|
354
|
+
detail: !config ? "no lighthouserc" : callers.length ? `called from ${callers.slice(0, 3).join(", ")}` : "configured, but nothing runs it",
|
|
355
|
+
};
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
]);
|
|
359
|
+
|
|
360
|
+
/** @param {string} id */
|
|
361
|
+
export function toolById(id) {
|
|
362
|
+
return TOOLS.find((t) => t.id === id) ?? null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The install line for this platform. @param {Tool} tool @param {NodeJS.Platform} platform */
|
|
366
|
+
export function installHint(tool, platform) {
|
|
367
|
+
const i = tool.install;
|
|
368
|
+
const own = platform === "win32" ? i.win : platform === "darwin" ? i.mac : i.linux;
|
|
369
|
+
return own ?? i.any ?? "see the tool's documentation";
|
|
370
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The four roles a tool plays. Trellis frames a repository with these, in this order: the rules
|
|
2
|
+
// that shape the work, where to look, who helps, and what stops a bad change.
|
|
3
|
+
|
|
4
|
+
/** @typedef {"frame" | "guide" | "help" | "guard"} Role */
|
|
5
|
+
|
|
6
|
+
/** @type {ReadonlyArray<{ id: Role, title: string, question: string }>} */
|
|
7
|
+
export const ROLES = Object.freeze([
|
|
8
|
+
{ id: "frame", title: "Frame", question: "What are the rules here, and is the setup itself safe?" },
|
|
9
|
+
{ id: "guide", title: "Guide", question: "Where do I look before I write?" },
|
|
10
|
+
{ id: "help", title: "Help", question: "Who plans, reviews and verifies with me?" },
|
|
11
|
+
{ id: "guard", title: "Guard", question: "What stops a bad change before it lands?" },
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** @param {string} id */
|
|
15
|
+
export function roleById(id) {
|
|
16
|
+
return ROLES.find((r) => r.id === id) ?? null;
|
|
17
|
+
}
|