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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +393 -0
  2. package/LICENSE +190 -0
  3. package/README.md +206 -2
  4. package/SECURITY.md +104 -0
  5. package/bin/trellis.mjs +5 -0
  6. package/docs/attestation/v0.1.md +96 -0
  7. package/package.json +78 -4
  8. package/schema/trellis.config.schema.json +349 -0
  9. package/src/agents/adapters.mjs +155 -0
  10. package/src/agents/git.mjs +241 -0
  11. package/src/agents/glob.mjs +41 -0
  12. package/src/agents/hooks.mjs +780 -0
  13. package/src/agents/install-adapters.mjs +172 -0
  14. package/src/agents/install.mjs +302 -0
  15. package/src/agents/manifests.mjs +127 -0
  16. package/src/agents/rules.mjs +330 -0
  17. package/src/agents/shell.mjs +253 -0
  18. package/src/agents/state.mjs +233 -0
  19. package/src/agents/transcript.mjs +120 -0
  20. package/src/cli/main.mjs +191 -0
  21. package/src/commands/attest.mjs +207 -0
  22. package/src/commands/checkpoint-state.mjs +62 -0
  23. package/src/commands/doctor.mjs +98 -0
  24. package/src/commands/guide.mjs +62 -0
  25. package/src/commands/init.mjs +129 -0
  26. package/src/commands/key.mjs +31 -0
  27. package/src/commands/scan.mjs +51 -0
  28. package/src/commands/uninstall.mjs +36 -0
  29. package/src/commands/verify.mjs +509 -0
  30. package/src/core/abatty.mjs +25 -0
  31. package/src/core/agents-block.mjs +65 -0
  32. package/src/core/attestation.mjs +213 -0
  33. package/src/core/config.mjs +135 -0
  34. package/src/core/context.mjs +280 -0
  35. package/src/core/diff.mjs +189 -0
  36. package/src/core/evidence.mjs +167 -0
  37. package/src/core/mcp.mjs +51 -0
  38. package/src/index.mjs +11 -0
  39. package/src/mcp/client.mjs +164 -0
  40. package/src/mcp/launch.mjs +57 -0
  41. package/src/mcp/relay.mjs +111 -0
  42. package/src/mcp/server.mjs +210 -0
  43. package/src/mcp/trust.mjs +142 -0
  44. package/src/tools/registry.mjs +370 -0
  45. package/src/tools/roles.mjs +17 -0
  46. package/src/verify/deps.mjs +382 -0
  47. package/src/verify/gitleaks-default.toml +3211 -0
  48. package/src/verify/rules.mjs +163 -0
  49. package/src/verify/secrets.mjs +169 -0
  50. package/src/verify/test-commands.mjs +116 -0
@@ -0,0 +1,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
+ }