@synovitec/trellis 0.0.0-stage → 0.6.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +406 -0
- package/LICENSE +190 -0
- package/README.md +206 -2
- package/SECURITY.md +105 -0
- package/bin/trellis.mjs +5 -0
- package/docs/attestation/v0.1.md +96 -0
- package/package.json +78 -4
- package/schema/trellis.config.schema.json +349 -0
- package/src/agents/adapters.mjs +155 -0
- package/src/agents/git.mjs +241 -0
- package/src/agents/glob.mjs +41 -0
- package/src/agents/hooks.mjs +780 -0
- package/src/agents/install-adapters.mjs +172 -0
- package/src/agents/install.mjs +302 -0
- package/src/agents/manifests.mjs +127 -0
- package/src/agents/rules.mjs +330 -0
- package/src/agents/shell.mjs +253 -0
- package/src/agents/state.mjs +233 -0
- package/src/agents/transcript.mjs +120 -0
- package/src/cli/main.mjs +191 -0
- package/src/commands/attest.mjs +208 -0
- package/src/commands/checkpoint-state.mjs +62 -0
- package/src/commands/doctor.mjs +98 -0
- package/src/commands/guide.mjs +62 -0
- package/src/commands/init.mjs +131 -0
- package/src/commands/key.mjs +31 -0
- package/src/commands/scan.mjs +51 -0
- package/src/commands/uninstall.mjs +36 -0
- package/src/commands/verify.mjs +510 -0
- package/src/core/abatty.mjs +25 -0
- package/src/core/agents-block.mjs +65 -0
- package/src/core/attestation.mjs +213 -0
- package/src/core/config.mjs +135 -0
- package/src/core/context.mjs +280 -0
- package/src/core/diff.mjs +189 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/mcp.mjs +51 -0
- package/src/index.mjs +11 -0
- package/src/mcp/client.mjs +164 -0
- package/src/mcp/launch.mjs +57 -0
- package/src/mcp/relay.mjs +111 -0
- package/src/mcp/server.mjs +210 -0
- package/src/mcp/trust.mjs +142 -0
- package/src/tools/registry.mjs +370 -0
- package/src/tools/roles.mjs +17 -0
- package/src/verify/deps.mjs +382 -0
- package/src/verify/gitleaks-default.toml +3211 -0
- package/src/verify/rules.mjs +177 -0
- package/src/verify/secrets.mjs +169 -0
- package/src/verify/test-commands.mjs +116 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// The repository as git sees it: what changed during a session, whoever changed it. The rules
|
|
2
|
+
// read edits from here rather than from the transcript, so a write through the shell, an MCP
|
|
3
|
+
// editor or a subagent counts the same as one through the Edit tool.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} root
|
|
11
|
+
* @param {string[]} args
|
|
12
|
+
* @returns {string | null} stdout, or null when git fails or is absent
|
|
13
|
+
*/
|
|
14
|
+
export function git(root, args) {
|
|
15
|
+
const r = spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, windowsHide: true });
|
|
16
|
+
return r.status === 0 ? r.stdout : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @param {string} root */
|
|
20
|
+
export const headOf = (root) => git(root, ["rev-parse", "--verify", "-q", "HEAD"])?.trim() || null;
|
|
21
|
+
|
|
22
|
+
/** @param {string} root */
|
|
23
|
+
export const isRepo = (root) => git(root, ["rev-parse", "--is-inside-work-tree"])?.trim() === "true";
|
|
24
|
+
|
|
25
|
+
/** The folder git runs hooks from, worktrees, submodules and core.hooksPath included. @param {string} root */
|
|
26
|
+
export function hooksDir(root) {
|
|
27
|
+
const p = git(root, ["rev-parse", "--git-path", "hooks"])?.trim();
|
|
28
|
+
return p ? p.replace(/\\/g, "/") : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @param {string} root @param {string} rel */
|
|
32
|
+
function hashOf(root, rel) {
|
|
33
|
+
try {
|
|
34
|
+
return createHash("sha1").update(readFileSync(join(root, rel))).digest("hex");
|
|
35
|
+
} catch {
|
|
36
|
+
return "deleted";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Files that differ from HEAD right now (modified, staged, untracked, deleted), with a hash of
|
|
42
|
+
* their content, so a later edit to a file that was already dirty is still seen.
|
|
43
|
+
* @param {string} root
|
|
44
|
+
* @returns {Record<string, string>}
|
|
45
|
+
*/
|
|
46
|
+
export function dirtyFiles(root) {
|
|
47
|
+
const out = git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
48
|
+
/** @type {Record<string, string>} */
|
|
49
|
+
const files = {};
|
|
50
|
+
if (out == null) return files;
|
|
51
|
+
const parts = out.split("\0");
|
|
52
|
+
for (let i = 0; i < parts.length; i++) {
|
|
53
|
+
const entry = parts[i];
|
|
54
|
+
if (entry.length < 4) continue;
|
|
55
|
+
const status = entry.slice(0, 2);
|
|
56
|
+
const path = entry.slice(3);
|
|
57
|
+
files[path] = hashOf(root, path);
|
|
58
|
+
// A rename carries its source as the next field.
|
|
59
|
+
if (status[0] === "R" || status[0] === "C") i++;
|
|
60
|
+
}
|
|
61
|
+
return files;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Files git has been told not to look at: assume-unchanged (lowercase tag) or skip-worktree (S).
|
|
66
|
+
* A change hidden this way is still a change, and hiding one mid-session is itself a finding.
|
|
67
|
+
* @param {string} root
|
|
68
|
+
*/
|
|
69
|
+
export function flaggedFiles(root) {
|
|
70
|
+
const out = git(root, ["ls-files", "-v", "-z"]) ?? "";
|
|
71
|
+
return out.split("\0").filter((l) => /^([a-z]|S) /.test(l)).map((l) => l.slice(2));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @param {string | null} out */
|
|
75
|
+
const zList = (out) => (out ?? "").split("\0").filter(Boolean).map((p) => p.replace(/\\/g, "/"));
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What git ignores and does not track, whatever the rule (a .gitignore, core.excludesFile,
|
|
79
|
+
* info/exclude), one entry per ignored folder (`node_modules/`), so the list stays short; with
|
|
80
|
+
* `under`, every file below those paths instead.
|
|
81
|
+
* @param {string} root
|
|
82
|
+
* @param {string[]} [under]
|
|
83
|
+
*/
|
|
84
|
+
export function ignoredEntries(root, under) {
|
|
85
|
+
if (under) return under.length ? zList(git(root, ["ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--", ...under])) : [];
|
|
86
|
+
return zList(git(root, ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--no-empty-directory", "-z"]));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A fingerprint of the rules that decide what git ignores, info/exclude aside (watched on its
|
|
91
|
+
* own): every .gitignore git reads (tracked, untracked, or one that ignores itself) and
|
|
92
|
+
* core.excludesFile with its content. Two equal fingerprints ignore the same files.
|
|
93
|
+
* @param {string} root
|
|
94
|
+
*/
|
|
95
|
+
export function ignoreRules(root) {
|
|
96
|
+
const files = new Set([
|
|
97
|
+
...zList(git(root, ["ls-files", "--cached", "-z", "--", ":(glob)**/.gitignore"])),
|
|
98
|
+
...zList(git(root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ":(glob)**/.gitignore"])),
|
|
99
|
+
...ignoredEntries(root).filter((p) => p === ".gitignore" || p.endsWith("/.gitignore")),
|
|
100
|
+
]);
|
|
101
|
+
const read = (/** @type {string} */ p) => {
|
|
102
|
+
try {
|
|
103
|
+
return readFileSync(p, "utf8");
|
|
104
|
+
} catch {
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const excludesFile = git(root, ["config", "--path", "--get", "core.excludesFile"])?.trim() ?? "";
|
|
109
|
+
const parts = [...files].sort().map((f) => `${f}\n${read(join(root, f))}`);
|
|
110
|
+
parts.push(`core.excludesFile=${excludesFile}\n${excludesFile ? read(excludesFile) : ""}`);
|
|
111
|
+
return createHash("sha256").update(parts.join("\0")).digest("hex");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** .git/info/exclude's text, wherever this checkout keeps it. @param {string} root */
|
|
115
|
+
export function excludeText(root) {
|
|
116
|
+
const p = git(root, ["rev-parse", "--git-path", "info/exclude"])?.trim();
|
|
117
|
+
if (!p) return null;
|
|
118
|
+
try {
|
|
119
|
+
return readFileSync(join(root, p), "utf8");
|
|
120
|
+
} catch {
|
|
121
|
+
try {
|
|
122
|
+
return readFileSync(p, "utf8");
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @param {string} root */
|
|
130
|
+
export const stashCount = (root) => (git(root, ["stash", "list"]) ?? "").split(/\r?\n/).filter(Boolean).length;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Where the repository stood when the session started: the commits its local branches, HEAD
|
|
134
|
+
* and the stash pointed at, and how long HEAD's reflog was. Commit dates play no part: the
|
|
135
|
+
* agent sets them (`GIT_COMMITTER_DATE`), so a session's commits are defined by reachability.
|
|
136
|
+
* @param {string} root
|
|
137
|
+
*/
|
|
138
|
+
export function startPoint(root) {
|
|
139
|
+
const out = git(root, ["for-each-ref", "--format=%(objectname)", "refs/heads", "refs/stash"]) ?? "";
|
|
140
|
+
const head = headOf(root);
|
|
141
|
+
return {
|
|
142
|
+
tips: [...new Set([...out.split(/\r?\n/).filter(Boolean), ...(head ? [head] : [])])],
|
|
143
|
+
remotes: lines(git(root, ["for-each-ref", "--format=%(objectname)", "refs/remotes"])),
|
|
144
|
+
reflog: reflogShas(root).length,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @param {string | null} out */
|
|
149
|
+
const lines = (out) => [...new Set((out ?? "").split(/\r?\n/).filter(Boolean))];
|
|
150
|
+
|
|
151
|
+
/** HEAD's reflog, newest first. @param {string} root */
|
|
152
|
+
const reflogShas = (root) => (git(root, ["reflog", "--format=%H"]) ?? "").split(/\r?\n/).filter(Boolean);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Commits a remote-tracking ref was moved to by a fetch or a pull, read from that ref's reflog:
|
|
156
|
+
* work that came from the remote. A push writes "update by push", `update-ref` writes what it is
|
|
157
|
+
* told; neither reads as received, so what the session sent away stays the session's.
|
|
158
|
+
* @param {string} root
|
|
159
|
+
*/
|
|
160
|
+
function received(root) {
|
|
161
|
+
const shas = [];
|
|
162
|
+
for (const ref of lines(git(root, ["for-each-ref", "--format=%(refname)", "refs/remotes"]))) {
|
|
163
|
+
for (const entry of lines(git(root, ["reflog", "show", "--format=%H %gs", ref, "--"]))) {
|
|
164
|
+
const [sha, ...msg] = entry.split(" ");
|
|
165
|
+
if (/^(fetch|pull)\b/.test(msg.join(" "))) shas.push(sha);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return [...new Set(shas)];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The revision set of the session's own commits: what local branches and HEAD's new reflog
|
|
173
|
+
* entries reach, minus where things stood at the start, minus what the remotes held then, minus
|
|
174
|
+
* what arrived from a remote since (work pulled from a teammate is theirs). What the session
|
|
175
|
+
* pushed is still the session's. A commit on another branch, one undone by `reset`, and one
|
|
176
|
+
* with a backdated date all belong to it.
|
|
177
|
+
* @param {string} root
|
|
178
|
+
* @param {{ tips?: string[], remotes?: string[], reflog?: number }} start
|
|
179
|
+
* @returns {string[]} git log / rev-list arguments
|
|
180
|
+
*/
|
|
181
|
+
export function sessionRevs(root, start) {
|
|
182
|
+
const log = reflogShas(root);
|
|
183
|
+
const fresh = log.slice(0, Math.max(0, log.length - (start.reflog ?? 0)));
|
|
184
|
+
const known = [...(start.tips ?? []), ...(start.remotes ?? []), ...received(root)];
|
|
185
|
+
return ["--branches", ...new Set(fresh), ...new Set(known.map((t) => `^${t}`))];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The session's commits, newest first.
|
|
190
|
+
* @param {string} root
|
|
191
|
+
* @param {{ tips?: string[], reflog?: number }} start
|
|
192
|
+
*/
|
|
193
|
+
export function sessionCommits(root, start) {
|
|
194
|
+
if (!start.tips) return [];
|
|
195
|
+
return (git(root, ["rev-list", ...sessionRevs(root, start)]) ?? "").split(/\r?\n/).filter(Boolean);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Every file changed since the session's snapshot, by content: files the session's commits
|
|
200
|
+
* touched (on any branch), files dirty now whose content differs from the start, files hidden
|
|
201
|
+
* from git's status by an index flag, and files put away in a stash made during the session.
|
|
202
|
+
* @param {string} root
|
|
203
|
+
* @param {{ head: string | null, dirty: Record<string, string>, stashes?: number, tips?: string[], remotes?: string[], reflog?: number }} snapshot
|
|
204
|
+
* @returns {Array<{ path: string, hash: string }>}
|
|
205
|
+
*/
|
|
206
|
+
export function changedSince(root, snapshot) {
|
|
207
|
+
const paths = new Set();
|
|
208
|
+
if (snapshot.tips) {
|
|
209
|
+
const committed = git(root, ["log", "--no-merges", "--name-only", "--format=", "-z", ...sessionRevs(root, snapshot)]);
|
|
210
|
+
// A file that was already dirty at the start and committed as it was is not a change.
|
|
211
|
+
for (const p of (committed ?? "").split(/[\0\n]/)) if (p && snapshot.dirty[p] !== hashOf(root, p)) paths.add(p);
|
|
212
|
+
} else if (snapshot.head) {
|
|
213
|
+
const committed = git(root, ["diff", "--name-only", "-z", snapshot.head, "HEAD"]);
|
|
214
|
+
for (const p of (committed ?? "").split("\0")) if (p && snapshot.dirty[p] !== hashOf(root, p)) paths.add(p);
|
|
215
|
+
}
|
|
216
|
+
const now = dirtyFiles(root);
|
|
217
|
+
for (const [p, hash] of Object.entries(now)) if (snapshot.dirty[p] !== hash) paths.add(p);
|
|
218
|
+
// A file dirty at the start and restored since changed too.
|
|
219
|
+
for (const p of Object.keys(snapshot.dirty)) if (!(p in now) && snapshot.dirty[p] !== hashOf(root, p)) paths.add(p);
|
|
220
|
+
for (const p of flaggedFiles(root)) {
|
|
221
|
+
const blob = (git(root, ["ls-files", "-s", "--", p]) ?? "").split(/\s+/)[1];
|
|
222
|
+
const work = git(root, ["hash-object", "--", p])?.trim() ?? "deleted";
|
|
223
|
+
if (blob !== work && snapshot.dirty[p] !== hashOf(root, p)) paths.add(p);
|
|
224
|
+
}
|
|
225
|
+
const fresh = stashCount(root) - (snapshot.stashes ?? 0);
|
|
226
|
+
for (let i = 0; i < fresh; i++) {
|
|
227
|
+
const files = git(root, ["stash", "show", "--name-only", "--include-untracked", `stash@{${i}}`]) ?? "";
|
|
228
|
+
for (const p of files.split(/\r?\n/)) if (p) paths.add(p);
|
|
229
|
+
}
|
|
230
|
+
return [...paths].map((path) => ({ path, hash: hashOf(root, path) }));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A file's content hash now, or "deleted". @param {string} root @param {string} rel */
|
|
234
|
+
export function contentHash(root, rel) {
|
|
235
|
+
return hashOf(root, rel);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A file's text at a revision, or null. @param {string} root @param {string} rev @param {string} path */
|
|
239
|
+
export function fileAt(root, rev, path) {
|
|
240
|
+
return git(root, ["show", `${rev}:${path}`]);
|
|
241
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// A small glob, enough for path rules in a config: `**` (any depth), `*` (within a segment),
|
|
2
|
+
// `?`, and `{a,b}` alternatives. No dependency, so the package keeps none of its own.
|
|
3
|
+
|
|
4
|
+
/** @param {string} glob */
|
|
5
|
+
export function globToRegExp(glob) {
|
|
6
|
+
let re = "";
|
|
7
|
+
for (let i = 0; i < glob.length; i++) {
|
|
8
|
+
const c = glob[i];
|
|
9
|
+
if (c === "*" && glob[i + 1] === "*") {
|
|
10
|
+
// `**/` matches zero or more whole segments; a trailing `**` matches anything.
|
|
11
|
+
if (glob[i + 2] === "/") {
|
|
12
|
+
re += "(?:.*/)?";
|
|
13
|
+
i += 2;
|
|
14
|
+
} else {
|
|
15
|
+
re += ".*";
|
|
16
|
+
i += 1;
|
|
17
|
+
}
|
|
18
|
+
} else if (c === "*") re += "[^/]*";
|
|
19
|
+
else if (c === "?") re += "[^/]";
|
|
20
|
+
else if (c === "{") {
|
|
21
|
+
const end = glob.indexOf("}", i);
|
|
22
|
+
if (end === -1) re += "\\{";
|
|
23
|
+
else {
|
|
24
|
+
re += `(?:${glob.slice(i + 1, end).split(",").map(escape).join("|")})`;
|
|
25
|
+
i = end;
|
|
26
|
+
}
|
|
27
|
+
} else re += escape(c);
|
|
28
|
+
}
|
|
29
|
+
return new RegExp(`^${re}$`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {string} s */
|
|
33
|
+
const escape = (s) => s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} path
|
|
37
|
+
* @param {string[]} globs
|
|
38
|
+
*/
|
|
39
|
+
export function matchesAny(path, globs) {
|
|
40
|
+
return globs.some((g) => globToRegExp(g).test(path));
|
|
41
|
+
}
|