@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,780 @@
|
|
|
1
|
+
// `trellis hook <event>`: what Claude Code runs at four moments of a session. The logic lives here,
|
|
2
|
+
// in the package, so updating Trellis updates every repository's hooks at once.
|
|
3
|
+
//
|
|
4
|
+
// session-start snapshot the repository and the config; hand the agent the routing, what is
|
|
5
|
+
// broken, and what it will be checked on
|
|
6
|
+
// pre-tool refuse an edit the agent has not looked before making (the graph, the schema),
|
|
7
|
+
// and any command that would tamper with the evidence or with Trellis itself
|
|
8
|
+
// post-tool record evidence the moment a real tool call succeeds; one graph reminder
|
|
9
|
+
// stop run the scanners itself and judge the session; "block" sends the agent back
|
|
10
|
+
// (exit 2), up to `maxBlocks` times, then the person decides
|
|
11
|
+
//
|
|
12
|
+
// Evidence is recorded as it happens and only for a call Claude Code itself logged, never
|
|
13
|
+
// reconstructed from what the agent says; the hooks fail closed and visibly.
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { CONFIG_FILE, enabledTools, validateConfig } from "../core/config.mjs";
|
|
21
|
+
import { parseJson } from "../core/context.mjs";
|
|
22
|
+
import { mcpServers } from "../tools/registry.mjs";
|
|
23
|
+
import { diagnose } from "../commands/doctor.mjs";
|
|
24
|
+
import { ROLES } from "../tools/roles.mjs";
|
|
25
|
+
import { readSession, editedPath, commandOf, relativeTo } from "./transcript.mjs";
|
|
26
|
+
import { agentConfig, evaluate, gate, isBrowser, isContext7, isGraphify, isSchemaLookup, isSearch, mcpParts, modeOf, onOrigin, RULES } from "./rules.mjs";
|
|
27
|
+
import { commandsIn, packagesAdded } from "./shell.mjs";
|
|
28
|
+
import { changedSince, contentHash, dirtyFiles, excludeText, fileAt, flaggedFiles, ignoredEntries, ignoreRules, isRepo, sessionCommits, sessionRevs, startPoint, stashCount, headOf } from "./git.mjs";
|
|
29
|
+
import { emptyLedger, loadState, prune, record, saveState, testOverride } from "./state.mjs";
|
|
30
|
+
import { appendEvent, readWindow } from "../core/evidence.mjs";
|
|
31
|
+
import { treeOf, worktreeDiff } from "../core/diff.mjs";
|
|
32
|
+
import { matchesAny } from "./glob.mjs";
|
|
33
|
+
import { addedDependencies, dependenciesOf } from "./manifests.mjs";
|
|
34
|
+
|
|
35
|
+
/** @typedef {import("../core/context.mjs").Context} Context */
|
|
36
|
+
/** @typedef {import("../core/config.mjs").Config} Config */
|
|
37
|
+
/** @typedef {import("./state.mjs").SessionState} SessionState */
|
|
38
|
+
/** @typedef {{ code: number, stdout?: string, stderr?: string }} HookResult */
|
|
39
|
+
|
|
40
|
+
export const EVENTS = Object.freeze(["session-start", "pre-tool", "post-tool", "stop"]);
|
|
41
|
+
|
|
42
|
+
/** After this many searches without a graph query, the reminder fires (once a session). */
|
|
43
|
+
const NUDGE_AT = 3;
|
|
44
|
+
/** Paths that hold the evidence, or Trellis itself. Writing them is tampering. */
|
|
45
|
+
// The transcripts folder as a whole: `find ~/.claude/projects -name '*.jsonl' -exec ...` names
|
|
46
|
+
// the folder, not a .jsonl path.
|
|
47
|
+
const EVIDENCE = /trellis[\\/]sessions|trellis-sessions|info[\\/]exclude|\.git[\\/]logs|\.claude[\\/]projects([\\/]|\b)|node_modules[\\/]@synovitec[\\/]trellis/i;
|
|
48
|
+
/** The signing key: not even reading it is harmless, since it signs evidence. */
|
|
49
|
+
const KEY = /\.trellis([\\/]|\b)/i;
|
|
50
|
+
/** A person's step named anywhere in a command, quoted or as list items: `trellis approve`, `'trellis', 'approve'`. */
|
|
51
|
+
const NESTED_PERSON_STEP = /trellis(\.mjs)?["']?\s*[,\s]\s*["']?(approve|checkpoint|evidence|mcp["']?\s*[,\s]\s*["']?trust)\b/i;
|
|
52
|
+
/** Clearing the markers that say an agent is at work: `env -u CLAUDECODE`, `unset`, `CLAUDECODE=`, PowerShell's Remove-Item env:. */
|
|
53
|
+
const UNMARK = /(?:\s-u\s*|--unset[\s=]|\bunset\s+(?:-\S+\s+)*|Remove-Item\s+(?:-\S+\s+)*env:)(?:CLAUDECODE|TRELLIS_AGENT)\b|\b(?:CLAUDECODE|TRELLIS_AGENT)\s*=|\benv\s+(?:-\S+\s+)*(?:-i|--ignore-environment)\b/i;
|
|
54
|
+
/** Setting one of Trellis's test overrides, in sh, cmd or PowerShell (`$env:NAME =`). */
|
|
55
|
+
const OVERRIDES = /\b(TRELLIS_(STATE_DIR|KEY_FILE|AGENTSHIELD|GRAPHIFY)|NODE_TEST_CONTEXT)\s*=/i;
|
|
56
|
+
const INDEX_FLAGS = /update-index\b[^\n;&|]*--(no-)?(assume-unchanged|skip-worktree)/;
|
|
57
|
+
/** Commands that only read. Anything else naming evidence is treated as a write. */
|
|
58
|
+
const READERS = new Set(["cat", "ls", "head", "tail", "less", "more", "type", "get-content", "stat", "wc", "grep", "rg", "find", "file", "git"]);
|
|
59
|
+
/** What turns a `find` from a reader into a writer: it deletes, runs a command, or writes a file. */
|
|
60
|
+
const FIND_ACTIONS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"]);
|
|
61
|
+
/** The pinned AgentShield the Stop hook runs. */
|
|
62
|
+
export const AGENTSHIELD = "ecc-agentshield@1.6.0";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The repository a hook judges: the project Claude Code names, else the nearest folder above the
|
|
66
|
+
* session's working folder that holds a trellis.config.json.
|
|
67
|
+
* @param {Record<string, any>} input
|
|
68
|
+
* @param {NodeJS.ProcessEnv} env
|
|
69
|
+
* @param {string} fallback
|
|
70
|
+
*/
|
|
71
|
+
export function hookRoot(input, env, fallback) {
|
|
72
|
+
if (env.CLAUDE_PROJECT_DIR) return env.CLAUDE_PROJECT_DIR;
|
|
73
|
+
let dir = resolve(typeof input.cwd === "string" ? input.cwd : fallback);
|
|
74
|
+
for (;;) {
|
|
75
|
+
if (existsSync(join(dir, CONFIG_FILE))) return dir;
|
|
76
|
+
const up = dirname(dir);
|
|
77
|
+
if (up === dir) return typeof input.cwd === "string" ? input.cwd : fallback;
|
|
78
|
+
dir = up;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A hash of Trellis's own code as installed. Code that checks itself cannot catch every change
|
|
84
|
+
* to itself; this catches the edits that leave the check running, and the guard and the deny
|
|
85
|
+
* rules keep the agent's tools away from the rest.
|
|
86
|
+
*/
|
|
87
|
+
export function selfHash(root = fileURLToPath(new URL("../../", import.meta.url))) {
|
|
88
|
+
const h = createHash("sha256");
|
|
89
|
+
const walk = (/** @type {string} */ dir) => {
|
|
90
|
+
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
91
|
+
const p = join(dir, e.name);
|
|
92
|
+
if (e.isDirectory()) walk(p);
|
|
93
|
+
else if (/\.(mjs|js|json)$/.test(e.name)) h.update(e.name).update(readFileSync(p));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
for (const d of ["src", "bin"]) if (existsSync(join(root, d))) walk(join(root, d));
|
|
97
|
+
return h.digest("hex");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {string | null | undefined} raw
|
|
102
|
+
* @returns {{ config: Config | null, errors: string[] }}
|
|
103
|
+
*/
|
|
104
|
+
function readConfig(raw) {
|
|
105
|
+
if (raw == null) return { config: null, errors: [] };
|
|
106
|
+
const config = parseJson(raw);
|
|
107
|
+
if (config == null) return { config: null, errors: [`${CONFIG_FILE} is not valid JSON`] };
|
|
108
|
+
const errors = validateConfig(config);
|
|
109
|
+
return { config: errors.length ? null : config, errors };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The config a session is judged by: the one it started with, and only that one. A config broken
|
|
114
|
+
* at the start fails closed: a permissive one written mid-session must not take its place. Without
|
|
115
|
+
* a start record (reported on its own), the current config is all there is.
|
|
116
|
+
* @param {SessionState | null} state
|
|
117
|
+
* @param {string | null} raw
|
|
118
|
+
*/
|
|
119
|
+
function judgedConfig(state, raw) {
|
|
120
|
+
const judged = readConfig(state ? state.configText : raw);
|
|
121
|
+
return { config: judged.config, errors: judged.config ? [] : judged.errors };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {string} event
|
|
126
|
+
* @param {Record<string, any>} input the hook's JSON from stdin
|
|
127
|
+
* @param {Context} ctx
|
|
128
|
+
* @param {{ env?: NodeJS.ProcessEnv, now?: number }} [opts]
|
|
129
|
+
* @returns {HookResult}
|
|
130
|
+
*/
|
|
131
|
+
export function handleHook(event, input, ctx, opts = {}) {
|
|
132
|
+
const env = opts.env ?? process.env;
|
|
133
|
+
const raw = ctx.read(CONFIG_FILE);
|
|
134
|
+
if (event === "session-start") return sessionStart(input, ctx, raw, env, opts.now ?? Date.now());
|
|
135
|
+
const { state, status } = loadState(ctx.root, input.session_id, env);
|
|
136
|
+
const { config, errors } = judgedConfig(status === "ok" ? state : null, raw);
|
|
137
|
+
if (event === "pre-tool") return preTool(input, ctx, config, state);
|
|
138
|
+
if (event === "post-tool") return postTool(input, ctx, config, state, env);
|
|
139
|
+
if (event === "stop") return stop(input, ctx, raw, { config, errors }, { state, status }, env);
|
|
140
|
+
return { code: 0, stderr: `trellis hook: unknown event "${event}" (known: ${EVENTS.join(", ")})\n` };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* What a hook does when Trellis itself fails: closed, and said. Stop sends the agent back once
|
|
145
|
+
* (then the person is told); pre-tool refuses the call. A crash must never read as a pass.
|
|
146
|
+
* @param {string} event
|
|
147
|
+
* @param {Record<string, any>} input
|
|
148
|
+
* @param {unknown} err
|
|
149
|
+
* @returns {HookResult}
|
|
150
|
+
*/
|
|
151
|
+
export function hookFailure(event, input, err) {
|
|
152
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
153
|
+
if (event === "stop") {
|
|
154
|
+
if (input?.stop_hook_active) return tell(`the session check could not run (${why}); the session was not checked: decide yourself, and fix the cause.`);
|
|
155
|
+
return { code: 2, stderr: `[trellis] The session check could not run (${why}). Tell the person: the work was not checked.\n` };
|
|
156
|
+
}
|
|
157
|
+
if (event === "pre-tool") return deny(`Trellis could not check this call (${why}). Tell the person.`);
|
|
158
|
+
return { code: 1, stderr: `[trellis] hook ${event} failed: ${why}\n` };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @param {Context} ctx @param {string | null} raw @param {number} now @returns {SessionState} */
|
|
162
|
+
function snapshot(ctx, raw, now) {
|
|
163
|
+
const repo = isRepo(ctx.root);
|
|
164
|
+
const start = repo ? startPoint(ctx.root) : { tips: [], remotes: [], reflog: 0 };
|
|
165
|
+
return {
|
|
166
|
+
v: 2,
|
|
167
|
+
startedAt: now,
|
|
168
|
+
head: repo ? headOf(ctx.root) : null,
|
|
169
|
+
tips: start.tips,
|
|
170
|
+
remotes: start.remotes,
|
|
171
|
+
reflog: start.reflog,
|
|
172
|
+
dirty: repo ? dirtyFiles(ctx.root) : {},
|
|
173
|
+
flagged: repo ? flaggedFiles(ctx.root) : [],
|
|
174
|
+
exclude: repo ? excludeText(ctx.root) : null,
|
|
175
|
+
ignored: repo ? ignoredEntries(ctx.root) : [],
|
|
176
|
+
ignoreRules: repo ? ignoreRules(ctx.root) : null,
|
|
177
|
+
stashes: repo ? stashCount(ctx.root) : 0,
|
|
178
|
+
configText: raw,
|
|
179
|
+
self: selfHash(),
|
|
180
|
+
blocks: 0,
|
|
181
|
+
ledger: emptyLedger(),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {Record<string, any>} input
|
|
187
|
+
* @param {Context} ctx
|
|
188
|
+
* @param {string | null} raw
|
|
189
|
+
* @param {NodeJS.ProcessEnv} env
|
|
190
|
+
* @param {number} now
|
|
191
|
+
* @returns {HookResult}
|
|
192
|
+
*/
|
|
193
|
+
function sessionStart(input, ctx, raw, env, now) {
|
|
194
|
+
// Once per session, when it starts: a resume or a compaction fires this again and must keep the
|
|
195
|
+
// real start. With the record gone by then, starting a fresh one would make everything done
|
|
196
|
+
// before it the baseline, so it stays missing and Stop reports it.
|
|
197
|
+
const starting = input.source == null || input.source === "startup" || input.source === "clear";
|
|
198
|
+
if (input.session_id && starting && loadState(ctx.root, input.session_id, env).status === "missing") {
|
|
199
|
+
prune(ctx.root, env, now);
|
|
200
|
+
saveState(ctx.root, input.session_id, snapshot(ctx, raw, now), env);
|
|
201
|
+
}
|
|
202
|
+
const { config, errors } = readConfig(raw);
|
|
203
|
+
const lines = [];
|
|
204
|
+
if (errors.length) lines.push(`[trellis] ${errors.join("; ")}. The session cannot be checked against its rules until this is fixed: tell the person.`);
|
|
205
|
+
const enabled = enabledTools(ctx, config);
|
|
206
|
+
lines.push("[trellis] The tools of this repository, by role. Use them: what you do is checked as you work and when you stop.");
|
|
207
|
+
for (const role of ROLES) {
|
|
208
|
+
const tools = enabled.filter((e) => e.tool.role === role.id);
|
|
209
|
+
if (tools.length) lines.push(`[trellis] ${role.title}: ${tools.map((e) => `${e.tool.name} (${e.tool.use})`).join(" | ")}`);
|
|
210
|
+
}
|
|
211
|
+
const broken = diagnose(ctx).results.filter((r) => r.status === "missing" || r.status === "unwired");
|
|
212
|
+
if (broken.length) lines.push(`[trellis] Not working here: ${broken.map((r) => `${r.name} ${r.status}`).join(", ")}. Do not claim a check ran that cannot run.`);
|
|
213
|
+
const cfg = agentConfig(config?.agents);
|
|
214
|
+
const ids = new Set(enabled.map((e) => e.tool.id));
|
|
215
|
+
// What is checked is said; how strictly is not: a rule the agent knows only warns is a rule it skips.
|
|
216
|
+
const active = RULES.filter((r) => (!r.needs || ids.has(r.needs)) && modeOf(r, cfg) !== "off" && r.id !== "evidence-intact");
|
|
217
|
+
if (active.length) lines.push(`[trellis] You are checked on: ${active.map((r) => r.title.toLowerCase()).join("; ")}.`);
|
|
218
|
+
lines.push("[trellis] Off limits: Trellis's session files, its key (~/.trellis), its installed code, the transcripts, git's index flags, info/exclude and logs.");
|
|
219
|
+
return { code: 0, stdout: `${lines.join("\n")}\n` };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @param {string} reason */
|
|
223
|
+
const deny = (reason) => ({ code: 0, stdout: `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: `[trellis] ${reason}` } })}\n` });
|
|
224
|
+
/** @param {string} message */
|
|
225
|
+
const tell = (message) => ({ code: 0, stdout: `${JSON.stringify({ systemMessage: `[trellis] ${message}` })}\n` });
|
|
226
|
+
|
|
227
|
+
const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* An everyday command that takes Trellis out of node_modules: `git clean -x`/`-X` (node_modules
|
|
231
|
+
* is ignored, so it goes), an install or prune without devDependencies (Trellis is one), or an
|
|
232
|
+
* install of Trellis itself, which could put another version, or another package, in its place.
|
|
233
|
+
* @param {{ prog: string, args: string[] }} c
|
|
234
|
+
*/
|
|
235
|
+
function removesTrellis(c) {
|
|
236
|
+
if (c.prog === "git") return c.args.includes("clean") && c.args.some((a) => /^-[a-zA-Z]*[xX]/.test(a));
|
|
237
|
+
if (!PACKAGE_MANAGERS.includes(c.prog)) return false;
|
|
238
|
+
const withoutDev = c.args.some((a, i) => ["--omit=dev", "--production", "--prod", "--production=true"].includes(a) || (a === "--omit" && c.args[i + 1] === "dev"));
|
|
239
|
+
const installsTrellis = ["install", "i", "add", "ci", "update", "up", "upgrade"].includes(c.args[0] ?? "") && c.args.some((a) => /@synovitec\/trellis/.test(a));
|
|
240
|
+
return withoutDev || installsTrellis;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A git command that would make the session's own commits read as received from a remote: a
|
|
245
|
+
* remote-tracking ref moved by `update-ref`, a fetch whose refspec writes one (`git fetch .
|
|
246
|
+
* HEAD:refs/remotes/origin/main` is logged as a fetch), or a reflog rewritten.
|
|
247
|
+
* @param {{ prog: string, args: string[] }} c
|
|
248
|
+
*/
|
|
249
|
+
const rewritesRemoteHistory = (c) =>
|
|
250
|
+
c.prog === "git" &&
|
|
251
|
+
((c.args.includes("update-ref") && c.args.some((a) => /refs\/remotes\//.test(a))) ||
|
|
252
|
+
(c.args.includes("fetch") && c.args.some((a) => /:\+?refs\/remotes\//.test(a))) ||
|
|
253
|
+
(c.args.includes("reflog") && c.args.some((a) => a === "expire" || a === "delete")));
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Why a shell command must not run, or null: it would forge evidence, hide work from git, or
|
|
257
|
+
* take Trellis out from under the session.
|
|
258
|
+
* @param {string} cmd
|
|
259
|
+
*/
|
|
260
|
+
export function tampering(cmd) {
|
|
261
|
+
const cmds = commandsIn(cmd);
|
|
262
|
+
// The paths as the shell sees them too: `.trel''lis` and `.tr\ellis` are `.trellis` once it has
|
|
263
|
+
// removed the quotes and escapes, and a regex over the raw text would miss them.
|
|
264
|
+
const seen = [cmd, ...cmds.map((c) => [c.prog, ...c.args].join(" "))].join("\n");
|
|
265
|
+
if (INDEX_FLAGS.test(seen)) return "Telling git to ignore a file's changes (update-index) hides work from the session's checks. Leave the index flags alone.";
|
|
266
|
+
if (KEY.test(seen)) return "Trellis's signing key (~/.trellis) is off limits.";
|
|
267
|
+
if (UNMARK.test(seen)) return "Clearing CLAUDECODE or TRELLIS_AGENT makes the session's commits read as a person's. Leave the session's markers alone.";
|
|
268
|
+
if (OVERRIDES.test(seen)) return "Trellis's test settings (TRELLIS_STATE_DIR, TRELLIS_KEY_FILE, ...) move the evidence and the key away from where the commit checkpoint looks. They are for Trellis's own tests, not a session.";
|
|
269
|
+
if (cmds.some((c) => c.prog === "trellis" && c.args[0] === "hook")) return "Trellis's hooks are Claude Code's to run, not the session's: running one by hand would record evidence for a call that never happened.";
|
|
270
|
+
if (cmds.some((c) => c.prog === "trellis" && (c.args[0] === "approve" || c.args[0] === "checkpoint" || c.args[0] === "evidence"))) {
|
|
271
|
+
return "Approving a change (approve) and closing its evidence window (checkpoint) are the person's and git's steps, not an agent's. If a person must look, stop with `trellis escalate \"<why>\"`.";
|
|
272
|
+
}
|
|
273
|
+
if (cmds.some((c) => c.prog === "trellis" && c.args[0] === "mcp" && c.args[1] === "trust")) {
|
|
274
|
+
return "Trusting an MCP server lets the gateway run it as a command on this machine: that is the person's decision. Ask them to run `trellis mcp trust` themselves.";
|
|
275
|
+
}
|
|
276
|
+
// The same steps nested in another command's argument: `script -qc "trellis approve"`, a
|
|
277
|
+
// Python pty spawning ['trellis', 'approve'], which would give them the terminal they ask for.
|
|
278
|
+
if (NESTED_PERSON_STEP.test(seen)) return "A person's step (approve, checkpoint, evidence reset, mcp trust) run inside another command is still a person's step. If a person must look, stop with `trellis escalate \"<why>\"`.";
|
|
279
|
+
if (cmds.some((c) => c.prog === "trellis" && (c.args[0] === "uninstall" || c.args[0] === "init"))) {
|
|
280
|
+
return "Taking Trellis's hooks out (uninstall) or rewriting them (init) mid-session would leave the session unchecked. That is the person's decision: ask them.";
|
|
281
|
+
}
|
|
282
|
+
if (cmds.some((c) => ["npm", "pnpm", "yarn", "bun"].includes(c.prog) && ["uninstall", "un", "remove", "rm", "r"].includes(c.args[0]) && c.args.some((a) => /@synovitec\/trellis/.test(a)))) {
|
|
283
|
+
return "Removing Trellis mid-session would end the session unchecked. Ask the person.";
|
|
284
|
+
}
|
|
285
|
+
if (cmds.some(removesTrellis)) {
|
|
286
|
+
return "That command removes or replaces Trellis (it deletes ignored files, drops devDependencies, or reinstalls Trellis), which would end the session unchecked. Ask the person.";
|
|
287
|
+
}
|
|
288
|
+
if (cmds.some(rewritesRemoteHistory)) {
|
|
289
|
+
return "Moving a remote-tracking ref by hand, or rewriting a reflog, changes which commits read as the session's. Fetch, pull and push as usual.";
|
|
290
|
+
}
|
|
291
|
+
const removes = cmds.some((c) => ["rm", "rmdir", "del", "rd", "remove-item", "mv", "move", "ren", "rename-item"].includes(c.prog) && c.args.some((a) => /(^|[\\/])node_modules([\\/]|$)/.test(a)));
|
|
292
|
+
if (removes) return "Deleting or moving node_modules takes Trellis out from under the session. Ask the person.";
|
|
293
|
+
const writes = cmds.some((c) => !READERS.has(c.prog) || (c.prog === "find" && c.args.some((a) => FIND_ACTIONS.has(a)))) || /[^2]>{1,2}\s*\S/.test(cmd);
|
|
294
|
+
if (EVIDENCE.test(seen) && writes) return "That command touches the session's evidence or Trellis's installed code. Leave them alone.";
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* @param {Record<string, any>} input
|
|
300
|
+
* @param {Context} ctx
|
|
301
|
+
* @param {Config | null} config
|
|
302
|
+
* @param {SessionState | null} state
|
|
303
|
+
* @returns {HookResult}
|
|
304
|
+
*/
|
|
305
|
+
function preTool(input, ctx, config, state) {
|
|
306
|
+
const cfg = agentConfig(config?.agents);
|
|
307
|
+
const intact = /** @type {import("./rules.mjs").Rule} */ (RULES.find((r) => r.id === "evidence-intact"));
|
|
308
|
+
const guard = modeOf(intact, cfg) !== "off";
|
|
309
|
+
const name = String(input.tool_name ?? "");
|
|
310
|
+
const tool = input.tool_input ?? {};
|
|
311
|
+
if (name === "Bash" || name === "PowerShell") {
|
|
312
|
+
const why = guard ? tampering(String(tool.command ?? "")) : null;
|
|
313
|
+
return why ? deny(why) : { code: 0 };
|
|
314
|
+
}
|
|
315
|
+
const abs = String(tool.file_path ?? tool.path ?? tool.notebook_path ?? tool.relative_path ?? "");
|
|
316
|
+
if (guard && (EVIDENCE.test(abs) || KEY.test(abs))) return deny("That file is part of the session's evidence or of Trellis itself. Leave it alone.");
|
|
317
|
+
const path = editedPath({ i: 0, t: 0, name, input: tool }, ctx.root, ctx.platform);
|
|
318
|
+
if (!path) return { code: 0 };
|
|
319
|
+
const enabled = new Set(enabledTools(ctx, config).map((e) => e.tool.id));
|
|
320
|
+
const ledger = state?.ledger ?? ledgerFromTranscript(readSession(input.transcript_path).uses, ctx);
|
|
321
|
+
// A graph to ask, not a graphify binary: installed with no graph built, a query cannot answer,
|
|
322
|
+
// and refusing every edit would leave the agent no way forward but the shell.
|
|
323
|
+
const graphReady = ctx.exists("graphify-out/graph.json");
|
|
324
|
+
const refusal = gate(path, ledger, enabled, config?.agents, { graphReady });
|
|
325
|
+
if (!refusal || refusal.mode === "off") return { code: 0 };
|
|
326
|
+
if (refusal.mode === "block") return deny(refusal.reason);
|
|
327
|
+
return tell(`${refusal.id}: ${refusal.reason}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Whether a tool call succeeded, as far as its response says. @param {any} r */
|
|
331
|
+
export function succeeded(r) {
|
|
332
|
+
if (r == null || typeof r !== "object") return true;
|
|
333
|
+
if (r.is_error || r.isError || r.interrupted) return false;
|
|
334
|
+
for (const k of ["exitCode", "exit_code", "returnCode", "code"]) if (typeof r[k] === "number" && r[k] !== 0) return false;
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** A response's text, whatever its shape. @param {any} r */
|
|
339
|
+
const outputOf = (r) => (r == null ? "" : typeof r === "string" ? r : [r.stdout, r.output, r.content, r.result].map((x) => (typeof x === "string" ? x : x ? JSON.stringify(x) : "")).join("\n"));
|
|
340
|
+
|
|
341
|
+
/** What may run beside a graph query without being able to write an answer: a cd before it, a head after it. */
|
|
342
|
+
const BESIDE_GRAPH = new Set(["graphify", "cd", "head"]);
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* A graph query that answered: graphify's own output is in the response, graphify is all that
|
|
346
|
+
* ran (beside a `cd` or a `| head`), so no other command can have printed the answer, and the
|
|
347
|
+
* program run is not a script inside the repository (an agent-written `./graphify`).
|
|
348
|
+
* @param {{ name: string, input: any }} use
|
|
349
|
+
* @param {any} response
|
|
350
|
+
* @param {Context} ctx
|
|
351
|
+
*/
|
|
352
|
+
export function graphAnswered(use, response, ctx) {
|
|
353
|
+
if (mcpParts(use.name)) return isGraphify(use) && succeeded(response);
|
|
354
|
+
if (!isGraphify(use) || !succeeded(response)) return false;
|
|
355
|
+
const cmd = String(use.input.command ?? "");
|
|
356
|
+
if (repoLocal(cmd, "graphify", ctx) || /(^|[\s;&|])PATH=/.test(cmd)) return false;
|
|
357
|
+
if (commandsIn(cmd).some((c) => !BESIDE_GRAPH.has(c.prog))) return false;
|
|
358
|
+
return /^Graph: .*\bnodes?\b|^NODE /m.test(outputOf(response));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Whether a command runs `tool` from a path inside the repository: `./bin/graphify`, `tools/graphify`,
|
|
363
|
+
* or an absolute path under the root. The real tool lives outside it.
|
|
364
|
+
* @param {string} cmd
|
|
365
|
+
* @param {string} tool
|
|
366
|
+
* @param {Context} ctx
|
|
367
|
+
*/
|
|
368
|
+
function repoLocal(cmd, tool, ctx) {
|
|
369
|
+
for (const w of cmd.split(/[\s;&|()]+/)) {
|
|
370
|
+
const word = w.replace(/^["']|["']$/g, "");
|
|
371
|
+
if (!/[\\/]/.test(word) || !new RegExp(`[\\\\/]${tool}(\\.\\w+)?$`, "i").test(word)) continue;
|
|
372
|
+
if (!/^([a-zA-Z]:)?[\\/]/.test(word)) return true;
|
|
373
|
+
if (relativeTo(word, ctx.root, ctx.platform) != null) return true;
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Whether Claude Code itself logged this call: its id, or else the same tool with the same input,
|
|
380
|
+
* in the session's transcript (subagents' included). A hook run by hand from the shell has no
|
|
381
|
+
* such entry, so it records nothing.
|
|
382
|
+
* @param {Record<string, any>} input
|
|
383
|
+
* @param {import("./transcript.mjs").ToolUse[]} uses
|
|
384
|
+
*/
|
|
385
|
+
function logged(input, uses) {
|
|
386
|
+
const name = String(input.tool_name ?? "");
|
|
387
|
+
if (typeof input.tool_use_id === "string") return uses.some((u) => u.id === input.tool_use_id && u.name === name);
|
|
388
|
+
const want = JSON.stringify(input.tool_input ?? {});
|
|
389
|
+
return uses.some((u) => u.name === name && JSON.stringify(u.input) === want);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* @param {Record<string, any>} input
|
|
394
|
+
* @param {Context} ctx
|
|
395
|
+
* @param {Config | null} config
|
|
396
|
+
* @param {SessionState | null} state
|
|
397
|
+
* @param {NodeJS.ProcessEnv} env
|
|
398
|
+
* @returns {HookResult}
|
|
399
|
+
*/
|
|
400
|
+
function postTool(input, ctx, config, state, env) {
|
|
401
|
+
const cfg = agentConfig(config?.agents);
|
|
402
|
+
const use = { name: String(input.tool_name ?? ""), input: input.tool_input ?? {} };
|
|
403
|
+
const response = input.tool_response;
|
|
404
|
+
const ok = succeeded(response);
|
|
405
|
+
const enabledList = enabledTools(ctx, config);
|
|
406
|
+
const graphOn = enabledList.some((e) => e.tool.id === "graphify");
|
|
407
|
+
const nudgeOn = graphOn && modeOf(/** @type {any} */ (RULES.find((r) => r.id === "graph-first")), cfg) !== "off";
|
|
408
|
+
// Most calls are no evidence of anything: answer them without reading the transcript, which
|
|
409
|
+
// can be large, so a session's every Bash and MCP call does not pay for it.
|
|
410
|
+
// That the agent is at work, once per window, as evidence: whether a commit is an agent's must
|
|
411
|
+
// not rest on CLAUDECODE, which `env -u CLAUDECODE git commit` removes.
|
|
412
|
+
const window = readWindow(ctx.root, env);
|
|
413
|
+
if (window.status === "ok" && !window.events.some((e) => e.agent === AGENT)) appendEvent(ctx.root, { kind: "agent", tool: use.name, agent: AGENT }, env);
|
|
414
|
+
const servers = serverKeys(ctx);
|
|
415
|
+
const relevant = isSearch(use) || isGraphify(use) || !!mcpParts(use.name) && (isBrowser(servers.playwright, use) || isContext7(servers.context7, use) || isSchemaLookup(servers.postgres, use));
|
|
416
|
+
if (!relevant) return { code: 0 };
|
|
417
|
+
const { uses } = readSession(input.transcript_path);
|
|
418
|
+
if (!state) {
|
|
419
|
+
// No session record: the reminder still works, from the transcript, at the threshold exactly.
|
|
420
|
+
if (!nudgeOn || uses.some(isGraphify) || uses.filter(isSearch).length !== NUDGE_AT) return { code: 0 };
|
|
421
|
+
return nudge();
|
|
422
|
+
}
|
|
423
|
+
if (!logged(input, uses)) return { code: 0 };
|
|
424
|
+
const sid = input.session_id;
|
|
425
|
+
const l = state.ledger;
|
|
426
|
+
const origin = /** @type {any} */ (enabledList.find((e) => e.tool.id === "playwright-mcp")?.opts)?.origin ?? null;
|
|
427
|
+
// The same evidence goes to the commit checkpoint's log, which every agent and person share.
|
|
428
|
+
const feed = (/** @type {Omit<import("../core/evidence.mjs").Event, "agent">} */ e) => appendEvent(ctx.root, { ...e, agent: AGENT }, env);
|
|
429
|
+
if (isSearch(use)) record(ctx.root, sid, { k: "search" }, env);
|
|
430
|
+
if (graphAnswered(use, response, ctx)) {
|
|
431
|
+
record(ctx.root, sid, { k: "graph" }, env);
|
|
432
|
+
feed({ kind: "graph", tool: "graphify", subject: String(use.input.command ?? use.name).slice(0, 300) });
|
|
433
|
+
}
|
|
434
|
+
if (ok && isSchemaLookup(servers.postgres, use)) {
|
|
435
|
+
record(ctx.root, sid, { k: "schema" }, env);
|
|
436
|
+
feed({ kind: "schema", tool: use.name });
|
|
437
|
+
}
|
|
438
|
+
if (ok && isContext7(servers.context7, use)) {
|
|
439
|
+
const q = JSON.stringify(use.input).toLowerCase().slice(0, 500);
|
|
440
|
+
record(ctx.root, sid, { k: "lookup", q }, env);
|
|
441
|
+
feed({ kind: "docs", tool: use.name, subject: q });
|
|
442
|
+
}
|
|
443
|
+
const p = mcpParts(use.name);
|
|
444
|
+
if (p && isBrowser(servers.playwright, use)) {
|
|
445
|
+
if (p.tool === "browser_navigate") record(ctx.root, sid, { k: "nav", on: ok && onOrigin(String(use.input.url ?? ""), origin) }, env);
|
|
446
|
+
else if ((p.tool === "browser_snapshot" || p.tool === "browser_take_screenshot") && ok && l.onPage) {
|
|
447
|
+
// The content the page could show is the content as it is now: record it, file by file.
|
|
448
|
+
/** @type {Record<string, string>} */
|
|
449
|
+
const files = {};
|
|
450
|
+
for (const c of changes(ctx, state, [])) if (matchesAny(c.path, cfg.uiPaths)) files[c.path] = c.hash;
|
|
451
|
+
record(ctx.root, sid, { k: "browser", files }, env);
|
|
452
|
+
const blobs = uiBlobs(ctx, cfg.uiPaths);
|
|
453
|
+
if (blobs) feed({ kind: "browser", tool: p.tool, blobs });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const searches = l.searches + (isSearch(use) ? 1 : 0);
|
|
457
|
+
const fire = nudgeOn && !l.nudged && !l.graph && searches >= NUDGE_AT;
|
|
458
|
+
if (fire) record(ctx.root, sid, { k: "nudged" }, env);
|
|
459
|
+
return fire ? nudge() : { code: 0 };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The agent these hooks speak for, as the evidence names it. */
|
|
463
|
+
const AGENT = "claude-code";
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The git blob of every UI file that differs from HEAD, as it is on disk now: what a browser
|
|
467
|
+
* check just looked at, in the ids the commit will carry. Null when git cannot tell (no commit yet).
|
|
468
|
+
* @param {Context} ctx
|
|
469
|
+
* @param {string[]} uiPaths
|
|
470
|
+
* @returns {Record<string, string> | null}
|
|
471
|
+
*/
|
|
472
|
+
function uiBlobs(ctx, uiPaths) {
|
|
473
|
+
try {
|
|
474
|
+
/** @type {Record<string, string>} */
|
|
475
|
+
const blobs = {};
|
|
476
|
+
for (const c of worktreeDiff(ctx.root, treeOf(ctx.root, "HEAD"))) if (c.newBlob && matchesAny(c.path, uiPaths)) blobs[c.path] = c.newBlob;
|
|
477
|
+
return blobs;
|
|
478
|
+
} catch {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const nudge = () => ({
|
|
484
|
+
code: 0,
|
|
485
|
+
stdout: `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: "[trellis] Several file searches in a row: `graphify query \"<question>\"` answers where something lives and what depends on it in one call. Ask it before the next search." } })}\n`,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
/** @param {Context} ctx */
|
|
489
|
+
function serverKeys(ctx) {
|
|
490
|
+
const keys = (/** @type {RegExp} */ re) => new Set(mcpServers(ctx, re).map(([k]) => k));
|
|
491
|
+
return { playwright: keys(/@playwright\/mcp/), context7: keys(/context7-mcp/), postgres: keys(/postgres-mcp|postgres/) };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Files changed this session, by content. From git when there is a snapshot; else from the
|
|
496
|
+
* agent's own edit calls, which misses shell and subagent writes (the weak path, always said).
|
|
497
|
+
* @param {Context} ctx
|
|
498
|
+
* @param {SessionState | null} state
|
|
499
|
+
* @param {import("./transcript.mjs").ToolUse[]} uses
|
|
500
|
+
*/
|
|
501
|
+
function changes(ctx, state, uses) {
|
|
502
|
+
if (state && isRepo(ctx.root)) return changedSince(ctx.root, state);
|
|
503
|
+
const paths = new Set(uses.flatMap((u) => editedPath(u, ctx.root, ctx.platform) ?? []));
|
|
504
|
+
return [...paths].map((path) => ({ path, hash: contentHash(ctx.root, path) }));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The weak path's ledger, rebuilt from the transcript when no session record exists. It is only
|
|
509
|
+
* used together with an `evidence-intact` finding, so it never turns a missing record into a pass.
|
|
510
|
+
* @param {import("./transcript.mjs").ToolUse[]} uses
|
|
511
|
+
* @param {Context} ctx
|
|
512
|
+
*/
|
|
513
|
+
function ledgerFromTranscript(uses, ctx) {
|
|
514
|
+
const servers = serverKeys(ctx);
|
|
515
|
+
const l = emptyLedger();
|
|
516
|
+
l.searches = uses.filter(isSearch).length;
|
|
517
|
+
l.graph = uses.some(isGraphify);
|
|
518
|
+
l.schema = uses.some((u) => isSchemaLookup(servers.postgres, u));
|
|
519
|
+
l.lookups = uses.filter((u) => isContext7(servers.context7, u)).map((u) => JSON.stringify(u.input).toLowerCase());
|
|
520
|
+
return l;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* @param {Record<string, any>} input
|
|
525
|
+
* @param {Context} ctx
|
|
526
|
+
* @param {string | null} raw
|
|
527
|
+
* @param {{ config: Config | null, errors: string[] }} judged
|
|
528
|
+
* @param {{ state: SessionState | null, status: string }} loaded
|
|
529
|
+
* @param {NodeJS.ProcessEnv} env
|
|
530
|
+
* @returns {HookResult}
|
|
531
|
+
*/
|
|
532
|
+
/**
|
|
533
|
+
* Work hidden from git's status by an ignore rule the session changed: when the rules differ from
|
|
534
|
+
* the start, every file ignored now under an entry that was not ignored then, where the rules
|
|
535
|
+
* look (code, UI, schema, agent setup). With the rules unchanged, a newly ignored file is one the
|
|
536
|
+
* repository always ignored (a build output), so there is nothing to compare. An older record
|
|
537
|
+
* without the start fingerprint compares nothing.
|
|
538
|
+
* @param {string} root
|
|
539
|
+
* @param {SessionState} state
|
|
540
|
+
* @param {Required<import("./rules.mjs").AgentConfig>} cfg
|
|
541
|
+
*/
|
|
542
|
+
function newlyIgnored(root, state, cfg) {
|
|
543
|
+
if (!state.ignoreRules || !Array.isArray(state.ignored) || ignoreRules(root) === state.ignoreRules) return [];
|
|
544
|
+
const before = new Set(state.ignored);
|
|
545
|
+
const fresh = ignoredEntries(root).filter((p) => !before.has(p));
|
|
546
|
+
const watched = [...cfg.codePaths, ...cfg.uiPaths, ...cfg.schemaPaths, ...cfg.agentSetupPaths];
|
|
547
|
+
return ignoredEntries(root, fresh).filter((p) => matchesAny(p, watched));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function stop(input, ctx, raw, judged, loaded, env) {
|
|
551
|
+
const { state, status } = loaded;
|
|
552
|
+
const { config } = judged;
|
|
553
|
+
const cfg = agentConfig(config?.agents);
|
|
554
|
+
const enabledList = enabledTools(ctx, config);
|
|
555
|
+
const enabled = new Set(enabledList.map((e) => e.tool.id));
|
|
556
|
+
const { uses, readable } = readSession(input.transcript_path);
|
|
557
|
+
const repo = isRepo(ctx.root);
|
|
558
|
+
const good = status === "ok" ? state : null;
|
|
559
|
+
const integrity = [];
|
|
560
|
+
const notes = [];
|
|
561
|
+
if (status === "tampered") integrity.push("The session's evidence was altered.");
|
|
562
|
+
if ((status === "missing" || status === "no-session") && uses.length) {
|
|
563
|
+
integrity.push("No evidence was recorded for this session (the session-start hook did not run, or its record was removed), so writes through the shell or by a subagent cannot be seen.");
|
|
564
|
+
}
|
|
565
|
+
if (good && repo) {
|
|
566
|
+
const flaggedNow = flaggedFiles(ctx.root).filter((f) => !good.flagged.includes(f));
|
|
567
|
+
if (flaggedNow.length) integrity.push(`git was told to ignore changes to ${flaggedNow.slice(0, 5).join(", ")} during the session (update-index).`);
|
|
568
|
+
if ((excludeText(ctx.root) ?? null) !== good.exclude) integrity.push(".git/info/exclude changed during the session.");
|
|
569
|
+
const hidden = newlyIgnored(ctx.root, good, cfg);
|
|
570
|
+
if (hidden.length) integrity.push(`Files were hidden from git during the session by an ignore rule (a .gitignore, core.excludesFile): ${hidden.slice(0, 5).join(", ")}.`);
|
|
571
|
+
}
|
|
572
|
+
if (good?.self && good.self !== selfHash()) integrity.push("Trellis's own installed code changed during the session.");
|
|
573
|
+
if (!repo) notes.push("not a git repository: changes are read from the agent's edit calls only, so shell writes are not seen");
|
|
574
|
+
if (!readable) notes.push("the session transcript could not be read");
|
|
575
|
+
const changed = changes(ctx, good, uses);
|
|
576
|
+
const ledger = good ? good.ledger : ledgerFromTranscript(uses, ctx);
|
|
577
|
+
const setupScan = enabled.has("agentshield") ? scanSetup(ctx, input.session_id, changed, ledger, cfg, good, env) : { status: /** @type {const} */ ("none") };
|
|
578
|
+
const session = {
|
|
579
|
+
changes: changed,
|
|
580
|
+
addedPackages: addedPackages(ctx, uses, changed, good),
|
|
581
|
+
enabled,
|
|
582
|
+
ledger,
|
|
583
|
+
setupScan,
|
|
584
|
+
secrets: enabled.has("gitleaks") ? scanCommits(ctx, good) : { status: /** @type {const} */ ("none") },
|
|
585
|
+
configChanged: good != null && norm(good.configText) !== norm(raw),
|
|
586
|
+
integrity,
|
|
587
|
+
};
|
|
588
|
+
const findings = evaluate(session, config?.agents);
|
|
589
|
+
if (!config && judged.errors.length) findings.unshift({ id: "config-invalid", mode: "block", reason: `${judged.errors[0]}: the session rules cannot run. Fix the file, or tell the person.` });
|
|
590
|
+
return decide(findings, notes, input, ctx, good, cfg.maxBlocks, env);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** @param {string | null | undefined} s */
|
|
594
|
+
const norm = (s) => (s ?? "").replace(/\r\n/g, "\n").trim();
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Packages added this session: from install commands the agent ran, and from every manifest whose
|
|
598
|
+
* dependencies grew. Only manifests are read at the start revision, one `git show` each, so the
|
|
599
|
+
* cost does not grow with the number of changed files.
|
|
600
|
+
* @param {Context} ctx
|
|
601
|
+
* @param {import("./transcript.mjs").ToolUse[]} uses
|
|
602
|
+
* @param {Array<{ path: string }>} changed
|
|
603
|
+
* @param {SessionState | null} state
|
|
604
|
+
*/
|
|
605
|
+
function addedPackages(ctx, uses, changed, state) {
|
|
606
|
+
const names = new Set(uses.flatMap((u) => commandsIn(commandOf(u)).flatMap(packagesAdded)));
|
|
607
|
+
for (const c of changed) {
|
|
608
|
+
if (dependenciesOf(c.path, "") == null) continue;
|
|
609
|
+
const before = state?.head ? fileAt(ctx.root, state.head, c.path) : null;
|
|
610
|
+
for (const d of addedDependencies(c.path, before, ctx.read(c.path))) names.add(d);
|
|
611
|
+
}
|
|
612
|
+
return [...names];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Runs AgentShield itself over the agent setup when it changed since the last clean run, rather
|
|
617
|
+
* than trusting an exit code the agent produced. A clean run is recorded with the content it saw.
|
|
618
|
+
* @param {Context} ctx
|
|
619
|
+
* @param {string | undefined} sid
|
|
620
|
+
* @param {Array<{ path: string, hash: string }>} changed
|
|
621
|
+
* @param {import("./state.mjs").Ledger} ledger
|
|
622
|
+
* @param {Required<import("./rules.mjs").AgentConfig>} cfg
|
|
623
|
+
* @param {SessionState | null} state
|
|
624
|
+
* @param {NodeJS.ProcessEnv} env
|
|
625
|
+
* @returns {{ status: "none" | "clean" | "findings" | "unavailable", detail?: string }}
|
|
626
|
+
*/
|
|
627
|
+
/** @param {string} p */
|
|
628
|
+
const slashes = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Runs AgentShield on the repository and reads its JSON report from a file (its output can run
|
|
632
|
+
* to megabytes). Without `--path` it scans the user's ~/.claude, so the report must name the
|
|
633
|
+
* repository as its target; a report that cannot be read, or that is about another folder, is
|
|
634
|
+
* an error, never a clean scan.
|
|
635
|
+
* @param {Context} ctx
|
|
636
|
+
* @param {NodeJS.ProcessEnv} env
|
|
637
|
+
* @returns {{ findings: Array<{ severity?: string, file?: string }> } | { error: string }}
|
|
638
|
+
*/
|
|
639
|
+
function runAgentShield(ctx, env) {
|
|
640
|
+
const dir = mkdtempSync(join(tmpdir(), "trellis-agentshield-"));
|
|
641
|
+
try {
|
|
642
|
+
const out = join(dir, "report.json");
|
|
643
|
+
const custom = testOverride(env, "TRELLIS_AGENTSHIELD");
|
|
644
|
+
const run = agentShieldCommand(ctx.root, out, dir, custom);
|
|
645
|
+
const r = spawnSync(run.cmd, run.args, { ...run.opts, encoding: "utf8", timeout: 120_000, windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
|
|
646
|
+
if (r.error || r.status == null) return { error: `AgentShield did not complete (${r.error?.message ?? "killed"})` };
|
|
647
|
+
const report = parseJson(existsSync(out) ? readFileSync(out, "utf8") : "");
|
|
648
|
+
if (!report || !Array.isArray(report.findings)) return { error: `AgentShield's report could not be read (exit ${r.status})` };
|
|
649
|
+
if (resolve(String(report.targetPath ?? "")).toLowerCase() !== resolve(ctx.root).toLowerCase()) {
|
|
650
|
+
return { error: `AgentShield scanned ${report.targetPath ?? "an unknown folder"}, not the repository` };
|
|
651
|
+
}
|
|
652
|
+
return { findings: report.findings };
|
|
653
|
+
} finally {
|
|
654
|
+
rmSync(dir, { recursive: true, force: true });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* How the Stop hook runs AgentShield. npx runs from `away`, an empty temporary folder: run from the
|
|
660
|
+
* repository, it would take a `node_modules/ecc-agentshield` the session planted there over the
|
|
661
|
+
* pinned version, which it now fetches from the registry with npm's integrity check. The scan
|
|
662
|
+
* still targets the repository, by an absolute `--path`.
|
|
663
|
+
* @param {string} root
|
|
664
|
+
* @param {string} out where the JSON report goes
|
|
665
|
+
* @param {string} away an empty folder outside the repository
|
|
666
|
+
* @param {string} [custom] a stand-in script (tests only, see testOverride)
|
|
667
|
+
* @returns {{ cmd: string, args: string[], opts: { cwd: string, shell?: boolean } }}
|
|
668
|
+
*/
|
|
669
|
+
export function agentShieldCommand(root, out, away, custom) {
|
|
670
|
+
const args = ["scan", "--path", resolve(root), "--format", "json", "--output", out];
|
|
671
|
+
if (custom) return { cmd: process.execPath, args: [custom, ...args], opts: { cwd: away } };
|
|
672
|
+
const win = process.platform === "win32";
|
|
673
|
+
return { cmd: "npx", args: ["--yes", AGENTSHIELD, ...args].map((a) => (win ? `"${a}"` : a)), opts: { cwd: away, shell: win } };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function scanSetup(ctx, sid, changed, ledger, cfg, state, env) {
|
|
677
|
+
const pending = changed.filter((c) => matchesAny(c.path, cfg.agentSetupPaths) && ledger.setup[c.path] !== c.hash);
|
|
678
|
+
if (!pending.length) return { status: "none" };
|
|
679
|
+
const report = runAgentShield(ctx, env);
|
|
680
|
+
if ("error" in report) return { status: "unavailable", detail: report.error };
|
|
681
|
+
const changedPaths = new Set(pending.map((c) => c.path));
|
|
682
|
+
const serious = report.findings.filter((f) => (f.severity === "critical" || f.severity === "high") && changedPaths.has(slashes(String(f.file ?? ""))));
|
|
683
|
+
if (serious.length) {
|
|
684
|
+
const count = (/** @type {string} */ s) => serious.filter((f) => f.severity === s).length;
|
|
685
|
+
const files = [...new Set(serious.map((f) => slashes(String(f.file))))].join(", ");
|
|
686
|
+
return { status: "findings", detail: `${count("critical")} critical and ${count("high")} high finding(s) in ${files}; run \`npx ${AGENTSHIELD} scan --path .\` to see them` };
|
|
687
|
+
}
|
|
688
|
+
if (state) {
|
|
689
|
+
/** @type {Record<string, string>} */
|
|
690
|
+
const files = {};
|
|
691
|
+
for (const c of pending) files[c.path] = c.hash;
|
|
692
|
+
record(ctx.root, sid, { k: "setup", files }, env);
|
|
693
|
+
for (const c of pending) ledger.setup[c.path] = c.hash;
|
|
694
|
+
}
|
|
695
|
+
return { status: "clean" };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Scans every commit the session made, on any branch or only in the reflog, chosen by
|
|
700
|
+
* reachability from where things stood at the start (a commit date can be set to anything). The
|
|
701
|
+
* hook runs the scanner itself; without gitleaks, the session's commits are reported unscanned.
|
|
702
|
+
* @param {Context} ctx
|
|
703
|
+
* @param {SessionState | null} state
|
|
704
|
+
* @returns {{ status: "none" | "clean" | "leaks" | "unavailable", detail?: string }}
|
|
705
|
+
*/
|
|
706
|
+
function scanCommits(ctx, state) {
|
|
707
|
+
if (!state?.tips) return { status: "none" };
|
|
708
|
+
const commits = sessionCommits(ctx.root, state);
|
|
709
|
+
if (!commits.length) return { status: "none" };
|
|
710
|
+
if (!ctx.which("gitleaks")) return { status: "unavailable", detail: `gitleaks is not installed, so the session's ${commits.length} commit(s) could not be scanned` };
|
|
711
|
+
const revs = sessionRevs(ctx.root, state).join(" ");
|
|
712
|
+
const pinned = mkdtempSync(join(tmpdir(), "trellis-gitleaks-"));
|
|
713
|
+
try {
|
|
714
|
+
// An allowlist is the agent's to write: an inline `gitleaks:allow` in its own commit never
|
|
715
|
+
// counts, and the repository's config and ignore file count only as they were at the start.
|
|
716
|
+
const args = ["git", `--log-opts=${revs}`, "--redact", "--no-banner", "--exit-code", "1", "--ignore-gitleaks-allow"];
|
|
717
|
+
if (allowlistChanged(ctx.root, state)) {
|
|
718
|
+
writeFileSync(join(pinned, "gitleaks.toml"), "[extend]\nuseDefault = true\n");
|
|
719
|
+
args.push("--config", join(pinned, "gitleaks.toml"), "--gitleaks-ignore-path", pinned);
|
|
720
|
+
}
|
|
721
|
+
const { GITLEAKS_CONFIG: _, GITLEAKS_CONFIG_TOML: __, ...env } = process.env;
|
|
722
|
+
const r = spawnSync("gitleaks", args, { cwd: ctx.root, encoding: "utf8", timeout: 40_000, windowsHide: true, env });
|
|
723
|
+
if (r.status === 0) return { status: "clean" };
|
|
724
|
+
const found = `${r.stdout}${r.stderr}`.match(/leaks found:\s*(\d+)/i);
|
|
725
|
+
if (r.status === 1 && found) return { status: "leaks", detail: `${found[1]} finding(s) in ${commits.length} commit(s)` };
|
|
726
|
+
return { status: "unavailable", detail: `gitleaks did not complete (${r.error?.message ?? `exit ${r.status}`})` };
|
|
727
|
+
} finally {
|
|
728
|
+
rmSync(pinned, { recursive: true, force: true });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const ALLOWLISTS = [".gitleaks.toml", ".gitleaksignore"];
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Whether the session touched gitleaks's allowlists: committed, staged or in the work tree,
|
|
736
|
+
* against HEAD at the start. Without a start HEAD, assume it did.
|
|
737
|
+
* @param {string} root
|
|
738
|
+
* @param {SessionState} state
|
|
739
|
+
*/
|
|
740
|
+
function allowlistChanged(root, state) {
|
|
741
|
+
if (!state.head) return true;
|
|
742
|
+
const diff = spawnSync("git", ["diff", "--quiet", state.head, "--", ...ALLOWLISTS], { cwd: root, windowsHide: true });
|
|
743
|
+
if (diff.status !== 0) return true;
|
|
744
|
+
const untracked = spawnSync("git", ["ls-files", "--others", "--", ...ALLOWLISTS], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
745
|
+
return untracked.status !== 0 || untracked.stdout.trim() !== "";
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Block, warn, or hand over to the person once the agent has been sent back `maxBlocks` times.
|
|
750
|
+
* Trellis counts its own blocks: Claude Code's `stop_hook_active` is set by any Stop hook's
|
|
751
|
+
* block, so trusting it would let another hook's block switch this one off.
|
|
752
|
+
* @param {Array<{ id: string, mode: string, reason: string }>} findings
|
|
753
|
+
* @param {string[]} notes
|
|
754
|
+
* @param {Record<string, any>} input
|
|
755
|
+
* @param {Context} ctx
|
|
756
|
+
* @param {SessionState | null} state
|
|
757
|
+
* @param {number} maxBlocks
|
|
758
|
+
* @param {NodeJS.ProcessEnv} env
|
|
759
|
+
* @returns {HookResult}
|
|
760
|
+
*/
|
|
761
|
+
function decide(findings, notes, input, ctx, state, maxBlocks, env) {
|
|
762
|
+
const blocking = findings.filter((f) => f.mode === "block");
|
|
763
|
+
const others = findings.filter((f) => f.mode !== "block");
|
|
764
|
+
// Without a trustworthy record, the shared flag is the only memory: block once, then tell.
|
|
765
|
+
const used = state ? state.blocks : input.stop_hook_active ? maxBlocks : 0;
|
|
766
|
+
if (blocking.length && used < maxBlocks) {
|
|
767
|
+
if (state) saveState(ctx.root, input.session_id, { ...state, blocks: used + 1 }, env);
|
|
768
|
+
const text = [...blocking, ...others].map((f) => `- ${f.id}: ${f.reason}`).join("\n");
|
|
769
|
+
return { code: 2, stderr: `[trellis] Not done yet:\n${text}\n` };
|
|
770
|
+
}
|
|
771
|
+
// A stop that meets every blocking rule starts the count again for the next piece of work.
|
|
772
|
+
if (state && !blocking.length && state.blocks) saveState(ctx.root, input.session_id, { ...state, blocks: 0 }, env);
|
|
773
|
+
const parts = [
|
|
774
|
+
...(blocking.length ? [`sent the agent back ${used} time(s) and these are still not met, so it is your call: ${blocking.map((f) => `${f.id}: ${f.reason}`).join(" | ")}`] : []),
|
|
775
|
+
...others.map((f) => `${f.id}: ${f.reason}`),
|
|
776
|
+
...notes,
|
|
777
|
+
];
|
|
778
|
+
return parts.length ? tell(parts.join(" | ")) : { code: 0 };
|
|
779
|
+
}
|
|
780
|
+
|