@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.
- package/CHANGELOG.md +393 -0
- package/LICENSE +190 -0
- package/README.md +206 -2
- package/SECURITY.md +104 -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 +207 -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 +129 -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 +509 -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 +163 -0
- package/src/verify/secrets.mjs +169 -0
- 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
|
+
}
|