@de-otio/repo-aegis-core 0.2.0

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 (191) hide show
  1. package/dist/age.d.ts +32 -0
  2. package/dist/age.d.ts.map +1 -0
  3. package/dist/age.js +98 -0
  4. package/dist/age.js.map +1 -0
  5. package/dist/audit-log.d.ts +50 -0
  6. package/dist/audit-log.d.ts.map +1 -0
  7. package/dist/audit-log.js +183 -0
  8. package/dist/audit-log.js.map +1 -0
  9. package/dist/audit-log.test.d.ts +2 -0
  10. package/dist/audit-log.test.d.ts.map +1 -0
  11. package/dist/audit-log.test.js +181 -0
  12. package/dist/audit-log.test.js.map +1 -0
  13. package/dist/deny-set.d.ts +43 -0
  14. package/dist/deny-set.d.ts.map +1 -0
  15. package/dist/deny-set.js +165 -0
  16. package/dist/deny-set.js.map +1 -0
  17. package/dist/deny-set.test.d.ts +2 -0
  18. package/dist/deny-set.test.d.ts.map +1 -0
  19. package/dist/deny-set.test.js +155 -0
  20. package/dist/deny-set.test.js.map +1 -0
  21. package/dist/exceptions.d.ts +96 -0
  22. package/dist/exceptions.d.ts.map +1 -0
  23. package/dist/exceptions.js +143 -0
  24. package/dist/exceptions.js.map +1 -0
  25. package/dist/exit-codes.d.ts +4 -0
  26. package/dist/exit-codes.d.ts.map +1 -0
  27. package/dist/exit-codes.js +6 -0
  28. package/dist/exit-codes.js.map +1 -0
  29. package/dist/first-touch.d.ts +57 -0
  30. package/dist/first-touch.d.ts.map +1 -0
  31. package/dist/first-touch.js +112 -0
  32. package/dist/first-touch.js.map +1 -0
  33. package/dist/import-graph.test.d.ts +2 -0
  34. package/dist/import-graph.test.d.ts.map +1 -0
  35. package/dist/import-graph.test.js +210 -0
  36. package/dist/import-graph.test.js.map +1 -0
  37. package/dist/index.d.ts +37 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +68 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/lock.d.ts +22 -0
  42. package/dist/lock.d.ts.map +1 -0
  43. package/dist/lock.js +86 -0
  44. package/dist/lock.js.map +1 -0
  45. package/dist/lock.test.d.ts +2 -0
  46. package/dist/lock.test.d.ts.map +1 -0
  47. package/dist/lock.test.js +125 -0
  48. package/dist/lock.test.js.map +1 -0
  49. package/dist/paths.d.ts +22 -0
  50. package/dist/paths.d.ts.map +1 -0
  51. package/dist/paths.js +46 -0
  52. package/dist/paths.js.map +1 -0
  53. package/dist/paths.test.d.ts +2 -0
  54. package/dist/paths.test.d.ts.map +1 -0
  55. package/dist/paths.test.js +78 -0
  56. package/dist/paths.test.js.map +1 -0
  57. package/dist/redaction.d.ts +29 -0
  58. package/dist/redaction.d.ts.map +1 -0
  59. package/dist/redaction.js +48 -0
  60. package/dist/redaction.js.map +1 -0
  61. package/dist/redaction.test.d.ts +2 -0
  62. package/dist/redaction.test.d.ts.map +1 -0
  63. package/dist/redaction.test.js +67 -0
  64. package/dist/redaction.test.js.map +1 -0
  65. package/dist/regex-safety.d.ts +87 -0
  66. package/dist/regex-safety.d.ts.map +1 -0
  67. package/dist/regex-safety.js +322 -0
  68. package/dist/regex-safety.js.map +1 -0
  69. package/dist/regex-safety.test.d.ts +2 -0
  70. package/dist/regex-safety.test.d.ts.map +1 -0
  71. package/dist/regex-safety.test.js +149 -0
  72. package/dist/regex-safety.test.js.map +1 -0
  73. package/dist/registry-mutate.d.ts +35 -0
  74. package/dist/registry-mutate.d.ts.map +1 -0
  75. package/dist/registry-mutate.js +149 -0
  76. package/dist/registry-mutate.js.map +1 -0
  77. package/dist/registry-mutate.test.d.ts +2 -0
  78. package/dist/registry-mutate.test.d.ts.map +1 -0
  79. package/dist/registry-mutate.test.js +96 -0
  80. package/dist/registry-mutate.test.js.map +1 -0
  81. package/dist/registry.d.ts +64 -0
  82. package/dist/registry.d.ts.map +1 -0
  83. package/dist/registry.js +120 -0
  84. package/dist/registry.js.map +1 -0
  85. package/dist/registry.test.d.ts +2 -0
  86. package/dist/registry.test.d.ts.map +1 -0
  87. package/dist/registry.test.js +316 -0
  88. package/dist/registry.test.js.map +1 -0
  89. package/dist/remote-url.d.ts +18 -0
  90. package/dist/remote-url.d.ts.map +1 -0
  91. package/dist/remote-url.js +66 -0
  92. package/dist/remote-url.js.map +1 -0
  93. package/dist/remote-url.test.d.ts +2 -0
  94. package/dist/remote-url.test.d.ts.map +1 -0
  95. package/dist/remote-url.test.js +116 -0
  96. package/dist/remote-url.test.js.map +1 -0
  97. package/dist/render.d.ts +54 -0
  98. package/dist/render.d.ts.map +1 -0
  99. package/dist/render.js +182 -0
  100. package/dist/render.js.map +1 -0
  101. package/dist/render.test.d.ts +2 -0
  102. package/dist/render.test.d.ts.map +1 -0
  103. package/dist/render.test.js +152 -0
  104. package/dist/render.test.js.map +1 -0
  105. package/dist/repo.d.ts +40 -0
  106. package/dist/repo.d.ts.map +1 -0
  107. package/dist/repo.js +214 -0
  108. package/dist/repo.js.map +1 -0
  109. package/dist/repo.test.d.ts +2 -0
  110. package/dist/repo.test.d.ts.map +1 -0
  111. package/dist/repo.test.js +234 -0
  112. package/dist/repo.test.js.map +1 -0
  113. package/dist/scan.d.ts +103 -0
  114. package/dist/scan.d.ts.map +1 -0
  115. package/dist/scan.js +436 -0
  116. package/dist/scan.js.map +1 -0
  117. package/dist/scan.test.d.ts +2 -0
  118. package/dist/scan.test.d.ts.map +1 -0
  119. package/dist/scan.test.js +437 -0
  120. package/dist/scan.test.js.map +1 -0
  121. package/dist/schemas.d.ts +50 -0
  122. package/dist/schemas.d.ts.map +1 -0
  123. package/dist/schemas.js +190 -0
  124. package/dist/schemas.js.map +1 -0
  125. package/dist/secret-markers.d.ts +34 -0
  126. package/dist/secret-markers.d.ts.map +1 -0
  127. package/dist/secret-markers.js +118 -0
  128. package/dist/secret-markers.js.map +1 -0
  129. package/dist/secret-markers.test.d.ts +2 -0
  130. package/dist/secret-markers.test.d.ts.map +1 -0
  131. package/dist/secret-markers.test.js +154 -0
  132. package/dist/secret-markers.test.js.map +1 -0
  133. package/dist/trust-boundary.d.ts +33 -0
  134. package/dist/trust-boundary.d.ts.map +1 -0
  135. package/dist/trust-boundary.js +77 -0
  136. package/dist/trust-boundary.js.map +1 -0
  137. package/dist/trust-boundary.test.d.ts +2 -0
  138. package/dist/trust-boundary.test.d.ts.map +1 -0
  139. package/dist/trust-boundary.test.js +170 -0
  140. package/dist/trust-boundary.test.js.map +1 -0
  141. package/dist/types.d.ts +47 -0
  142. package/dist/types.d.ts.map +1 -0
  143. package/dist/types.js +8 -0
  144. package/dist/types.js.map +1 -0
  145. package/dist/working-tree.d.ts +38 -0
  146. package/dist/working-tree.d.ts.map +1 -0
  147. package/dist/working-tree.js +133 -0
  148. package/dist/working-tree.js.map +1 -0
  149. package/dist/working-tree.test.d.ts +2 -0
  150. package/dist/working-tree.test.d.ts.map +1 -0
  151. package/dist/working-tree.test.js +162 -0
  152. package/dist/working-tree.test.js.map +1 -0
  153. package/package.json +40 -0
  154. package/src/age.ts +113 -0
  155. package/src/audit-log.test.ts +222 -0
  156. package/src/audit-log.ts +215 -0
  157. package/src/deny-set.test.ts +208 -0
  158. package/src/deny-set.ts +231 -0
  159. package/src/exceptions.ts +134 -0
  160. package/src/exit-codes.ts +5 -0
  161. package/src/first-touch.ts +172 -0
  162. package/src/import-graph.test.ts +239 -0
  163. package/src/index.ts +191 -0
  164. package/src/lock.test.ts +151 -0
  165. package/src/lock.ts +88 -0
  166. package/src/paths.test.ts +94 -0
  167. package/src/paths.ts +55 -0
  168. package/src/redaction.test.ts +81 -0
  169. package/src/redaction.ts +49 -0
  170. package/src/regex-safety.test.ts +194 -0
  171. package/src/regex-safety.ts +349 -0
  172. package/src/registry-mutate.test.ts +134 -0
  173. package/src/registry-mutate.ts +185 -0
  174. package/src/registry.test.ts +460 -0
  175. package/src/registry.ts +178 -0
  176. package/src/remote-url.test.ts +121 -0
  177. package/src/remote-url.ts +78 -0
  178. package/src/render.test.ts +206 -0
  179. package/src/render.ts +215 -0
  180. package/src/repo.test.ts +275 -0
  181. package/src/repo.ts +245 -0
  182. package/src/scan.test.ts +580 -0
  183. package/src/scan.ts +531 -0
  184. package/src/schemas.ts +207 -0
  185. package/src/secret-markers.test.ts +183 -0
  186. package/src/secret-markers.ts +145 -0
  187. package/src/trust-boundary.test.ts +198 -0
  188. package/src/trust-boundary.ts +98 -0
  189. package/src/types.ts +55 -0
  190. package/src/working-tree.test.ts +193 -0
  191. package/src/working-tree.ts +130 -0
@@ -0,0 +1,208 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ import { describe, it, before, after } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { computeDenySet, ALWAYS_FILE_STEM } from "./deny-set.js";
9
+ import type { RepoConfig, RepoClass } from "./repo.js";
10
+
11
+ let tmp: string;
12
+ let markersDir: string;
13
+
14
+ function setupMarkers() {
15
+ rmSync(markersDir, { recursive: true, force: true });
16
+ mkdirSync(markersDir, { recursive: true });
17
+ writeFileSync(join(markersDir, `${ALWAYS_FILE_STEM}.txt`), "PROJECT-CODENAME-ALPHA\n");
18
+ writeFileSync(join(markersDir, "customer-a.txt"), "acme-corp\nacme\\.com\n");
19
+ writeFileSync(join(markersDir, "customer-b.txt"), "betaco\nbetaco\\.tech\n");
20
+ }
21
+
22
+ function makeRepo(cls: RepoClass, engagements: string[] = []): RepoConfig {
23
+ return {
24
+ cwd: "/tmp/fake",
25
+ isGitRepo: true,
26
+ class: cls,
27
+ classExplicit: true,
28
+ engagements,
29
+ };
30
+ }
31
+
32
+ before(() => {
33
+ tmp = mkdtempSync(join(tmpdir(), "repo-aegis-denyset-"));
34
+ markersDir = join(tmp, "markers");
35
+ setupMarkers();
36
+ });
37
+
38
+ after(() => {
39
+ rmSync(tmp, { recursive: true, force: true });
40
+ });
41
+
42
+ describe("computeDenySet", () => {
43
+ it("returns empty set when markers dir does not exist", () => {
44
+ const ds = computeDenySet(makeRepo("private-strict"), { markersDir: join(tmp, "no-such-dir") });
45
+ assert.equal(ds.patterns.length, 0);
46
+ assert.equal(ds.combinedRegex, "");
47
+ });
48
+
49
+ it("public-eligible: full union (all marker files, no scoping)", () => {
50
+ const ds = computeDenySet(makeRepo("public-eligible"), { markersDir });
51
+ assert.equal(ds.files.length, 3);
52
+ assert.ok(ds.patterns.includes("acme-corp"));
53
+ assert.ok(ds.patterns.includes("betaco"));
54
+ assert.ok(ds.patterns.includes("PROJECT-CODENAME-ALPHA"));
55
+ });
56
+
57
+ it("private-strict: same as public-eligible", () => {
58
+ const ds = computeDenySet(makeRepo("private-strict"), { markersDir });
59
+ assert.equal(ds.files.length, 3);
60
+ });
61
+
62
+ it("public-eligible warns if engagement is set", () => {
63
+ const ds = computeDenySet(
64
+ makeRepo("public-eligible", ["customer-a"]),
65
+ { markersDir },
66
+ );
67
+ assert.ok(ds.warnings.length > 0);
68
+ // engagement is ignored: full deny set
69
+ assert.equal(ds.files.length, 3);
70
+ assert.ok(ds.patterns.includes("acme-corp"));
71
+ });
72
+
73
+ it("customer-coupled: scopes deny set, excluding own engagement", () => {
74
+ const ds = computeDenySet(
75
+ makeRepo("customer-coupled", ["customer-a"]),
76
+ { markersDir },
77
+ );
78
+ const stems = ds.files.map(f => f.stem).sort();
79
+ assert.deepEqual(stems, [ALWAYS_FILE_STEM, "customer-b"]);
80
+ assert.ok(ds.patterns.includes("betaco"));
81
+ assert.ok(ds.patterns.includes("PROJECT-CODENAME-ALPHA"));
82
+ assert.ok(!ds.patterns.includes("acme-corp"));
83
+ });
84
+
85
+ it("customer-coupled: still blocks _always", () => {
86
+ const ds = computeDenySet(
87
+ makeRepo("customer-coupled", ["customer-a"]),
88
+ { markersDir },
89
+ );
90
+ assert.ok(ds.patterns.includes("PROJECT-CODENAME-ALPHA"));
91
+ });
92
+
93
+ it("customer-coupled: multi-engagement excludes both own files", () => {
94
+ const ds = computeDenySet(
95
+ makeRepo("customer-coupled", ["customer-a", "customer-b"]),
96
+ { markersDir },
97
+ );
98
+ const stems = ds.files.map(f => f.stem).sort();
99
+ assert.deepEqual(stems, [ALWAYS_FILE_STEM]);
100
+ assert.ok(!ds.patterns.includes("acme-corp"));
101
+ assert.ok(!ds.patterns.includes("betaco"));
102
+ });
103
+
104
+ it("customer-coupled: empty engagements still computes deny set (caller enforces error)", () => {
105
+ const ds = computeDenySet(makeRepo("customer-coupled", []), { markersDir });
106
+ // Library doesn't enforce the error; that's the CLI's job. Library returns full set.
107
+ assert.equal(ds.files.length, 3);
108
+ });
109
+
110
+ it("scratch: same scoping as customer-coupled", () => {
111
+ const ds = computeDenySet(
112
+ makeRepo("scratch", ["customer-a"]),
113
+ { markersDir },
114
+ );
115
+ assert.equal(ds.files.length, 2);
116
+ });
117
+
118
+ it("strips ; comments and blank lines from marker files", () => {
119
+ writeFileSync(
120
+ join(markersDir, "customer-c.txt"),
121
+ "; comment line\n\nfirst-pattern\n; another comment\nsecond-pattern\n",
122
+ );
123
+ const ds = computeDenySet(makeRepo("private-strict"), { markersDir });
124
+ assert.ok(ds.patterns.includes("first-pattern"));
125
+ assert.ok(ds.patterns.includes("second-pattern"));
126
+ assert.ok(!ds.patterns.some(p => p.includes("comment")));
127
+ // restore
128
+ rmSync(join(markersDir, "customer-c.txt"));
129
+ });
130
+
131
+ it("writes a cache file with the expected shape on miss", () => {
132
+ const cachePath = join(tmp, "cache-shape.json");
133
+ const repo = makeRepo("private-strict");
134
+ const ds = computeDenySet(repo, { markersDir, cachePath });
135
+ assert.ok(existsSync(cachePath), "cache file written");
136
+ const cached = JSON.parse(readFileSync(cachePath, "utf8")) as {
137
+ schemaVersion: number;
138
+ key: string;
139
+ files: unknown[];
140
+ patterns: string[];
141
+ combinedRegex: string;
142
+ };
143
+ assert.equal(cached.schemaVersion, 2);
144
+ assert.equal(typeof cached.key, "string");
145
+ assert.equal(cached.key.length, 64, "fingerprint is sha256 hex");
146
+ assert.deepEqual(cached.patterns, ds.patterns);
147
+ assert.equal(cached.combinedRegex, ds.combinedRegex);
148
+ });
149
+
150
+ it("invalidates cache when a marker file changes (mtime updated)", () => {
151
+ const cachePath = join(tmp, "cache-invalidate.json");
152
+ const repo = makeRepo("private-strict");
153
+ const ds1 = computeDenySet(repo, { markersDir, cachePath });
154
+
155
+ // Change a marker file with a NEW mtime + different size.
156
+ const acmePath = join(markersDir, "customer-a.txt");
157
+ writeFileSync(acmePath, "completely-different-content\n");
158
+
159
+ const ds2 = computeDenySet(repo, { markersDir, cachePath });
160
+ assert.notDeepEqual(ds2.patterns, ds1.patterns, "cache must invalidate on marker change");
161
+ assert.ok(ds2.patterns.includes("completely-different-content"));
162
+
163
+ // Restore for downstream tests.
164
+ setupMarkers();
165
+ });
166
+
167
+ it("cachePath: null disables caching", () => {
168
+ const repo = makeRepo("private-strict");
169
+ const initialFiles = readdirSync(tmp).filter(f => f.endsWith(".json")).length;
170
+ computeDenySet(repo, { markersDir, cachePath: null });
171
+ const after = readdirSync(tmp).filter(f => f.endsWith(".json")).length;
172
+ assert.equal(after, initialFiles, "no cache file should be written");
173
+ });
174
+
175
+ it("populates patternSources parallel to patterns", () => {
176
+ const repo = makeRepo("private-strict");
177
+ const ds = computeDenySet(repo, { markersDir, cachePath: null });
178
+ assert.equal(ds.patternSources?.length, ds.patterns.length, "lengths must match");
179
+ // Spot-check: alphabetical file order is _always, customer-a, customer-b
180
+ const alwaysIdx = ds.patterns.findIndex(p => p === "PROJECT-CODENAME-ALPHA");
181
+ if (alwaysIdx >= 0) {
182
+ assert.equal(ds.patternSources![alwaysIdx], "_always");
183
+ }
184
+ const acmeIdx = ds.patterns.findIndex(p => p === "acme-corp");
185
+ if (acmeIdx >= 0) {
186
+ assert.equal(ds.patternSources![acmeIdx], "customer-a");
187
+ }
188
+ });
189
+
190
+ it("preserves mid-line ; characters in marker patterns", () => {
191
+ // Regression: legitimate patterns containing `;` (e.g. `db;internal`,
192
+ // `key;value` form codenames) used to be silently truncated at the
193
+ // first `;`. Only lines whose first non-whitespace character is `;`
194
+ // are comments.
195
+ writeFileSync(
196
+ join(markersDir, "customer-d.txt"),
197
+ "db;internal\nkey;val;more\n ; leading-space-comment\n",
198
+ );
199
+ const ds = computeDenySet(makeRepo("private-strict"), { markersDir });
200
+ assert.ok(ds.patterns.includes("db;internal"), "mid-line ; must not truncate");
201
+ assert.ok(ds.patterns.includes("key;val;more"), "multiple mid-line ; must survive");
202
+ assert.ok(
203
+ !ds.patterns.some(p => p.includes("leading-space-comment")),
204
+ "leading-whitespace ; lines are still comments",
205
+ );
206
+ rmSync(join(markersDir, "customer-d.txt"));
207
+ });
208
+ });
@@ -0,0 +1,231 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ import { createHash } from "node:crypto";
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import {
14
+ denySetCachePath as defaultDenySetCachePath,
15
+ markersDir as defaultMarkersDir,
16
+ } from "./paths.js";
17
+ import type { RepoConfig } from "./repo.js";
18
+
19
+ export const ALWAYS_FILE_STEM = "_always";
20
+
21
+ export interface DenySetFile {
22
+ stem: string;
23
+ path: string;
24
+ }
25
+
26
+ export interface DenySet {
27
+ files: DenySetFile[];
28
+ patterns: string[];
29
+ /**
30
+ * Parallel to `patterns`: `patternSources[i]` is the file stem (engagement
31
+ * id or `_always`) that pattern i was loaded from. Used by scanText to
32
+ * attribute each hit to its source engagement, surfaced as
33
+ * {@link ScanHit.engagement}.
34
+ *
35
+ * Optional for backward compatibility with fixtures and ad-hoc DenySet
36
+ * literals; runtime callers (computeDenySet) always populate it. When
37
+ * absent or length-mismatched, scanText falls back to no-attribution.
38
+ */
39
+ patternSources?: string[];
40
+ combinedRegex: string;
41
+ warnings: string[];
42
+ }
43
+
44
+ export interface DenySetOptions {
45
+ markersDir?: string;
46
+ /**
47
+ * Path to the cache file. Default: `<home>/state/deny-set.cache.json`.
48
+ * Pass `null` to disable caching entirely (useful for tests).
49
+ */
50
+ cachePath?: string | null;
51
+ }
52
+
53
+ // Bumped to 2 when patternSources was added; v1 caches are invalidated
54
+ // (the read path's schemaVersion check returns null, falling through to
55
+ // recompute).
56
+ const DENY_SET_CACHE_VERSION = 2;
57
+
58
+ interface CacheEntry {
59
+ schemaVersion: number;
60
+ key: string;
61
+ files: DenySetFile[];
62
+ patterns: string[];
63
+ patternSources: string[];
64
+ combinedRegex: string;
65
+ warnings: string[];
66
+ }
67
+
68
+ /**
69
+ * Build a fingerprint of the inputs to computeDenySet. Two calls with the
70
+ * same fingerprint produce the same deny set. Includes:
71
+ * - repo class + sorted engagements (these change the per-engagement
72
+ * filtering applied to the marker file set)
73
+ * - the marker dir's file list, mtimes, and sizes (any edit to a marker
74
+ * file or addition/removal invalidates the cache)
75
+ */
76
+ function computeFingerprint(repo: RepoConfig, dir: string): string {
77
+ const fileSummaries: string[] = [];
78
+ if (existsSync(dir)) {
79
+ const files = readdirSync(dir).filter(f => f.endsWith(".txt")).sort();
80
+ for (const f of files) {
81
+ const st = statSync(join(dir, f));
82
+ fileSummaries.push(`${f}:${st.mtimeMs}:${st.size}`);
83
+ }
84
+ }
85
+ const sortedEng = [...repo.engagements].sort().join(",");
86
+ const input = `v${DENY_SET_CACHE_VERSION}|${repo.class}|${sortedEng}|${fileSummaries.join(";")}`;
87
+ return createHash("sha256").update(input).digest("hex");
88
+ }
89
+
90
+ function readCache(cachePath: string): CacheEntry | null {
91
+ if (!existsSync(cachePath)) return null;
92
+ try {
93
+ const parsed = JSON.parse(readFileSync(cachePath, "utf8")) as Partial<CacheEntry>;
94
+ if (
95
+ parsed.schemaVersion !== DENY_SET_CACHE_VERSION ||
96
+ typeof parsed.key !== "string" ||
97
+ !Array.isArray(parsed.files) ||
98
+ !Array.isArray(parsed.patterns) ||
99
+ !Array.isArray(parsed.patternSources) ||
100
+ parsed.patternSources.length !== parsed.patterns.length ||
101
+ typeof parsed.combinedRegex !== "string" ||
102
+ !Array.isArray(parsed.warnings)
103
+ ) {
104
+ return null;
105
+ }
106
+ return parsed as CacheEntry;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ function writeCache(cachePath: string, entry: CacheEntry): void {
113
+ try {
114
+ mkdirSync(dirname(cachePath), { recursive: true });
115
+ writeFileSync(cachePath, JSON.stringify(entry, null, 2), { mode: 0o600 });
116
+ } catch {
117
+ /* cache is best-effort; failure to write is not fatal */
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Compute the per-repo deny set. Class-aware:
123
+ *
124
+ * - `public-eligible` / `private-strict`: full union (every marker file).
125
+ * Engagement field on the repo is ignored; if set, a warning is emitted.
126
+ * - `customer-coupled`: union of `_always.txt` + every per-engagement file
127
+ * whose stem is NOT in this repo's `engagements` list.
128
+ * - `scratch`: same set as `customer-coupled`, but the caller (the CLI's
129
+ * `check`) treats hits as advisory and exits 0.
130
+ */
131
+ export function computeDenySet(repo: RepoConfig, opts: DenySetOptions = {}): DenySet {
132
+ const dir = opts.markersDir ?? defaultMarkersDir();
133
+ const warnings: string[] = [];
134
+
135
+ if ((repo.class === "public-eligible" || repo.class === "private-strict") &&
136
+ repo.engagements.length > 0) {
137
+ warnings.push(
138
+ `repo class is ${repo.class} but ${repo.engagements.length} engagement(s) are set; ` +
139
+ `engagement field is ignored for non-customer-coupled classes`,
140
+ );
141
+ }
142
+
143
+ // Cache fast-path. Cache is keyed on (class, engagements, marker-file
144
+ // mtimes+sizes). An exact key match returns the cached deny set without
145
+ // re-reading any marker file. Caller can disable with cachePath: null
146
+ // (tests) or override the path. The base warnings (repo-class engagement
147
+ // mismatch, computed above) are always recomputed so they reflect the
148
+ // current call rather than what the cache wrote at fingerprint time.
149
+ const cachePath =
150
+ opts.cachePath === null ? null : opts.cachePath ?? defaultDenySetCachePath();
151
+ const fingerprint = computeFingerprint(repo, dir);
152
+
153
+ if (cachePath !== null) {
154
+ const cached = readCache(cachePath);
155
+ if (cached !== null && cached.key === fingerprint) {
156
+ return {
157
+ files: cached.files,
158
+ patterns: cached.patterns,
159
+ patternSources: cached.patternSources,
160
+ combinedRegex: cached.combinedRegex,
161
+ warnings: [...warnings, ...cached.warnings.filter(w => !warnings.includes(w))],
162
+ };
163
+ }
164
+ }
165
+
166
+ if (!existsSync(dir)) {
167
+ const empty: DenySet = { files: [], patterns: [], patternSources: [], combinedRegex: "", warnings };
168
+ if (cachePath !== null) {
169
+ writeCache(cachePath, {
170
+ schemaVersion: DENY_SET_CACHE_VERSION,
171
+ key: fingerprint,
172
+ files: [],
173
+ patterns: [],
174
+ patternSources: [],
175
+ combinedRegex: "",
176
+ warnings: [],
177
+ });
178
+ }
179
+ return empty;
180
+ }
181
+
182
+ const own = new Set(repo.engagements);
183
+ const useScoping = repo.class === "customer-coupled" || repo.class === "scratch";
184
+
185
+ const files: DenySetFile[] = readdirSync(dir)
186
+ .filter(f => f.endsWith(".txt"))
187
+ .map(f => ({ stem: f.replace(/\.txt$/, ""), path: join(dir, f) }))
188
+ .filter(({ stem }) => {
189
+ if (stem === ALWAYS_FILE_STEM) return true;
190
+ if (!useScoping) return true;
191
+ return !own.has(stem);
192
+ });
193
+
194
+ const patterns: string[] = [];
195
+ const patternSources: string[] = [];
196
+ for (const f of files) {
197
+ const lines = readFileSync(f.path, "utf8").split("\n");
198
+ for (const raw of lines) {
199
+ const trimmed = raw.trim();
200
+ // A line is a comment only if its first non-whitespace character is `;`.
201
+ // Mid-line `;` is part of the pattern (e.g. `db;internal` is a literal
202
+ // marker, not "db" with a comment).
203
+ if (trimmed.length === 0 || trimmed.startsWith(";")) continue;
204
+ patterns.push(trimmed);
205
+ patternSources.push(f.stem);
206
+ }
207
+ }
208
+ const result: DenySet = {
209
+ files,
210
+ patterns,
211
+ patternSources,
212
+ combinedRegex: patterns.join("|"),
213
+ warnings,
214
+ };
215
+
216
+ if (cachePath !== null) {
217
+ writeCache(cachePath, {
218
+ schemaVersion: DENY_SET_CACHE_VERSION,
219
+ key: fingerprint,
220
+ files,
221
+ patterns,
222
+ patternSources,
223
+ combinedRegex: result.combinedRegex,
224
+ // Cache only the input-derived warnings; the call-time class mismatch
225
+ // warning is recomputed above per call.
226
+ warnings: [],
227
+ });
228
+ }
229
+
230
+ return result;
231
+ }
@@ -0,0 +1,134 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ import { redactMatch } from "./redaction.js";
4
+
5
+ export class RegistryNotFoundError extends Error {
6
+ readonly code = "REGISTRY_NOT_FOUND" as const;
7
+ constructor(public path: string) {
8
+ super(`engagement registry not found at ${path}`);
9
+ this.name = "RegistryNotFoundError";
10
+ }
11
+ }
12
+
13
+ export class RegistryParseError extends Error {
14
+ readonly code = "REGISTRY_PARSE" as const;
15
+ constructor(public path: string, public override cause: unknown) {
16
+ super(`failed to parse registry at ${path}: ${(cause as Error).message ?? cause}`);
17
+ this.name = "RegistryParseError";
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Raised by `loadRegistry` when the plaintext registry path is absent
23
+ * but a sibling `<path>.age` ciphertext exists. The user (or the agent
24
+ * on their behalf) must run `repo-aegis registry decrypt --identity
25
+ * <path>` before any registry-reading command will work.
26
+ *
27
+ * Why this is its own error: encrypted-at-rest is the *intended* state
28
+ * during periods of non-use. Auto-decrypt would defeat the purpose
29
+ * (any process could read the registry). Surfacing a distinct code
30
+ * lets the agent guide the user to the right command without trying
31
+ * to recover blindly.
32
+ */
33
+ export class RegistryEncryptedError extends Error {
34
+ readonly code = "REGISTRY_ENCRYPTED" as const;
35
+ constructor(public path: string, public ciphertextPath: string) {
36
+ super(
37
+ `engagement registry at ${path} is encrypted at rest ` +
38
+ `(ciphertext present at ${ciphertextPath}); ` +
39
+ `run \`repo-aegis registry decrypt --identity <path>\` first`,
40
+ );
41
+ this.name = "RegistryEncryptedError";
42
+ }
43
+ }
44
+
45
+ export class NotAGitRepoError extends Error {
46
+ readonly code = "NOT_GIT_REPO" as const;
47
+ constructor() {
48
+ super("not inside a git repository");
49
+ this.name = "NotAGitRepoError";
50
+ }
51
+ }
52
+
53
+ export class AmbiguousQueryError extends Error {
54
+ readonly code = "AMBIGUOUS_QUERY" as const;
55
+ constructor(public query: string, public candidateCount: number) {
56
+ super(`ambiguous engagement query "${query}" (${candidateCount} candidates)`);
57
+ this.name = "AmbiguousQueryError";
58
+ }
59
+ }
60
+
61
+ export class EngagementNotFoundError extends Error {
62
+ readonly code = "ENGAGEMENT_NOT_FOUND" as const;
63
+ constructor(public query: string) {
64
+ super(`no engagement matches "${query}"`);
65
+ this.name = "EngagementNotFoundError";
66
+ }
67
+ }
68
+
69
+ export class PatternValidationError extends Error {
70
+ readonly code = "PATTERN_VALIDATION" as const;
71
+ constructor(
72
+ public invalid: { pattern: string; reason: string; engagementId?: string }[],
73
+ ) {
74
+ super(`${invalid.length} marker pattern${invalid.length === 1 ? "" : "s"} failed validation`);
75
+ this.name = "PatternValidationError";
76
+ }
77
+
78
+ /**
79
+ * Same shape as {@link invalid} but with each `pattern` field passed
80
+ * through {@link redactMatch} so the literal customer-derived substring
81
+ * never leaves the process.
82
+ *
83
+ * Marker patterns are user-authored regexes that typically embed
84
+ * customer-derived strings (e.g. `acmeengineering\.com`). They fall
85
+ * under the same redaction policy as match values whenever they are
86
+ * surfaced to JSON, log files, or anything an AI agent might
87
+ * subsequently read.
88
+ *
89
+ * Callers serialising this error to JSON, stderr, or any persisted
90
+ * artifact should prefer `redactedPatterns`. The raw `invalid` field
91
+ * remains for the in-process renderer (it needs the literal pattern to
92
+ * point the user at the offending entry in their own marker file) —
93
+ * that is an internal contract; do not leak it across a process
94
+ * boundary.
95
+ */
96
+ get redactedPatterns(): { pattern: string; reason: string; engagementId?: string }[] {
97
+ return this.invalid.map(entry => {
98
+ const out: { pattern: string; reason: string; engagementId?: string } = {
99
+ pattern: redactMatch(entry.pattern),
100
+ reason: entry.reason,
101
+ };
102
+ if (entry.engagementId !== undefined) out.engagementId = entry.engagementId;
103
+ return out;
104
+ });
105
+ }
106
+ }
107
+
108
+ export class OutsideWorkingTreeError extends Error {
109
+ readonly code = "OUTSIDE_WORKING_TREE" as const;
110
+ constructor(public path: string, public workingTree: string) {
111
+ super(`path ${path} is outside the working tree ${workingTree}`);
112
+ this.name = "OutsideWorkingTreeError";
113
+ }
114
+ }
115
+
116
+ export class LockTimeoutError extends Error {
117
+ readonly code = "LOCK_TIMEOUT" as const;
118
+ constructor(public lockPath: string) {
119
+ super(`could not acquire registry lock at ${lockPath} (another repo-aegis process is running?)`);
120
+ this.name = "LockTimeoutError";
121
+ }
122
+ }
123
+
124
+ export class CustomerCoupledNoEngagementError extends Error {
125
+ readonly code = "CUSTOMER_COUPLED_NO_ENGAGEMENT" as const;
126
+ constructor() {
127
+ super(
128
+ "repo-aegis.class=customer-coupled but no repo-aegis.engagement is set; " +
129
+ "run `repo-aegis allow <engagement-id>` to declare which engagement(s) " +
130
+ "this repo legitimately references",
131
+ );
132
+ this.name = "CustomerCoupledNoEngagementError";
133
+ }
134
+ }
@@ -0,0 +1,5 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ export const EXIT_OK = 0;
4
+ export const EXIT_HIT = 1;
5
+ export const EXIT_USAGE = 2;