@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,233 @@
|
|
|
1
|
+
// What a session looked like when it started, and the evidence collected since, inside the
|
|
2
|
+
// repository's git folder (never committed), signed with a key kept outside the repository.
|
|
3
|
+
//
|
|
4
|
+
// Two files per session. The snapshot (`<id>.json`) is written once at session start; only the
|
|
5
|
+
// Stop hook rewrites it, to count its blocks. The evidence (`<id>.events.jsonl`) is append-only,
|
|
6
|
+
// one signed line per event, so hooks running in parallel add to it without losing each other's
|
|
7
|
+
// lines. The agent runs as the same user, so nothing here is out of its reach: the signature
|
|
8
|
+
// makes an edit detectable, the deny rules and the shell guard make it a deliberate act, and a
|
|
9
|
+
// missing or altered file is itself a finding. Evidence is only ever positive (a check that
|
|
10
|
+
// happened), so removing a line can only hurt the agent that removes it.
|
|
11
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
12
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir, tmpdir } from "node:os";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { git } from "./git.mjs";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} Ledger evidence, folded from the event log
|
|
19
|
+
* @property {Record<string, string>} browser file → its content hash at the last browser check
|
|
20
|
+
* @property {Record<string, string>} setup file → its content hash at the last clean AgentShield run
|
|
21
|
+
* @property {boolean} graph a code graph query returned an answer
|
|
22
|
+
* @property {boolean} schema a database schema lookup succeeded
|
|
23
|
+
* @property {string[]} lookups Context7 calls, as lowercased input
|
|
24
|
+
* @property {number} searches file searches
|
|
25
|
+
* @property {boolean} onPage the browser's last navigation was to the origin
|
|
26
|
+
* @property {boolean} nudged the graph reminder was given
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} SessionState
|
|
31
|
+
* @property {number} v
|
|
32
|
+
* @property {number} startedAt
|
|
33
|
+
* @property {string | null} head
|
|
34
|
+
* @property {string[]} [tips] where local branches, HEAD and the stash pointed at the start
|
|
35
|
+
* @property {string[]} [remotes] where the remote-tracking refs pointed at the start
|
|
36
|
+
* @property {number} [reflog] HEAD's reflog length at the start
|
|
37
|
+
* @property {Record<string, string>} dirty
|
|
38
|
+
* @property {string[]} flagged files git was told to ignore (assume-unchanged, skip-worktree)
|
|
39
|
+
* @property {string | null} exclude .git/info/exclude as it was
|
|
40
|
+
* @property {string[]} [ignored] what git ignored and did not track at the start
|
|
41
|
+
* @property {string | null} [ignoreRules] a fingerprint of the ignore rules at the start
|
|
42
|
+
* @property {number} stashes
|
|
43
|
+
* @property {string | null} configText
|
|
44
|
+
* @property {string | null} [self] a hash of Trellis's own code, as installed at the start
|
|
45
|
+
* @property {number} blocks
|
|
46
|
+
* @property {Ledger} ledger
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** Session files older than this are removed when a new session starts. */
|
|
50
|
+
const KEEP_MS = 30 * 24 * 3600 * 1000;
|
|
51
|
+
|
|
52
|
+
/** @returns {Ledger} */
|
|
53
|
+
export const emptyLedger = () => ({ browser: {}, setup: {}, graph: false, schema: false, lookups: [], searches: 0, onPage: false, nudged: false });
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A test override (`TRELLIS_STATE_DIR`, `TRELLIS_KEY_FILE`, `TRELLIS_AGENTSHIELD`), honoured only
|
|
57
|
+
* under Node's test runner and outside Claude Code's project (`CLAUDE_PROJECT_DIR`), where a
|
|
58
|
+
* settings file's `env` could set it. An agent's own command could set it too: the shell guard
|
|
59
|
+
* refuses a command that does (hooks.mjs).
|
|
60
|
+
* @param {NodeJS.ProcessEnv} env
|
|
61
|
+
* @param {"TRELLIS_STATE_DIR" | "TRELLIS_KEY_FILE" | "TRELLIS_AGENTSHIELD" | "TRELLIS_GRAPHIFY"} name
|
|
62
|
+
* @returns {string | undefined}
|
|
63
|
+
*/
|
|
64
|
+
export const testOverride = (env, name) => (env.CLAUDE_PROJECT_DIR || !underTest(env) ? undefined : env[name] || undefined);
|
|
65
|
+
|
|
66
|
+
/** Whether Node's test runner started this process (or its parent, whose environment it inherits). @param {NodeJS.ProcessEnv} env */
|
|
67
|
+
const underTest = (env) => Boolean(process.env.NODE_TEST_CONTEXT || env.NODE_TEST_CONTEXT);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where a repository's session files live: its git folder, else the temp folder.
|
|
71
|
+
* @param {string} root
|
|
72
|
+
* @param {NodeJS.ProcessEnv} env
|
|
73
|
+
*/
|
|
74
|
+
export function stateDir(root, env = process.env) {
|
|
75
|
+
const override = testOverride(env, "TRELLIS_STATE_DIR");
|
|
76
|
+
if (override) return override;
|
|
77
|
+
const common = git(root, ["rev-parse", "--git-common-dir"])?.trim();
|
|
78
|
+
return common ? join(resolve(root, common), "trellis", "sessions") : join(tmpdir(), "trellis-sessions");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @param {NodeJS.ProcessEnv} env */
|
|
82
|
+
export const keyFile = (env = process.env) => {
|
|
83
|
+
const override = testOverride(env, "TRELLIS_KEY_FILE");
|
|
84
|
+
if (override) return override;
|
|
85
|
+
// A test that forgot its key would sign with the developer's own: fail it instead.
|
|
86
|
+
if (underTest(env) && !env.CLAUDE_PROJECT_DIR) throw new Error("a test reached the real signing key (~/.trellis): set TRELLIS_KEY_FILE");
|
|
87
|
+
return join(homedir(), ".trellis", "key");
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** The signing key, created once per user, readable by the user only. @param {NodeJS.ProcessEnv} env */
|
|
91
|
+
function key(env) {
|
|
92
|
+
const file = keyFile(env);
|
|
93
|
+
if (!existsSync(file)) {
|
|
94
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
95
|
+
writeFileSync(file, randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
96
|
+
try {
|
|
97
|
+
chmodSync(file, 0o600);
|
|
98
|
+
} catch {
|
|
99
|
+
/* Windows keeps its own ACLs */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return readFileSync(file, "utf8").trim();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @param {string} body @param {NodeJS.ProcessEnv} env */
|
|
106
|
+
export const sign = (body, env) => createHmac("sha256", key(env)).update(body).digest("hex");
|
|
107
|
+
|
|
108
|
+
/** @param {unknown} sig @param {string} body @param {NodeJS.ProcessEnv} env */
|
|
109
|
+
export function valid(sig, body, env) {
|
|
110
|
+
const expected = Buffer.from(sign(body, env), "hex");
|
|
111
|
+
const got = Buffer.from(String(sig ?? ""), "hex");
|
|
112
|
+
return got.length === expected.length && timingSafeEqual(got, expected);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {string} sessionId */
|
|
116
|
+
const baseName = (sessionId) => sessionId.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The evidence, folded in the order it was recorded.
|
|
120
|
+
* @param {Array<Record<string, any>>} events
|
|
121
|
+
* @returns {Ledger}
|
|
122
|
+
*/
|
|
123
|
+
export function fold(events) {
|
|
124
|
+
const l = emptyLedger();
|
|
125
|
+
for (const e of events) {
|
|
126
|
+
if (e.k === "graph") l.graph = true;
|
|
127
|
+
else if (e.k === "schema") l.schema = true;
|
|
128
|
+
else if (e.k === "search") l.searches++;
|
|
129
|
+
else if (e.k === "lookup") l.lookups.push(String(e.q ?? ""));
|
|
130
|
+
else if (e.k === "nav") l.onPage = !!e.on;
|
|
131
|
+
else if (e.k === "browser") Object.assign(l.browser, e.files ?? {});
|
|
132
|
+
else if (e.k === "setup") Object.assign(l.setup, e.files ?? {});
|
|
133
|
+
else if (e.k === "nudged") l.nudged = true;
|
|
134
|
+
}
|
|
135
|
+
return l;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} root
|
|
140
|
+
* @param {string | undefined} sessionId
|
|
141
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
142
|
+
* @returns {{ state: SessionState | null, status: "ok" | "missing" | "tampered" | "no-session" }}
|
|
143
|
+
*/
|
|
144
|
+
export function loadState(root, sessionId, env = process.env) {
|
|
145
|
+
if (!sessionId) return { state: null, status: "no-session" };
|
|
146
|
+
const dir = stateDir(root, env);
|
|
147
|
+
let raw;
|
|
148
|
+
try {
|
|
149
|
+
raw = JSON.parse(readFileSync(join(dir, `${baseName(sessionId)}.json`), "utf8"));
|
|
150
|
+
} catch {
|
|
151
|
+
return { state: null, status: "missing" };
|
|
152
|
+
}
|
|
153
|
+
if (!valid(raw?.sig, JSON.stringify(raw?.state ?? null), env)) return { state: null, status: "tampered" };
|
|
154
|
+
/** @type {Array<Record<string, any>>} */
|
|
155
|
+
const events = [];
|
|
156
|
+
let text = "";
|
|
157
|
+
try {
|
|
158
|
+
text = readFileSync(join(dir, `${baseName(sessionId)}.events.jsonl`), "utf8");
|
|
159
|
+
} catch {
|
|
160
|
+
/* no evidence yet */
|
|
161
|
+
}
|
|
162
|
+
for (const line of text.split("\n")) {
|
|
163
|
+
if (!line.trim()) continue;
|
|
164
|
+
let rec;
|
|
165
|
+
try {
|
|
166
|
+
rec = JSON.parse(line);
|
|
167
|
+
} catch {
|
|
168
|
+
// A line a parallel hook is still appending can be read half-written; the next read has it whole.
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!valid(rec?.sig, JSON.stringify(rec?.e ?? null), env)) return { state: null, status: "tampered" };
|
|
172
|
+
events.push(rec.e);
|
|
173
|
+
}
|
|
174
|
+
return { state: { ...raw.state, ledger: fold(events) }, status: "ok" };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Writes the snapshot (once at session start, and when Stop counts a block). The ledger is not
|
|
179
|
+
* part of it: evidence goes through `record`.
|
|
180
|
+
* @param {string} root
|
|
181
|
+
* @param {string | undefined} sessionId
|
|
182
|
+
* @param {SessionState} state
|
|
183
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
184
|
+
*/
|
|
185
|
+
export function saveState(root, sessionId, state, env = process.env) {
|
|
186
|
+
if (!sessionId) return;
|
|
187
|
+
const dir = stateDir(root, env);
|
|
188
|
+
mkdirSync(dir, { recursive: true });
|
|
189
|
+
const { ledger, ...snapshot } = state;
|
|
190
|
+
void ledger;
|
|
191
|
+
const file = join(dir, `${baseName(sessionId)}.json`);
|
|
192
|
+
// Written beside, then renamed over: a reader never sees half a snapshot.
|
|
193
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
194
|
+
writeFileSync(tmp, JSON.stringify({ state: snapshot, sig: sign(JSON.stringify(snapshot), env) }));
|
|
195
|
+
renameSync(tmp, file);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Appends one signed event to the session's evidence.
|
|
200
|
+
* @param {string} root
|
|
201
|
+
* @param {string | undefined} sessionId
|
|
202
|
+
* @param {Record<string, any>} event
|
|
203
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
204
|
+
*/
|
|
205
|
+
export function record(root, sessionId, event, env = process.env) {
|
|
206
|
+
if (!sessionId) return;
|
|
207
|
+
const dir = stateDir(root, env);
|
|
208
|
+
mkdirSync(dir, { recursive: true });
|
|
209
|
+
appendFileSync(join(dir, `${baseName(sessionId)}.events.jsonl`), `${JSON.stringify({ e: event, sig: sign(JSON.stringify(event), env) })}\n`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Removes session files older than 30 days.
|
|
214
|
+
* @param {string} root
|
|
215
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
216
|
+
* @param {number} [now]
|
|
217
|
+
*/
|
|
218
|
+
export function prune(root, env = process.env, now = Date.now()) {
|
|
219
|
+
const dir = stateDir(root, env);
|
|
220
|
+
let names = [];
|
|
221
|
+
try {
|
|
222
|
+
names = readdirSync(dir);
|
|
223
|
+
} catch {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
for (const n of names) {
|
|
227
|
+
try {
|
|
228
|
+
if (now - statSync(join(dir, n)).mtimeMs > KEEP_MS) unlinkSync(join(dir, n));
|
|
229
|
+
} catch {
|
|
230
|
+
/* in use or gone */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// What a coding agent actually did in a session, read from its transcript: every tool call, with
|
|
2
|
+
// its time. Which files changed is read from git (git.mjs); the transcript's own edit calls are
|
|
3
|
+
// the fallback outside a git repository.
|
|
4
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{ i: number, t: number, id?: string, name: string, input: Record<string, any> }} ToolUse
|
|
9
|
+
* `t` is the call's time (ms since epoch), 0 when the transcript has none.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Tools whose call writes a file, and the input field that names it. */
|
|
13
|
+
const EDIT_TOOLS = /** @type {Record<string, string>} */ ({ Edit: "file_path", Write: "file_path", MultiEdit: "file_path", NotebookEdit: "notebook_path" });
|
|
14
|
+
/** Symbol-level editors over MCP that take a repository-relative path. */
|
|
15
|
+
const MCP_EDIT = /^mcp__[^_]+(?:_[^_]+)*__(replace|insert|create|rename|safe_delete|write|edit)/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every tool call of a session, its subagents' included, in time order. A subagent writes its
|
|
19
|
+
* own transcript (`<session>/subagents/agent-*.jsonl` beside the main one): the work it does is
|
|
20
|
+
* the session's work, so a browser check or a scan it ran counts, and so does an edit.
|
|
21
|
+
* Unreadable lines are skipped: a transcript is appended while the session runs.
|
|
22
|
+
* @param {string | null | undefined} path
|
|
23
|
+
* @returns {{ uses: ToolUse[], readable: boolean }}
|
|
24
|
+
*/
|
|
25
|
+
export function readSession(path) {
|
|
26
|
+
if (!path) return { uses: [], readable: false };
|
|
27
|
+
let text;
|
|
28
|
+
try {
|
|
29
|
+
text = readFileSync(path, "utf8");
|
|
30
|
+
} catch {
|
|
31
|
+
return { uses: [], readable: false };
|
|
32
|
+
}
|
|
33
|
+
const all = parseToolUses(text);
|
|
34
|
+
const subDir = join(dirname(path), basename(path).replace(/\.jsonl$/, ""), "subagents");
|
|
35
|
+
let files = [];
|
|
36
|
+
try {
|
|
37
|
+
files = readdirSync(subDir).filter((f) => f.endsWith(".jsonl"));
|
|
38
|
+
} catch {
|
|
39
|
+
/* no subagent ran */
|
|
40
|
+
}
|
|
41
|
+
for (const f of files) {
|
|
42
|
+
try {
|
|
43
|
+
all.push(...parseToolUses(readFileSync(join(subDir, f), "utf8")));
|
|
44
|
+
} catch {
|
|
45
|
+
/* a subagent file being written */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Stable: calls without a time keep their order relative to each other.
|
|
49
|
+
const sorted = all.map((u, k) => ({ u, k })).sort((a, b) => a.u.t - b.u.t || a.k - b.k);
|
|
50
|
+
return { uses: sorted.map(({ u }, i) => ({ ...u, i })), readable: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @param {string | null | undefined} path */
|
|
54
|
+
export function readToolUses(path) {
|
|
55
|
+
return readSession(path).uses;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {string} text @returns {ToolUse[]} */
|
|
59
|
+
export function parseToolUses(text) {
|
|
60
|
+
/** @type {ToolUse[]} */
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const line of text.split(/\r?\n/)) {
|
|
63
|
+
if (!line.includes('"tool_use"')) continue;
|
|
64
|
+
let entry;
|
|
65
|
+
try {
|
|
66
|
+
entry = JSON.parse(line);
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const content = entry?.message?.role === "assistant" ? entry.message.content : null;
|
|
71
|
+
if (!Array.isArray(content)) continue;
|
|
72
|
+
const t = Date.parse(entry.timestamp ?? "") || 0;
|
|
73
|
+
for (const c of content) {
|
|
74
|
+
if (c?.type === "tool_use" && typeof c.name === "string") out.push({ i: out.length, t, id: typeof c.id === "string" ? c.id : undefined, name: c.name, input: c.input ?? {} });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The repository-relative path a tool call wrote, or null when it wrote nothing (or outside the
|
|
82
|
+
* repository). Edits made through a shell command are not seen: that is a stated limit.
|
|
83
|
+
* @param {ToolUse} use
|
|
84
|
+
* @param {string} root
|
|
85
|
+
* @param {NodeJS.Platform} platform
|
|
86
|
+
*/
|
|
87
|
+
export function editedPath(use, root, platform) {
|
|
88
|
+
const field = EDIT_TOOLS[use.name];
|
|
89
|
+
if (field) return relativeTo(use.input[field], root, platform);
|
|
90
|
+
if (!MCP_EDIT.test(use.name)) return null;
|
|
91
|
+
const p = use.input.relative_path ?? use.input.path ?? use.input.file_path;
|
|
92
|
+
return typeof p === "string" ? relativeTo(p, root, platform) : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {unknown} file
|
|
97
|
+
* @param {string} root
|
|
98
|
+
* @param {NodeJS.Platform} platform
|
|
99
|
+
*/
|
|
100
|
+
export function relativeTo(file, root, platform) {
|
|
101
|
+
if (typeof file !== "string" || !file) return null;
|
|
102
|
+
const norm = (/** @type {string} */ p) => {
|
|
103
|
+
let s = p.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
104
|
+
// Git Bash spells C:\x as /c/x.
|
|
105
|
+
if (platform === "win32") s = s.replace(/^\/([a-zA-Z])\//, "$1:/");
|
|
106
|
+
return s;
|
|
107
|
+
};
|
|
108
|
+
const f = norm(file);
|
|
109
|
+
const r = norm(root);
|
|
110
|
+
// Windows and macOS file systems are case-insensitive by default.
|
|
111
|
+
const same = platform === "win32" || platform === "darwin" ?(/** @type {string} */ a, /** @type {string} */ b) => a.toLowerCase() === b.toLowerCase() : (/** @type {string} */ a, /** @type {string} */ b) => a === b;
|
|
112
|
+
if (!/^([a-zA-Z]:)?\//.test(f)) return f.replace(/^\.\//, "");
|
|
113
|
+
if (f.length > r.length && same(f.slice(0, r.length), r) && f[r.length] === "/") return f.slice(r.length + 1);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The shell command of a call, or "". @param {ToolUse} use */
|
|
118
|
+
export function commandOf(use) {
|
|
119
|
+
return use.name === "Bash" || use.name === "PowerShell" ? String(use.input.command ?? "") : "";
|
|
120
|
+
}
|
package/src/cli/main.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Argument parsing and dispatch; no tool logic lives here.
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { createContext } from "../core/context.mjs";
|
|
5
|
+
import { PASSTHROUGH, runAbatty } from "../core/abatty.mjs";
|
|
6
|
+
import { doctor } from "../commands/doctor.mjs";
|
|
7
|
+
import { init } from "../commands/init.mjs";
|
|
8
|
+
import { guide } from "../commands/guide.mjs";
|
|
9
|
+
import { scan } from "../commands/scan.mjs";
|
|
10
|
+
import { TOOLS } from "../tools/registry.mjs";
|
|
11
|
+
import { ROLES } from "../tools/roles.mjs";
|
|
12
|
+
import { handleHook, hookFailure, hookRoot, EVENTS } from "../agents/hooks.mjs";
|
|
13
|
+
import { uninstall } from "../commands/uninstall.mjs";
|
|
14
|
+
import { parseJson } from "../core/context.mjs";
|
|
15
|
+
import { approve, checkTests, commitCheckpoint, escalate, evidenceReset, verify, verifyCi } from "../commands/verify.mjs";
|
|
16
|
+
import { keyCommand } from "../commands/key.mjs";
|
|
17
|
+
import { agentHook } from "../agents/adapters.mjs";
|
|
18
|
+
import { serveMcp } from "../mcp/server.mjs";
|
|
19
|
+
import { trustCommand } from "../mcp/trust.mjs";
|
|
20
|
+
|
|
21
|
+
/** A hook's JSON input. Nothing to read from a terminal: an interactive run has no event. */
|
|
22
|
+
function readStdin() {
|
|
23
|
+
if (process.stdin.isTTY) return "";
|
|
24
|
+
try {
|
|
25
|
+
return readFileSync(0, "utf8");
|
|
26
|
+
} catch {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const HELP = `trellis - the frame a repository grows on
|
|
32
|
+
|
|
33
|
+
trellis init [--dry-run] [--agents[=cursor,codex,copilot]]
|
|
34
|
+
write trellis.config.json, the MCP servers and the AGENTS.md block;
|
|
35
|
+
--agents also the Claude Code hooks and a pre-commit secret scan
|
|
36
|
+
trellis hook <${EVENTS.join("|")}>
|
|
37
|
+
what the agent hooks run (reads the hook's JSON on stdin)
|
|
38
|
+
trellis uninstall [--dry-run] take out the agent hooks, the pre-commit hook and the AGENTS.md block
|
|
39
|
+
trellis doctor [--json] [--strict] [--all]
|
|
40
|
+
every enabled tool: installed? actually wired? what to do next
|
|
41
|
+
trellis guide [tool|role] which tool answers which question, and how to use it here
|
|
42
|
+
trellis tools every tool Trellis knows, by role
|
|
43
|
+
trellis scan [--history] [--only a,b] [--allow-missing]
|
|
44
|
+
run the enabled scanners (Gitleaks, OSV-Scanner, AgentShield)
|
|
45
|
+
trellis verify the staged change against the rules (the pre-commit checkpoint)
|
|
46
|
+
trellis verify --ci [--base <rev>] [--head <rev>] [--summary <file>] [--checksums <file>]
|
|
47
|
+
every commit after base: its attestation checked, the checks re-run here
|
|
48
|
+
trellis evidence reset a person sets an unusable evidence log aside and starts a new one
|
|
49
|
+
trellis key [--add] this developer's public key; --add commits it under .trellis/keys/
|
|
50
|
+
trellis check tests -- <cmd> run the tests and record what they ran on
|
|
51
|
+
trellis escalate "<why>" an agent stops and hands the change to a person
|
|
52
|
+
trellis approve a person approves the change as it is now (in a terminal)
|
|
53
|
+
trellis checkpoint [--merge | --rewritten <how>]
|
|
54
|
+
after a commit, merge or rewrite: attest it (the git hooks run it)
|
|
55
|
+
trellis mcp the MCP server: the checks as tools for any agent, recorded as evidence
|
|
56
|
+
trellis mcp trust a person trusts the .mcp.json servers the gateway may start (in a terminal)
|
|
57
|
+
trellis ${PASSTHROUGH.join("|")} [...]
|
|
58
|
+
the guard engine: abatty's command, run as it is
|
|
59
|
+
|
|
60
|
+
Roles: ${ROLES.map((r) => r.id).join(", ")}. Exit codes: 0 ok, 1 something to fix, 2 bad usage.`;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {object} IO
|
|
64
|
+
* @property {string} [cwd]
|
|
65
|
+
* @property {(...lines: string[]) => void} [out]
|
|
66
|
+
* @property {string} [stdin] a hook's input; read from the process when absent
|
|
67
|
+
* @property {(text: string) => void} [stdout] raw output, for hooks
|
|
68
|
+
* @property {(text: string) => void} [stderr]
|
|
69
|
+
* @property {NodeJS.ProcessEnv} [env]
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string[]} argv
|
|
74
|
+
* @param {IO} [io]
|
|
75
|
+
* @returns {number | Promise<number>} exit code (a promise for the commands that use the network)
|
|
76
|
+
*/
|
|
77
|
+
export function main(argv, io = {}) {
|
|
78
|
+
const root = io.cwd ?? process.cwd();
|
|
79
|
+
const out = io.out ?? ((/** @type {string[]} */ ...lines) => lines.forEach((l) => console.log(l)));
|
|
80
|
+
const [cmd, ...rest] = argv;
|
|
81
|
+
const flag = (/** @type {string} */ name) => rest.includes(`--${name}`);
|
|
82
|
+
const opt = (/** @type {string} */ name) => {
|
|
83
|
+
const i = rest.indexOf(`--${name}`);
|
|
84
|
+
return i === -1 ? undefined : rest[i + 1];
|
|
85
|
+
};
|
|
86
|
+
const ctx = createContext({ root });
|
|
87
|
+
const write = (/** @type {string} */ rel, /** @type {string} */ text, /** @type {boolean | undefined} */ executable) => {
|
|
88
|
+
// Absolute when git keeps its hooks outside the work tree (worktrees, a global hooksPath).
|
|
89
|
+
const full = resolve(root, rel);
|
|
90
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
91
|
+
writeFileSync(full, text, executable ? { mode: 0o755 } : undefined);
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
switch (cmd) {
|
|
95
|
+
case "init":
|
|
96
|
+
{
|
|
97
|
+
const list = rest.find((x) => x.startsWith("--agents="));
|
|
98
|
+
const agentList = list ? list.slice("--agents=".length).split(",").map((x) => x.trim()).filter(Boolean) : undefined;
|
|
99
|
+
return init(ctx, { dryRun: flag("dry-run"), agents: flag("agents") || Boolean(list), agentList }, out, write, io.env);
|
|
100
|
+
}
|
|
101
|
+
case "hook": {
|
|
102
|
+
const input = parseJson(io.stdin ?? readStdin()) ?? {};
|
|
103
|
+
const env = io.env ?? process.env;
|
|
104
|
+
const a = rest.indexOf("--agent");
|
|
105
|
+
const agent = a === -1 ? null : String(rest[a + 1] ?? "");
|
|
106
|
+
let r;
|
|
107
|
+
try {
|
|
108
|
+
const hctx = createContext({ root: hookRoot(input, env, root) });
|
|
109
|
+
r = agent ? agentHook(agent, rest[0] ?? "", input, hctx, env) : handleHook(rest[0] ?? "", input, hctx, { env });
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// A crash in a hook must not read as a pass. Claude Code treats any exit but 2 as one; for
|
|
112
|
+
// the other agents a crashed pre-tool hook exits 2, a deny for Codex and Copilot, and
|
|
113
|
+
// Cursor's entry is written with failClosed.
|
|
114
|
+
r = agent
|
|
115
|
+
? { code: rest[0] === "pre-tool" ? 2 : 0, stderr: `[trellis] hook ${rest[0]} failed: ${err instanceof Error ? err.message : String(err)}\n` }
|
|
116
|
+
: hookFailure(rest[0] ?? "", input, err);
|
|
117
|
+
}
|
|
118
|
+
if (r.stdout) (io.stdout ?? ((/** @type {string} */ t) => process.stdout.write(t)))(r.stdout);
|
|
119
|
+
if (r.stderr) (io.stderr ?? ((/** @type {string} */ t) => process.stderr.write(t)))(r.stderr);
|
|
120
|
+
return r.code;
|
|
121
|
+
}
|
|
122
|
+
case "uninstall":
|
|
123
|
+
return uninstall(ctx, { dryRun: flag("dry-run") }, out, write);
|
|
124
|
+
case "doctor":
|
|
125
|
+
return doctor(ctx, { json: flag("json"), strict: flag("strict"), all: flag("all"), env: io.env }, out);
|
|
126
|
+
case "guide":
|
|
127
|
+
return guide(ctx, rest.find((a) => !a.startsWith("--")), out);
|
|
128
|
+
case "tools":
|
|
129
|
+
for (const role of ROLES) {
|
|
130
|
+
out(role.title);
|
|
131
|
+
for (const t of TOOLS.filter((x) => x.role === role.id)) out(` ${t.id.padEnd(16)} ${t.name}${t.ai ? " (agent)" : ""}`);
|
|
132
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
case "scan": {
|
|
135
|
+
const i = rest.indexOf("--only");
|
|
136
|
+
const only = i === -1 ? [] : String(rest[i + 1] ?? "").split(",").filter(Boolean);
|
|
137
|
+
return scan(ctx, { history: flag("history"), allowMissing: flag("allow-missing"), only }, out);
|
|
138
|
+
}
|
|
139
|
+
case "verify": {
|
|
140
|
+
if (!flag("ci")) return verify(ctx, out, { env: io.env });
|
|
141
|
+
return verifyCi(ctx, out, { env: io.env, base: opt("base"), head: opt("head"), summary: opt("summary"), checksums: opt("checksums") });
|
|
142
|
+
}
|
|
143
|
+
case "evidence":
|
|
144
|
+
if (rest[0] !== "reset") {
|
|
145
|
+
out("Usage: trellis evidence reset (a person sets an unusable evidence log aside)");
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
|
148
|
+
return evidenceReset(ctx, out, { env: io.env });
|
|
149
|
+
case "key":
|
|
150
|
+
return keyCommand(ctx, { add: flag("add") }, out, io.env ?? process.env);
|
|
151
|
+
case "check": {
|
|
152
|
+
if (rest[0] !== "tests") {
|
|
153
|
+
out('Usage: trellis check tests -- <test command>');
|
|
154
|
+
return 2;
|
|
155
|
+
}
|
|
156
|
+
const dash = rest.indexOf("--");
|
|
157
|
+
return checkTests(ctx, dash === -1 ? [] : rest.slice(dash + 1), out, { env: io.env });
|
|
158
|
+
}
|
|
159
|
+
case "escalate":
|
|
160
|
+
return escalate(ctx, rest.join(" "), out, { env: io.env });
|
|
161
|
+
case "approve":
|
|
162
|
+
return approve(ctx, out, { env: io.env });
|
|
163
|
+
case "checkpoint":
|
|
164
|
+
return commitCheckpoint(ctx, out, { env: io.env, merge: flag("merge"), rewritten: opt("rewritten"), input: opt("rewritten") ? (io.stdin ?? readStdin()) : undefined });
|
|
165
|
+
case "mcp": {
|
|
166
|
+
// Launched by an agent from wherever it stands: the repository is the project it names,
|
|
167
|
+
// else the nearest folder above holding the config.
|
|
168
|
+
const env = io.env ?? process.env;
|
|
169
|
+
if (rest[0] === "trust") return trustCommand(ctx, out, { env });
|
|
170
|
+
return serveMcp(createContext({ root: hookRoot({ cwd: root }, env, root) }), env);
|
|
171
|
+
}
|
|
172
|
+
case "--version":
|
|
173
|
+
case "-v":
|
|
174
|
+
out(JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version);
|
|
175
|
+
return 0;
|
|
176
|
+
case undefined:
|
|
177
|
+
case "help":
|
|
178
|
+
case "--help":
|
|
179
|
+
case "-h":
|
|
180
|
+
out(HELP);
|
|
181
|
+
return 0;
|
|
182
|
+
default:
|
|
183
|
+
if (PASSTHROUGH.includes(cmd)) return runAbatty(root, [cmd, ...rest], out);
|
|
184
|
+
out(`Unknown command "${cmd}".`, "", HELP);
|
|
185
|
+
return 2;
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
out(`trellis ${cmd}: ${err instanceof Error ? err.message : String(err)}`);
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
}
|