@synovitec/trellis 0.0.0-stage → 0.6.0-rc.2

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 +393 -0
  2. package/LICENSE +190 -0
  3. package/README.md +206 -2
  4. package/SECURITY.md +104 -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 +207 -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 +129 -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 +509 -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 +163 -0
  49. package/src/verify/secrets.mjs +169 -0
  50. package/src/verify/test-commands.mjs +116 -0
@@ -0,0 +1,330 @@
1
+ // The session rules: what an agent must have done before it may say a task is done, and what it
2
+ // must have looked at before it may write. Each rule is active only when the tool it asks for is
3
+ // enabled, and each judges evidence: which files changed, by content, comes from git; what was
4
+ // checked, and against which content, comes from the ledger the hooks write as each tool call
5
+ // succeeds. A rule's mode is "block" (the agent is sent back, or the edit refused), "warn" (the
6
+ // person is told) or "off".
7
+ import { commandsIn, flagValue, gitSubcommand, selfDescribing } from "./shell.mjs";
8
+ import { commandOf } from "./transcript.mjs";
9
+ import { matchesAny } from "./glob.mjs";
10
+
11
+ /** @typedef {import("./transcript.mjs").ToolUse} ToolUse */
12
+ /** @typedef {import("./shell.mjs").Command} Command */
13
+ /** @typedef {import("./state.mjs").Ledger} Ledger */
14
+ /** @typedef {"block" | "warn" | "off"} Mode */
15
+ /**
16
+ * @typedef {object} AgentConfig
17
+ * @property {Mode} [mode] the default for every rule
18
+ * @property {Record<string, Mode>} [rules] per-rule overrides
19
+ * @property {string[]} [uiPaths]
20
+ * @property {string[]} [uiIgnore]
21
+ * @property {string[]} [agentSetupPaths]
22
+ * @property {string[]} [codePaths]
23
+ * @property {string[]} [codeIgnore]
24
+ * @property {string[]} [schemaPaths]
25
+ * @property {number} [graphAfterSearches]
26
+ * @property {number} [maxBlocks] times the agent is sent back before the person decides
27
+ */
28
+ /**
29
+ * @typedef {object} Session
30
+ * @property {Array<{ path: string, hash: string }>} changes
31
+ * @property {string[]} addedPackages
32
+ * @property {Set<string>} enabled the enabled tool ids
33
+ * @property {Ledger} ledger
34
+ * @property {{ status: "none" | "clean" | "leaks" | "unavailable", detail?: string }} secrets
35
+ * @property {boolean} configChanged
36
+ * @property {{ status: "none" | "clean" | "findings" | "unavailable", detail?: string }} [setupScan] the Stop hook's own AgentShield run
37
+ * @property {string[]} integrity reasons the evidence itself cannot be trusted
38
+ */
39
+
40
+ const GENERATED = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/coverage/**", "**/vendor/**", "**/.next/**", "**/target/**"];
41
+
42
+ export const DEFAULTS = Object.freeze({
43
+ mode: /** @type {Mode} */ ("warn"),
44
+ uiPaths: ["**/*.{jsx,tsx,vue,svelte,astro,html,css,scss,sass,less}"],
45
+ uiIgnore: ["**/*.{test,spec,stories}.*", "**/__tests__/**", "**/tests/**", "**/test/**", "**/e2e/**", "docs/**", ...GENERATED],
46
+ agentSetupPaths: [".claude/**", ".mcp.json", "CLAUDE.md", "**/CLAUDE.md", "AGENTS.md", ".agents/**", "**/SKILL.md", "trellis.config.json"],
47
+ codePaths: ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,py,go,rs,java,kt,kts,rb,php,cs,swift,vue,svelte,astro,dart,scala,c,cc,cpp,h,hpp,ex,exs}"],
48
+ codeIgnore: GENERATED,
49
+ schemaPaths: ["**/migrations/**", "**/migrate/**", "**/*.sql", "**/schema.prisma", "**/db/schema.rb", "**/alembic/versions/**", "**/models/**", "**/entities/**"],
50
+ graphAfterSearches: 6,
51
+ maxBlocks: 2,
52
+ });
53
+
54
+ /** Rules that may only warn at Stop: sending an agent back after the work is done cannot fix them. */
55
+ const WARN_ONLY_AT_STOP = new Set(["graph-first", "graph-before-edit"]);
56
+
57
+ // --- what a tool call is -------------------------------------------------------------------
58
+
59
+ /** `mcp__<server>__<tool>` as its two parts; servers may contain underscores. @param {string} name */
60
+ export function mcpParts(name) {
61
+ if (!name.startsWith("mcp__")) return null;
62
+ const rest = name.slice(5);
63
+ const cut = rest.indexOf("__");
64
+ return cut === -1 ? null : { server: rest.slice(0, cut), tool: rest.slice(cut + 2) };
65
+ }
66
+
67
+ /** The commands a call ran, without those that only describe a tool. @param {ToolUse | { name: string, input: any }} u */
68
+ const ran = (u) => commandsIn(commandOf(/** @type {ToolUse} */ (u))).filter((c) => !selfDescribing(c));
69
+
70
+ /** `trellis scan` that covers `tool` (no --only, or --only naming it). @param {Command} c @param {string} tool */
71
+ const trellisScan = (c, tool) => {
72
+ if (c.prog !== "trellis" || c.args[0] !== "scan") return false;
73
+ const only = flagValue(c.args, "--only");
74
+ return only == null || only.split(",").includes(tool);
75
+ };
76
+
77
+ const SEARCH_PROGS = new Set(["grep", "rg", "ag", "ack", "find", "fd", "findstr", "select-string"]);
78
+ /** A search of files. A grep reading another command's output (`npm test | grep x`) is not. @param {{ name: string, input: any }} u */
79
+ export const isSearch = (u) =>
80
+ u.name === "Grep" || u.name === "Glob" || ran(u).some((c) => !c.piped && (SEARCH_PROGS.has(c.prog) || gitSubcommand(c)?.sub === "grep"));
81
+ /** @param {{ name: string, input: any }} u */
82
+ export const isGraphify = (u) =>
83
+ ran(u).some((c) => c.prog === "graphify" && ["query", "path", "explain"].includes(c.args[0])) || /graphify/i.test(mcpParts(u.name)?.server ?? "");
84
+ /** @param {{ name: string, input: any }} u */
85
+ export const isAgentShield = (u) => ran(u).some((c) => (c.prog === "ecc-agentshield" && c.args.includes("scan")) || trellisScan(c, "agentshield"));
86
+ /** @param {{ name: string, input: any }} u */
87
+ export const isSecretScan = (u) => ran(u).some((c) => (c.prog === "gitleaks" && ["git", "dir", "detect", "protect", "stdin"].includes(c.args[0])) || trellisScan(c, "gitleaks"));
88
+ /** A commit that skipped the git hooks. @param {{ name: string, input: any }} u */
89
+ export const isUnhookedCommit = (u) =>
90
+ ran(u).some((c) => {
91
+ const g = gitSubcommand(c);
92
+ return g?.sub === "commit" && g.rest.some((a) => a === "--no-verify" || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(a));
93
+ });
94
+
95
+ /** @param {Set<string>} keys @param {RegExp} name @param {{ name: string }} u */
96
+ const onServer = (keys, name, u) => {
97
+ const p = mcpParts(u.name);
98
+ return !!p && (keys.has(p.server) || name.test(p.server));
99
+ };
100
+ /** @param {Set<string>} keys @param {{ name: string }} u */
101
+ export const isBrowser = (keys, u) => onServer(keys, /playwright/i, u);
102
+ /** @param {Set<string>} keys @param {{ name: string }} u */
103
+ export const isContext7 = (keys, u) => onServer(keys, /context7/i, u);
104
+ /** @param {Set<string>} keys @param {{ name: string }} u */
105
+ export const isSchemaLookup = (keys, u) => onServer(keys, /postgres|^pg$|database|^db$/i, u);
106
+
107
+ /** Whether `url` is on `origin`: same scheme, host and port; 127.0.0.1 and localhost are one. @param {string} url @param {string | null} origin */
108
+ export function onOrigin(url, origin) {
109
+ const norm = (/** @type {string} */ u) => {
110
+ try {
111
+ const x = new URL(u);
112
+ return `${x.protocol}//${x.hostname === "127.0.0.1" ? "localhost" : x.hostname}:${x.port || (x.protocol === "https:" ? "443" : "80")}`;
113
+ } catch {
114
+ return null;
115
+ }
116
+ };
117
+ const got = norm(url);
118
+ if (!got) return false;
119
+ return origin ? got === norm(origin) : /^https?:\/\/localhost:/.test(got);
120
+ }
121
+
122
+ /** @param {string[]} items */
123
+ const list = (items) => items.slice(0, 5).join(", ") + (items.length > 5 ? ` and ${items.length - 5} more` : "");
124
+
125
+ /**
126
+ * What clears an evidence rule, said plainly: an explanation does not.
127
+ * @param {Required<AgentConfig>} cfg
128
+ * @param {string} what
129
+ */
130
+ const evidenceOnly = (cfg, what) =>
131
+ `Only ${what} clears this; an explanation does not. If it is not possible here, say why in one line: after ${cfg.maxBlocks} attempt(s) the decision passes to the person.`;
132
+
133
+ /** @param {string} pkg @param {string[]} lookups */
134
+ function lookedUp(pkg, lookups) {
135
+ const name = pkg.toLowerCase();
136
+ const base = name.slice(name.lastIndexOf("/") + 1);
137
+ const esc = (/** @type {string} */ s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
138
+ // A whole word: a lookup about "react" is not one about "re", nor about "react-dom".
139
+ const word = (/** @type {string} */ s) => new RegExp(`(^|[^a-z0-9_.-])${esc(s)}($|[^a-z0-9_-])`);
140
+ return lookups.some((q) => word(name).test(q) || (base.length > 2 && word(base).test(q)));
141
+ }
142
+
143
+ // --- the rules ------------------------------------------------------------------------------
144
+
145
+ /**
146
+ * @typedef {object} Rule
147
+ * @property {string} id
148
+ * @property {string | null} needs the tool id that must be enabled; null for always
149
+ * @property {string} title
150
+ * @property {(s: Session, cfg: Required<AgentConfig>) => string | null} check the reason, or null
151
+ */
152
+
153
+ /** @param {Session} s @param {string[]} globs @param {string[]} [ignore] */
154
+ const changedUnder = (s, globs, ignore = []) => s.changes.filter((c) => matchesAny(c.path, globs) && !matchesAny(c.path, ignore));
155
+
156
+ /** @type {ReadonlyArray<Rule>} */
157
+ export const RULES = Object.freeze([
158
+ {
159
+ id: "evidence-intact",
160
+ needs: null,
161
+ title: "The session's evidence is intact",
162
+ check: (s) => (s.integrity.length ? `${s.integrity.join(" ")} The checks cannot be trusted this session: tell the person.` : null),
163
+ },
164
+ {
165
+ id: "config-unchanged",
166
+ needs: null,
167
+ title: "The Trellis rules stay as they were at the start of the session",
168
+ check: (s) =>
169
+ s.configChanged
170
+ ? "trellis.config.json changed during this session, perhaps by the person. Do not revert it; tell the person. This session is still judged against the rules as they were when it started."
171
+ : null,
172
+ },
173
+ {
174
+ id: "ui-verified",
175
+ needs: "playwright-mcp",
176
+ title: "A UI change is seen in the browser",
177
+ check: (s, cfg) => {
178
+ const pending = changedUnder(s, cfg.uiPaths, cfg.uiIgnore).filter((c) => s.ledger.browser[c.path] !== c.hash).map((c) => c.path);
179
+ return pending.length
180
+ ? `UI files changed since they were last seen in the browser: ${list(pending)}. Open the page with the Playwright MCP (navigate to the configured origin, then snapshot or screenshot). ${evidenceOnly(cfg, "a browser check of the current content")}`
181
+ : null;
182
+ },
183
+ },
184
+ {
185
+ id: "agent-setup-scanned",
186
+ needs: "agentshield",
187
+ title: "A change to the agent setup passes AgentShield",
188
+ // The Stop hook runs the scan itself (an exit code the agent produced proves nothing): this
189
+ // rule reads its result.
190
+ check: (s, cfg) => {
191
+ const pending = changedUnder(s, cfg.agentSetupPaths).filter((c) => s.ledger.setup[c.path] !== c.hash).map((c) => c.path);
192
+ if (!pending.length) return null;
193
+ const scan = s.setupScan ?? { status: "none" };
194
+ if (scan.status === "findings") return `The agent setup changed (${list(pending)}) and AgentShield reports ${scan.detail}. Fix them.`;
195
+ if (scan.status === "unavailable") return `The agent setup changed (${list(pending)}) and could not be scanned: ${scan.detail}.`;
196
+ return `The agent setup changed (${list(pending)}) and was not scanned.`;
197
+ },
198
+ },
199
+ {
200
+ id: "schema-before-change",
201
+ needs: "postgres-mcp",
202
+ title: "The database schema is looked at before a migration or a model changes",
203
+ check: (s, cfg) => {
204
+ const files = changedUnder(s, cfg.schemaPaths, cfg.codeIgnore).map((c) => c.path);
205
+ return files.length && !s.ledger.schema
206
+ ? `Schema files changed (${list(files)}) and the database schema was never looked at this session. Inspect the tables involved with the Postgres MCP and check the change against them.`
207
+ : null;
208
+ },
209
+ },
210
+ {
211
+ id: "graph-before-edit",
212
+ needs: "graphify",
213
+ title: "The code graph is asked before code changes",
214
+ check: (s, cfg) => {
215
+ const files = changedUnder(s, cfg.codePaths, cfg.codeIgnore).map((c) => c.path);
216
+ return files.length && !s.ledger.graph
217
+ ? `Code changed (${list(files)}) without a single \`graphify query\` this session. Ask the graph what depends on what you change, before you change it.`
218
+ : null;
219
+ },
220
+ },
221
+ {
222
+ id: "docs-looked-up",
223
+ needs: "context7",
224
+ title: "A new dependency is checked against its current docs",
225
+ check: (s) => {
226
+ const missing = s.addedPackages.filter((p) => !lookedUp(p, s.ledger.lookups));
227
+ return missing.length
228
+ ? `Dependencies added without a Context7 lookup naming them: ${list(missing)}. Look each one up (its current API, for the version you added) before relying on what you remember of it.`
229
+ : null;
230
+ },
231
+ },
232
+ {
233
+ id: "graph-first",
234
+ needs: "graphify",
235
+ title: "The code graph is asked before a long search",
236
+ check: (s, cfg) =>
237
+ s.ledger.searches >= cfg.graphAfterSearches && !s.ledger.graph
238
+ ? `${s.ledger.searches} file searches and no \`graphify query\` this session. Ask the graph first next time: it answers where something lives and what depends on it in one call.`
239
+ : null,
240
+ },
241
+ {
242
+ id: "secrets-scanned",
243
+ needs: "gitleaks",
244
+ title: "Every commit of the session is scanned for secrets",
245
+ check: (s) => {
246
+ if (s.secrets.status === "leaks") return `Gitleaks found secrets in commits made this session (${s.secrets.detail ?? "see `gitleaks git --redact`"}). Remove them from the history before anything is pushed, and treat the secret as exposed.`;
247
+ if (s.secrets.status === "unavailable") return `Commits were made this session and could not be scanned: ${s.secrets.detail ?? "gitleaks is not installed"}.`;
248
+ return null;
249
+ },
250
+ },
251
+ ]);
252
+
253
+ /** The rules that can refuse an edit before it happens, by the path it writes. */
254
+ export const GATES = Object.freeze(["schema-before-change", "graph-before-edit"]);
255
+
256
+ /**
257
+ * @param {AgentConfig | undefined} agents
258
+ * @returns {Required<AgentConfig>}
259
+ */
260
+ export function agentConfig(agents) {
261
+ return {
262
+ mode: agents?.mode ?? DEFAULTS.mode,
263
+ rules: agents?.rules ?? {},
264
+ uiPaths: agents?.uiPaths ?? DEFAULTS.uiPaths,
265
+ uiIgnore: agents?.uiIgnore ?? DEFAULTS.uiIgnore,
266
+ agentSetupPaths: agents?.agentSetupPaths ?? DEFAULTS.agentSetupPaths,
267
+ codePaths: agents?.codePaths ?? DEFAULTS.codePaths,
268
+ codeIgnore: agents?.codeIgnore ?? DEFAULTS.codeIgnore,
269
+ schemaPaths: agents?.schemaPaths ?? DEFAULTS.schemaPaths,
270
+ graphAfterSearches: agents?.graphAfterSearches ?? DEFAULTS.graphAfterSearches,
271
+ maxBlocks: agents?.maxBlocks ?? DEFAULTS.maxBlocks,
272
+ };
273
+ }
274
+
275
+ /**
276
+ * The mode a rule runs in. Evidence integrity blocks unless set otherwise: a check whose evidence
277
+ * was altered is no check. The config guard warns: the session is judged by the start's rules
278
+ * anyway, so an edit gains the agent nothing, and the person may be the one who made it.
279
+ * @param {Rule} rule
280
+ * @param {Required<AgentConfig>} cfg
281
+ * @param {{ atStop?: boolean }} [when]
282
+ * @returns {Mode}
283
+ */
284
+ export function modeOf(rule, cfg, when = { atStop: true }) {
285
+ const fallback = rule.id === "evidence-intact" ? "block" : rule.id === "config-unchanged" ? "warn" : cfg.mode;
286
+ const mode = cfg.rules[rule.id] ?? fallback;
287
+ return when.atStop && WARN_ONLY_AT_STOP.has(rule.id) && mode === "block" ? "warn" : mode;
288
+ }
289
+
290
+ /**
291
+ * Every rule that is not satisfied, with its mode.
292
+ * @param {Session} session
293
+ * @param {AgentConfig | undefined} agents
294
+ * @returns {Array<{ id: string, mode: Mode, reason: string }>}
295
+ */
296
+ export function evaluate(session, agents) {
297
+ const cfg = agentConfig(agents);
298
+ return RULES.flatMap((rule) => {
299
+ const mode = modeOf(rule, cfg);
300
+ if (mode === "off" || (rule.needs && !session.enabled.has(rule.needs))) return [];
301
+ const reason = rule.check(session, cfg);
302
+ return reason ? [{ id: rule.id, mode, reason }] : [];
303
+ });
304
+ }
305
+
306
+ /**
307
+ * Whether an edit to `path` may happen now. Returns the reason to refuse it, or null.
308
+ * @param {string} path repository-relative
309
+ * @param {Ledger} ledger
310
+ * @param {Set<string>} enabled
311
+ * @param {AgentConfig | undefined} agents
312
+ * @param {{ graphReady: boolean }} env
313
+ * @returns {{ id: string, mode: Mode, reason: string } | null}
314
+ */
315
+ export function gate(path, ledger, enabled, agents, env) {
316
+ const cfg = agentConfig(agents);
317
+ const rule = (/** @type {string} */ id) => /** @type {Rule} */ (RULES.find((r) => r.id === id));
318
+ const schema = rule("schema-before-change");
319
+ const schemaMode = modeOf(schema, cfg, { atStop: false });
320
+ if (schemaMode !== "off" && enabled.has("postgres-mcp") && matchesAny(path, cfg.schemaPaths) && !matchesAny(path, cfg.codeIgnore) && !ledger.schema) {
321
+ return { id: schema.id, mode: schemaMode, reason: `${path} is a schema file and the database schema has not been looked at this session. Inspect the tables it touches with the Postgres MCP (list_objects, get_object_details) first.` };
322
+ }
323
+ const graph = rule("graph-before-edit");
324
+ const graphMode = modeOf(graph, cfg, { atStop: false });
325
+ // A graph that cannot be queried here is not asked for: doctor reports it, the edit goes on.
326
+ if (graphMode !== "off" && enabled.has("graphify") && env.graphReady && matchesAny(path, cfg.codePaths) && !matchesAny(path, cfg.codeIgnore) && !ledger.graph) {
327
+ return { id: graph.id, mode: graphMode, reason: `Ask the code graph before changing code: \`graphify query "<what you are about to change, and what depends on it>"\`. One query this session unlocks every edit.` };
328
+ }
329
+ return null;
330
+ }
@@ -0,0 +1,253 @@
1
+ // A shell line as the commands it runs, each as a program and its arguments. The rules ask
2
+ // "was gitleaks run", not "does the text contain gitleaks": `echo gitleaks`, a comment and
3
+ // `gitleaks version` all contain the word and none of them scans anything.
4
+
5
+ /** @typedef {{ prog: string, args: string[], piped: boolean }} Command */
6
+
7
+ /**
8
+ * Programs that run another program: the command is what they run. `command` is not one: its
9
+ * common use, `command -v x`, asks whether x exists and runs nothing.
10
+ */
11
+ const WRAPPERS = new Set(["exec", "env", "sudo", "time", "nohup", "npx", "bunx", "pnpx"]);
12
+ /** A wrapper's own options that take a value. */
13
+ const WRAPPER_VALUE_FLAGS = new Set(["-p", "--package", "-c", "--call"]);
14
+ /** Arguments that make a tool describe itself instead of doing its work. */
15
+ const SELF_DESCRIBING = new Set(["-h", "--help", "--version", "-V", "help", "version"]);
16
+
17
+ /**
18
+ * @param {string} line
19
+ * @returns {Command[]}
20
+ */
21
+ export function commandsIn(line) {
22
+ const text = String(line ?? "");
23
+ // A function or alias named like a tool shadows it: `gitleaks(){ true; }; gitleaks git` runs nothing.
24
+ const shadowed = new Set([...text.matchAll(/(?:^|[\s;&|{(])(?:function\s+)?([A-Za-z_][\w.-]*)\s*\(\s*\)\s*\{|\bfunction\s+([A-Za-z_][\w.-]*)|\balias\s+([\w.-]+)=/g)].map((m) => (m[1] ?? m[2] ?? m[3]).toLowerCase()));
25
+ /** @type {Command[]} */
26
+ const out = [];
27
+ let prev = "";
28
+ let prevDead = false;
29
+ for (const { words, piped, sep } of segments(text)) {
30
+ const c = normalise(words, piped);
31
+ const joined = sep === "&&" || sep === "||";
32
+ // `false && x` and `true || x` never run x, nor anything chained after it.
33
+ const dead = joined && (prevDead || (prev === "false" && sep === "&&") || (prev === "true" && sep === "||"));
34
+ prev = c?.prog ?? "";
35
+ prevDead = dead;
36
+ if (!c || dead || c.prog === "false" || c.prog === "true") continue;
37
+ // `bash -c "..."` and `sh -c '...'` run their string: look inside it.
38
+ if (["bash", "sh", "zsh", "pwsh", "powershell"].includes(c.prog)) {
39
+ const i = c.args.findIndex((a) => a === "-c" || a === "-Command" || a === "-command");
40
+ if (i !== -1 && c.args[i + 1]) {
41
+ out.push(...commandsIn(c.args[i + 1]));
42
+ continue;
43
+ }
44
+ }
45
+ if (!shadowed.has(c.prog)) out.push(c);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** Whether a command only describes the tool (help, version) rather than running it. @param {Command} c */
51
+ export const selfDescribing = (c) => c.args.some((a) => SELF_DESCRIBING.has(a)) && c.args.length <= 3;
52
+
53
+ /**
54
+ * Words per command, split on ; && || | & and newlines, quotes respected, comments dropped, and
55
+ * heredoc bodies skipped: `cat <<EOF` followed by `gitleaks git` writes text, it runs nothing.
56
+ * @param {string} line
57
+ */
58
+ function segments(line) {
59
+ /** @type {Array<{ words: string[], piped: boolean, sep: string }>} */
60
+ const out = [];
61
+ /** @type {string[]} */
62
+ let words = [];
63
+ let word = "";
64
+ let inWord = false;
65
+ let piped = false;
66
+ let sep = ";";
67
+ /** @type {Array<{ tag: string, strip: boolean }>} */
68
+ const heredocs = [];
69
+ const endWord = () => {
70
+ if (inWord) {
71
+ const m = word.match(/^<<(-?)(['"]?)([A-Za-z_][\w]*)\2$/);
72
+ if (m) heredocs.push({ tag: m[3], strip: m[1] === "-" });
73
+ words.push(word);
74
+ }
75
+ word = "";
76
+ inWord = false;
77
+ };
78
+ const endSegment = (/** @type {boolean} */ nextPiped, /** @type {string} */ op) => {
79
+ endWord();
80
+ if (words.length) out.push({ words, piped, sep });
81
+ words = [];
82
+ piped = nextPiped;
83
+ sep = op;
84
+ };
85
+ for (let i = 0; i < line.length; i++) {
86
+ const ch = line[i];
87
+ if (ch === "\n" && heredocs.length) {
88
+ endSegment(false, ";");
89
+ // Skip every pending heredoc body, each up to its closing tag.
90
+ let pos = i + 1;
91
+ for (const h of heredocs) {
92
+ for (;;) {
93
+ const nl = line.indexOf("\n", pos);
94
+ const row = line.slice(pos, nl === -1 ? line.length : nl);
95
+ pos = nl === -1 ? line.length : nl + 1;
96
+ if ((h.strip ? row.replace(/^\t+/, "") : row) === h.tag || nl === -1) break;
97
+ }
98
+ }
99
+ heredocs.length = 0;
100
+ i = pos - 1;
101
+ continue;
102
+ }
103
+ if (ch === "<" && line[i + 1] === "<" && !inWord) {
104
+ // `<<EOF`, `<< 'EOF'`, `<<-EOF`: read the tag as one word.
105
+ const m = line.slice(i).match(/^<<(-?)\s*(['"]?)([A-Za-z_]\w*)\2/);
106
+ if (m) {
107
+ heredocs.push({ tag: m[3], strip: m[1] === "-" });
108
+ words.push(m[0]);
109
+ i += m[0].length - 1;
110
+ continue;
111
+ }
112
+ }
113
+ if (ch === "'" || ch === '"') {
114
+ const end = line.indexOf(ch, i + 1);
115
+ word += end === -1 ? line.slice(i + 1) : line.slice(i + 1, end);
116
+ inWord = true;
117
+ i = end === -1 ? line.length : end;
118
+ } else if (ch === "\\" && i + 1 < line.length) {
119
+ word += line[++i];
120
+ inWord = true;
121
+ } else if (ch === "#" && !inWord) {
122
+ const nl = line.indexOf("\n", i);
123
+ i = nl === -1 ? line.length : nl - 1;
124
+ } else if (ch === "|" && line[i + 1] === "|") {
125
+ endSegment(false, "||");
126
+ i++;
127
+ } else if (ch === "&" && (line[i - 1] === ">" || line[i + 1] === ">")) {
128
+ // A redirection (2>&1, &>file), not a separator.
129
+ word += ch;
130
+ inWord = true;
131
+ } else if (ch === "|") endSegment(true, "|");
132
+ else if (ch === "&" && line[i + 1] === "&") {
133
+ endSegment(false, "&&");
134
+ i++;
135
+ } else if (ch === ";" || ch === "\n" || ch === "&") endSegment(false, ";");
136
+ else if (/\s/.test(ch)) endWord();
137
+ else {
138
+ word += ch;
139
+ inWord = true;
140
+ }
141
+ }
142
+ endSegment(false, ";");
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * @param {string[]} words
148
+ * @param {boolean} piped
149
+ * @returns {Command | null}
150
+ */
151
+ function normalise(words, piped) {
152
+ let i = 0;
153
+ // Leading assignments (FOO=1 cmd) set the environment of the command, they are not it.
154
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i])) i++;
155
+ while (i < words.length) {
156
+ const p = program(words[i]);
157
+ if (WRAPPERS.has(p)) {
158
+ i++;
159
+ while (i < words.length && words[i].startsWith("-")) i += WRAPPER_VALUE_FLAGS.has(words[i]) ? 2 : 1;
160
+ } else if ((p === "pnpm" || p === "yarn") && words[i + 1] === "dlx") i += 2;
161
+ else if (p === "node" || p === "bun" || p === "deno") {
162
+ // `node path/to/tool.mjs args`: the tool is the script.
163
+ let j = i + 1;
164
+ while (j < words.length && words[j].startsWith("-")) j++;
165
+ if (j >= words.length) break;
166
+ i = j;
167
+ return { prog: program(words[i]), args: words.slice(i + 1), piped };
168
+ } else if (p === "python" || p === "python3" || p === "py") {
169
+ if (words[i + 1] === "-m" && words[i + 2]) return { prog: program(words[i + 2]), args: words.slice(i + 3), piped };
170
+ break;
171
+ } else break;
172
+ }
173
+ if (i >= words.length) return null;
174
+ return { prog: program(words[i]), args: words.slice(i + 1), piped };
175
+ }
176
+
177
+ /**
178
+ * A word as the program it names: no directory, no extension, no version, no scope.
179
+ * `./node_modules/.bin/trellis`, `@synovitec/trellis@0.3.0` and `gitleaks.exe` are trellis,
180
+ * trellis and gitleaks.
181
+ * @param {string} word
182
+ */
183
+ export function program(word) {
184
+ let w = word.replace(/\\/g, "/");
185
+ w = w.replace(/^@[^/]+\//, "");
186
+ w = w.slice(w.lastIndexOf("/") + 1);
187
+ w = w.replace(/@[^@]*$/, "");
188
+ w = w.replace(/\.(exe|cmd|bat|ps1|mjs|cjs|js|sh)$/i, "");
189
+ return w.toLowerCase();
190
+ }
191
+
192
+ /** The value after a flag, or a `--flag=value`. @param {string[]} args @param {string} flag */
193
+ export function flagValue(args, flag) {
194
+ const i = args.indexOf(flag);
195
+ if (i !== -1) return args[i + 1] ?? "";
196
+ const eq = args.find((a) => a.startsWith(`${flag}=`));
197
+ return eq ? eq.slice(flag.length + 1) : null;
198
+ }
199
+
200
+ /**
201
+ * The git subcommand, past git's own options (`git -C dir -c k=v commit`).
202
+ * @param {Command} c
203
+ */
204
+ export function gitSubcommand(c) {
205
+ if (c.prog !== "git") return null;
206
+ for (let i = 0; i < c.args.length; i++) {
207
+ const a = c.args[i];
208
+ if (a === "-C" || a === "-c" || a === "--git-dir" || a === "--work-tree" || a === "--namespace") i++;
209
+ else if (!a.startsWith("-")) return { sub: a, rest: c.args.slice(i + 1) };
210
+ }
211
+ return null;
212
+ }
213
+
214
+ /**
215
+ * The packages a command adds to a project, without versions. Global installs and bare
216
+ * `npm install` (restoring the lockfile) add nothing.
217
+ * @param {Command} c
218
+ * @returns {string[]}
219
+ */
220
+ export function packagesAdded(c) {
221
+ const positional = (/** @type {string[]} */ args, /** @type {string[]} */ valueFlags = []) => {
222
+ const out = [];
223
+ for (let i = 0; i < args.length; i++) {
224
+ if (valueFlags.includes(args[i])) i++;
225
+ else if (!args[i].startsWith("-")) out.push(args[i]);
226
+ }
227
+ return out;
228
+ };
229
+ const [sub, ...rest] = c.args;
230
+ const global = rest.includes("-g") || rest.includes("--global") || rest.includes("--location=global");
231
+ let names = [];
232
+ if (c.prog === "npm" && ["i", "install", "add", "in", "isntall"].includes(sub) && !global) names = positional(rest, ["--prefix", "-w", "--workspace"]);
233
+ else if ((c.prog === "pnpm" || c.prog === "yarn" || c.prog === "bun") && sub === "add" && !global) names = positional(rest, ["--filter", "-F", "--cwd"]);
234
+ else if ((c.prog === "pip" || c.prog === "pip3") && sub === "install") names = positional(rest, ["-r", "--requirement", "-c", "--constraint", "-e", "--editable", "-t", "--target", "-i", "--index-url"]);
235
+ else if (c.prog === "uv" && sub === "add") names = positional(rest);
236
+ else if (c.prog === "uv" && sub === "pip" && rest[0] === "install") names = positional(rest.slice(1), ["-r"]);
237
+ else if ((c.prog === "poetry" || c.prog === "cargo") && sub === "add") names = positional(rest);
238
+ else if (c.prog === "go" && sub === "get") names = positional(rest);
239
+ const upgrade = rest.includes("-U") || rest.includes("--upgrade");
240
+ return names
241
+ // A local path, a URL or a git source is the team's own code, not a library to look up.
242
+ .filter((n) => !/^[.~/\\]|^[a-zA-Z]:[\\/]|:/.test(n))
243
+ .map(packageName)
244
+ // Upgrading the installer itself adds nothing to the project.
245
+ .filter((n) => n && !(upgrade && ["pip", "setuptools", "wheel"].includes(n)));
246
+ }
247
+
248
+ /** `zod@4` → zod, `@scope/x@1.2` → @scope/x, `requests==2.1` → requests, `mod/x@v1` → mod/x. @param {string} spec */
249
+ export function packageName(spec) {
250
+ const s = spec.replace(/\[.*\]$/, "");
251
+ if (s.startsWith("@")) return s.replace(/^(@[^/]+\/[^@]+).*$/, "$1");
252
+ return s.replace(/(==|>=|<=|~=|!=|>|<|@).*$/, "");
253
+ }