@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,198 @@
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 { execFileSync } from "node:child_process";
6
+ import { mkdtempSync, mkdirSync, rmSync, realpathSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import {
10
+ computeTrustBoundary,
11
+ trustBoundariesOverlap,
12
+ } from "./trust-boundary.js";
13
+ import type { Registry, Engagement } from "./registry.js";
14
+
15
+ let tmp: string;
16
+
17
+ before(() => {
18
+ tmp = realpathSync(mkdtempSync(join(tmpdir(), "repo-aegis-tb-")));
19
+ });
20
+
21
+ after(() => {
22
+ rmSync(tmp, { recursive: true, force: true });
23
+ });
24
+
25
+ function makeRepo(
26
+ name: string,
27
+ opts: { remote?: string; class?: string; engagements?: string[] } = {},
28
+ ): string {
29
+ const dir = join(tmp, name);
30
+ mkdirSync(dir);
31
+ execFileSync("git", ["init", "-q", "--initial-branch=main", dir], { stdio: "ignore" });
32
+ if (opts.remote) {
33
+ execFileSync("git", ["-C", dir, "config", "remote.origin.url", opts.remote], {
34
+ stdio: "ignore",
35
+ });
36
+ }
37
+ if (opts.class) {
38
+ execFileSync("git", ["-C", dir, "config", "repo-aegis.class", opts.class], {
39
+ stdio: "ignore",
40
+ });
41
+ }
42
+ for (const eng of opts.engagements ?? []) {
43
+ execFileSync("git", ["-C", dir, "config", "--add", "repo-aegis.engagement", eng], {
44
+ stdio: "ignore",
45
+ });
46
+ }
47
+ return dir;
48
+ }
49
+
50
+ function makeRegistry(
51
+ engagements: Engagement[],
52
+ personalOrgs: string[] = [],
53
+ ): Registry {
54
+ return {
55
+ engagements,
56
+ alwaysBlock: [],
57
+ personalOrgs,
58
+ schemaVersion: 2,
59
+ };
60
+ }
61
+
62
+ const E_ALPHA: Engagement = {
63
+ id: "alpha",
64
+ name: "Alpha",
65
+ markers: [],
66
+ githubOrgs: ["alpha-org", "alpha-mirror"],
67
+ };
68
+ const E_BETA: Engagement = {
69
+ id: "beta",
70
+ name: "Beta",
71
+ markers: [],
72
+ githubOrgs: ["beta-org"],
73
+ };
74
+
75
+ describe("computeTrustBoundary", () => {
76
+ it("derives orgs from a single allow'd engagement", () => {
77
+ const repo = makeRepo("ct-cust", {
78
+ remote: "git@github.com:alpha-org/foo.git",
79
+ class: "customer-coupled",
80
+ engagements: ["alpha"],
81
+ });
82
+ const reg = makeRegistry([E_ALPHA, E_BETA]);
83
+ const tb = computeTrustBoundary(repo, reg);
84
+ assert.deepEqual([...tb.orgs].sort(), ["alpha-mirror", "alpha-org"]);
85
+ assert.equal(tb.fromRemoteFallback, false);
86
+ assert.equal(tb.class, "customer-coupled");
87
+ });
88
+
89
+ it("union'd orgs across multiple allow'd engagements", () => {
90
+ const repo = makeRepo("ct-multi", {
91
+ remote: "git@github.com:alpha-org/x.git",
92
+ class: "customer-coupled",
93
+ engagements: ["alpha", "beta"],
94
+ });
95
+ const reg = makeRegistry([E_ALPHA, E_BETA]);
96
+ const tb = computeTrustBoundary(repo, reg);
97
+ assert.deepEqual([...tb.orgs].sort(), ["alpha-mirror", "alpha-org", "beta-org"]);
98
+ });
99
+
100
+ it("public-eligible picks up personalOrgs", () => {
101
+ const repo = makeRepo("ct-pub", {
102
+ remote: "git@github.com:de-otio/foo.git",
103
+ class: "public-eligible",
104
+ });
105
+ const reg = makeRegistry([E_ALPHA], ["de-otio", "personal2"]);
106
+ const tb = computeTrustBoundary(repo, reg);
107
+ assert.deepEqual([...tb.orgs].sort(), ["de-otio", "personal2"]);
108
+ assert.equal(tb.fromRemoteFallback, false);
109
+ });
110
+
111
+ it("falls back to the remote when classification is empty", () => {
112
+ const repo = makeRepo("ct-fallback", {
113
+ remote: "git@github.com:de-otio/foo.git",
114
+ // No class, no engagements: defaults to private-strict.
115
+ });
116
+ const reg = makeRegistry([E_ALPHA]);
117
+ const tb = computeTrustBoundary(repo, reg);
118
+ assert.deepEqual([...tb.orgs], ["de-otio"]);
119
+ assert.equal(tb.fromRemoteFallback, true);
120
+ assert.equal(tb.class, "private-strict");
121
+ assert.equal(tb.classExplicit, false);
122
+ });
123
+
124
+ it("returns an empty set when there's no remote and no classification", () => {
125
+ const repo = makeRepo("ct-bare");
126
+ const reg = makeRegistry([E_ALPHA]);
127
+ const tb = computeTrustBoundary(repo, reg);
128
+ assert.equal(tb.orgs.size, 0);
129
+ assert.equal(tb.fromRemoteFallback, false);
130
+ });
131
+
132
+ it("classification beats remote (forks)", () => {
133
+ // Repo's remote is the customer org but it's allow'd into the
134
+ // personal world (e.g. an internal mirror of an upstream).
135
+ const repo = makeRepo("ct-fork", {
136
+ remote: "git@github.com:alpha-org/x.git",
137
+ class: "public-eligible",
138
+ });
139
+ const reg = makeRegistry([E_ALPHA], ["de-otio"]);
140
+ const tb = computeTrustBoundary(repo, reg);
141
+ assert.deepEqual([...tb.orgs], ["de-otio"]);
142
+ assert.equal(tb.fromRemoteFallback, false);
143
+ });
144
+ });
145
+
146
+ describe("trustBoundariesOverlap", () => {
147
+ const reg = makeRegistry([E_ALPHA, E_BETA], ["de-otio"]);
148
+
149
+ it("returns true when both repos sit in the same engagement orgs", () => {
150
+ const a = makeRepo("ov-a", { class: "customer-coupled", engagements: ["alpha"] });
151
+ const b = makeRepo("ov-b", { class: "customer-coupled", engagements: ["alpha"] });
152
+ assert.ok(trustBoundariesOverlap(computeTrustBoundary(a, reg), computeTrustBoundary(b, reg)));
153
+ });
154
+
155
+ it("returns true when repos share at least one githubOrg via different engagements", () => {
156
+ // Engagement γ shares one org with α
157
+ const E_GAMMA: Engagement = {
158
+ id: "gamma",
159
+ name: "Gamma",
160
+ markers: [],
161
+ githubOrgs: ["alpha-mirror", "gamma-org"],
162
+ };
163
+ const reg2 = makeRegistry([E_ALPHA, E_GAMMA]);
164
+ const a = makeRepo("ov-shared-a", { class: "customer-coupled", engagements: ["alpha"] });
165
+ const b = makeRepo("ov-shared-b", { class: "customer-coupled", engagements: ["gamma"] });
166
+ assert.ok(trustBoundariesOverlap(computeTrustBoundary(a, reg2), computeTrustBoundary(b, reg2)));
167
+ });
168
+
169
+ it("returns false when engagements have disjoint orgs", () => {
170
+ const a = makeRepo("ov-x", { class: "customer-coupled", engagements: ["alpha"] });
171
+ const b = makeRepo("ov-y", { class: "customer-coupled", engagements: ["beta"] });
172
+ assert.ok(!trustBoundariesOverlap(computeTrustBoundary(a, reg), computeTrustBoundary(b, reg)));
173
+ });
174
+
175
+ it("returns false when one side is personal and the other customer-coupled", () => {
176
+ const a = makeRepo("ov-pers", {
177
+ remote: "git@github.com:de-otio/x.git",
178
+ class: "public-eligible",
179
+ });
180
+ const b = makeRepo("ov-cust", {
181
+ class: "customer-coupled",
182
+ engagements: ["alpha"],
183
+ });
184
+ assert.ok(!trustBoundariesOverlap(computeTrustBoundary(a, reg), computeTrustBoundary(b, reg)));
185
+ });
186
+
187
+ it("returns false when both sides have empty boundaries", () => {
188
+ const a = makeRepo("ov-empty-a");
189
+ const b = makeRepo("ov-empty-b");
190
+ assert.ok(!trustBoundariesOverlap(computeTrustBoundary(a, reg), computeTrustBoundary(b, reg)));
191
+ });
192
+
193
+ it("matches via remote fallback when neither repo is classified but they share an org", () => {
194
+ const a = makeRepo("ov-r-a", { remote: "git@github.com:de-otio/x.git" });
195
+ const b = makeRepo("ov-r-b", { remote: "git@github.com:de-otio/y.git" });
196
+ assert.ok(trustBoundariesOverlap(computeTrustBoundary(a, reg), computeTrustBoundary(b, reg)));
197
+ });
198
+ });
@@ -0,0 +1,98 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ //
4
+ // Trust-boundary computation for the path-aware PostToolUse hook.
5
+ //
6
+ // Two working trees are in the same trust boundary if their derived
7
+ // org sets overlap. The org set for a working tree is:
8
+ //
9
+ // (engagements[*].githubOrgs of every engagement the repo is
10
+ // allow'd into)
11
+ // ∪ (personalOrgs, if class === public-eligible)
12
+ // ∪ (remote-origin org, if the above is empty)
13
+ //
14
+ // The classification is the source of truth (per design open question
15
+ // #3): a fork's remote may belong to one org while the classification
16
+ // declares the canonical engagement-org mapping. The remote is only
17
+ // consulted as a last-resort fallback for completely unclassified repos.
18
+
19
+ import { readRepoConfig, type RepoClass } from "./repo.js";
20
+ import { type Registry } from "./registry.js";
21
+ import { getRemoteOrg } from "./working-tree.js";
22
+
23
+ export interface TrustBoundary {
24
+ /** Set of GitHub orgs that span this repo's trust boundary. */
25
+ orgs: Set<string>;
26
+ /**
27
+ * True when `orgs` came from the git remote because no
28
+ * classification could supply orgs. The hook surfaces this as
29
+ * `DEST_UNCLASSIFIED` when the destination tree is in this state.
30
+ */
31
+ fromRemoteFallback: boolean;
32
+ /** The repo's resolved class (private-strict if unclassified). */
33
+ class: RepoClass;
34
+ /** True if class came from explicit git config / `.repo-aegis.yml`. */
35
+ classExplicit: boolean;
36
+ }
37
+
38
+ /**
39
+ * Compute the trust boundary for a working tree against a registry.
40
+ *
41
+ * Reads `git config repo-aegis.class` / `.repo-aegis.yml` for the
42
+ * working tree (via {@link readRepoConfig}), pulls the relevant
43
+ * `githubOrgs` arrays out of the registry, and falls back to the
44
+ * remote URL only when the classification supplies no orgs.
45
+ */
46
+ export function computeTrustBoundary(
47
+ workingTree: string,
48
+ registry: Registry,
49
+ ): TrustBoundary {
50
+ const repo = readRepoConfig(workingTree);
51
+ const orgs = new Set<string>();
52
+
53
+ // Engagement-derived orgs.
54
+ for (const engId of repo.engagements) {
55
+ const eng = registry.engagements.find(e => e.id === engId);
56
+ if (!eng) continue;
57
+ for (const org of eng.githubOrgs ?? []) {
58
+ orgs.add(org.toLowerCase());
59
+ }
60
+ }
61
+
62
+ // public-eligible repos sit on every personal org. The model says
63
+ // "this repo is not customer-coupled, so anything in personalOrgs
64
+ // is its peer."
65
+ if (repo.class === "public-eligible") {
66
+ for (const org of registry.personalOrgs ?? []) {
67
+ orgs.add(org.toLowerCase());
68
+ }
69
+ }
70
+
71
+ let fromRemoteFallback = false;
72
+ if (orgs.size === 0) {
73
+ const remoteOrg = getRemoteOrg(workingTree);
74
+ if (remoteOrg !== null) {
75
+ orgs.add(remoteOrg);
76
+ fromRemoteFallback = true;
77
+ }
78
+ }
79
+
80
+ return {
81
+ orgs,
82
+ fromRemoteFallback,
83
+ class: repo.class,
84
+ classExplicit: repo.classExplicit,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Two trust boundaries overlap iff their org sets share at least one
90
+ * element. Two empty sets do NOT overlap — that's the "neither side
91
+ * has any signal" case, which the policy layer treats as
92
+ * "scan-with-warning" rather than silently allowing.
93
+ */
94
+ export function trustBoundariesOverlap(a: TrustBoundary, b: TrustBoundary): boolean {
95
+ if (a.orgs.size === 0 || b.orgs.size === 0) return false;
96
+ for (const o of a.orgs) if (b.orgs.has(o)) return true;
97
+ return false;
98
+ }
package/src/types.ts ADDED
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ // Central type re-export. Every module in core defines its own types, but
4
+ // downstream consumers (cli, scan) should import from `@de-otio/repo-aegis-core`
5
+ // (which re-exports everything via index.ts). This file just makes sure the
6
+ // canonical TypeScript types are aggregated in one place for ease of review.
7
+
8
+ export type { Engagement, Registry, ResolveResult } from "./registry.js";
9
+ export type { RepoClass, RepoConfig } from "./repo.js";
10
+ export type { DenySet, DenySetFile, DenySetOptions } from "./deny-set.js";
11
+ export type { ScanHit, SkippedFile, ScanOptions } from "./scan.js";
12
+ export type { RenderOptions, RenderedFile, RenderResult } from "./render.js";
13
+ export type { PatternValidationResult } from "./regex-safety.js";
14
+ export type { RedactionMode } from "./redaction.js";
15
+
16
+ /**
17
+ * Canonical JSON shape for a repo across every CLI subcommand's output.
18
+ * Consumers of repo-aegis (other tools, AI agents) parse this once and
19
+ * reuse across allow/deny/status/check.
20
+ *
21
+ * `engagements` is **deliberately a list of bare ids** (`string[]`), not
22
+ * a list of {@link EngagementJson}. RepoJson describes the per-repo
23
+ * configured membership recorded by `git config --get-all
24
+ * repo-aegis.engagement` — it is the *reference set*, not a hydrated view
25
+ * of registry state. Resolving an id to its registry entry (name, active
26
+ * flag) is the consumer's responsibility, and the canonical hydrated
27
+ * shape is {@link EngagementJson}, which appears as a sibling field on
28
+ * commands that perform that resolution (allow, deny, status). The two
29
+ * are intentionally distinct and must not be conflated.
30
+ */
31
+ export interface RepoJson {
32
+ cwd: string;
33
+ isGitRepo: boolean;
34
+ class: import("./repo.js").RepoClass;
35
+ classExplicit: boolean;
36
+ engagements: string[];
37
+ }
38
+
39
+ /**
40
+ * Canonical JSON shape for an engagement reference in CLI output.
41
+ * Includes only the fields needed to identify and act on the engagement;
42
+ * full {@link Engagement} bodies (with markers) are never emitted to JSON
43
+ * outputs that flow through hook context.
44
+ *
45
+ * Relationship to {@link RepoJson}: `RepoJson.engagements` is `string[]`
46
+ * (the per-repo membership of bare ids); commands that hydrate those ids
47
+ * against the registry (e.g. `allow`, `deny`, `status`) emit a separate
48
+ * sibling field of type `EngagementJson` (or `EngagementJson[]`) with the
49
+ * resolved name + active flag. The shapes are not interchangeable.
50
+ */
51
+ export interface EngagementJson {
52
+ id: string;
53
+ name: string;
54
+ active: boolean;
55
+ }
@@ -0,0 +1,193 @@
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 { execFileSync } from "node:child_process";
6
+ import {
7
+ mkdtempSync,
8
+ mkdirSync,
9
+ writeFileSync,
10
+ symlinkSync,
11
+ rmSync,
12
+ realpathSync,
13
+ } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import {
17
+ findEnclosingWorkingTree,
18
+ resolveGitDir,
19
+ getRemoteOrg,
20
+ } from "./working-tree.js";
21
+
22
+ let tmp: string;
23
+
24
+ before(() => {
25
+ tmp = realpathSync(mkdtempSync(join(tmpdir(), "repo-aegis-wt-")));
26
+ });
27
+
28
+ after(() => {
29
+ rmSync(tmp, { recursive: true, force: true });
30
+ });
31
+
32
+ function gitInit(dir: string): void {
33
+ execFileSync("git", ["init", "-q", "--initial-branch=main", dir], { stdio: "ignore" });
34
+ }
35
+
36
+ function gitConfig(dir: string, key: string, value: string): void {
37
+ execFileSync("git", ["-C", dir, "config", key, value], { stdio: "ignore" });
38
+ }
39
+
40
+ function commitNothing(dir: string): void {
41
+ // Need at least one commit before `git worktree add`.
42
+ gitConfig(dir, "user.email", "test@example.com");
43
+ gitConfig(dir, "user.name", "Test");
44
+ writeFileSync(join(dir, "README.md"), "x");
45
+ execFileSync("git", ["-C", dir, "add", "README.md"], { stdio: "ignore" });
46
+ execFileSync("git", ["-C", dir, "commit", "-q", "-m", "initial"], { stdio: "ignore" });
47
+ }
48
+
49
+ describe("findEnclosingWorkingTree", () => {
50
+ it("finds the enclosing working tree of a file in a regular repo", () => {
51
+ const repo = join(tmp, "regular");
52
+ mkdirSync(repo);
53
+ gitInit(repo);
54
+ const file = join(repo, "src", "foo.ts");
55
+ mkdirSync(join(repo, "src"));
56
+ writeFileSync(file, "x");
57
+ assert.equal(findEnclosingWorkingTree(file), repo);
58
+ });
59
+
60
+ it("finds the working tree from a nested subdirectory", () => {
61
+ const repo = join(tmp, "nested");
62
+ mkdirSync(repo);
63
+ gitInit(repo);
64
+ const deep = join(repo, "a", "b", "c");
65
+ mkdirSync(deep, { recursive: true });
66
+ const file = join(deep, "deep.ts");
67
+ writeFileSync(file, "x");
68
+ assert.equal(findEnclosingWorkingTree(file), repo);
69
+ });
70
+
71
+ it("returns the worktree (not the parent repo) for a git worktree", () => {
72
+ const main = join(tmp, "main-repo");
73
+ mkdirSync(main);
74
+ gitInit(main);
75
+ commitNothing(main);
76
+ const wt = join(tmp, "wt-feature");
77
+ execFileSync("git", ["-C", main, "worktree", "add", "-q", "-b", "feat", wt], {
78
+ stdio: "ignore",
79
+ });
80
+ const file = join(wt, "newfile.ts");
81
+ writeFileSync(file, "x");
82
+ assert.equal(findEnclosingWorkingTree(file), wt);
83
+ });
84
+
85
+ it("returns null for a path outside any git repo", () => {
86
+ const outside = join(tmp, "no-git");
87
+ mkdirSync(outside);
88
+ writeFileSync(join(outside, "loose.txt"), "x");
89
+ assert.equal(findEnclosingWorkingTree(join(outside, "loose.txt")), null);
90
+ });
91
+
92
+ it("follows symlinks (resolves to the destination's working tree)", () => {
93
+ const repo = join(tmp, "sym-target");
94
+ mkdirSync(repo);
95
+ gitInit(repo);
96
+ const target = join(repo, "real.ts");
97
+ writeFileSync(target, "x");
98
+ const linkDir = join(tmp, "sym-link");
99
+ mkdirSync(linkDir);
100
+ const link = join(linkDir, "alias.ts");
101
+ symlinkSync(target, link);
102
+ // Even though `link` lives outside `repo`, realpath resolves to
103
+ // inside `repo`, and that's what we apply rules to.
104
+ assert.equal(findEnclosingWorkingTree(link), repo);
105
+ });
106
+
107
+ it("works when the file does not yet exist (walks up to first existing ancestor)", () => {
108
+ const repo = join(tmp, "no-file");
109
+ mkdirSync(repo);
110
+ gitInit(repo);
111
+ const ghost = join(repo, "src", "not-yet.ts");
112
+ // Don't create the file or its dir.
113
+ assert.equal(findEnclosingWorkingTree(ghost), repo);
114
+ });
115
+ });
116
+
117
+ describe("resolveGitDir", () => {
118
+ it("returns <wt>/.git for a regular repo", () => {
119
+ const repo = join(tmp, "rgd-regular");
120
+ mkdirSync(repo);
121
+ gitInit(repo);
122
+ const gitDir = resolveGitDir(repo);
123
+ assert.equal(gitDir, join(repo, ".git"));
124
+ });
125
+
126
+ it("resolves the linked gitdir for a worktree", () => {
127
+ const main = join(tmp, "rgd-main");
128
+ mkdirSync(main);
129
+ gitInit(main);
130
+ commitNothing(main);
131
+ const wt = join(tmp, "rgd-wt");
132
+ execFileSync("git", ["-C", main, "worktree", "add", "-q", "-b", "feat", wt], {
133
+ stdio: "ignore",
134
+ });
135
+ const gitDir = resolveGitDir(wt);
136
+ assert.ok(gitDir !== null);
137
+ // Should point to <main>/.git/worktrees/<name>
138
+ assert.match(gitDir!, /worktrees/);
139
+ });
140
+
141
+ it("returns null when .git is missing", () => {
142
+ const dir = join(tmp, "rgd-bare");
143
+ mkdirSync(dir);
144
+ assert.equal(resolveGitDir(dir), null);
145
+ });
146
+ });
147
+
148
+ describe("getRemoteOrg", () => {
149
+ it("parses GitHub HTTPS URLs", () => {
150
+ const repo = join(tmp, "remote-https");
151
+ mkdirSync(repo);
152
+ gitInit(repo);
153
+ gitConfig(repo, "remote.origin.url", "https://github.com/Foo-Org/Bar.git");
154
+ assert.equal(getRemoteOrg(repo), "foo-org");
155
+ });
156
+
157
+ it("parses GitHub SSH URLs", () => {
158
+ const repo = join(tmp, "remote-ssh");
159
+ mkdirSync(repo);
160
+ gitInit(repo);
161
+ gitConfig(repo, "remote.origin.url", "git@github.com:de-otio/repo-aegis.git");
162
+ assert.equal(getRemoteOrg(repo), "de-otio");
163
+ });
164
+
165
+ it("parses ssh-config-alias URLs (`github.com-personal`)", () => {
166
+ const repo = join(tmp, "remote-alias");
167
+ mkdirSync(repo);
168
+ gitInit(repo);
169
+ gitConfig(repo, "remote.origin.url", "git@github.com-personal:de-otio/foo.git");
170
+ assert.equal(getRemoteOrg(repo), "de-otio");
171
+ });
172
+
173
+ it("returns null when there is no origin remote", () => {
174
+ const repo = join(tmp, "remote-none");
175
+ mkdirSync(repo);
176
+ gitInit(repo);
177
+ assert.equal(getRemoteOrg(repo), null);
178
+ });
179
+
180
+ it("returns null for a non-GitHub remote", () => {
181
+ const repo = join(tmp, "remote-non-gh");
182
+ mkdirSync(repo);
183
+ gitInit(repo);
184
+ gitConfig(repo, "remote.origin.url", "https://gitlab.com/x/y.git");
185
+ assert.equal(getRemoteOrg(repo), null);
186
+ });
187
+
188
+ it("returns null when the directory is not a working tree", () => {
189
+ const dir = join(tmp, "remote-bare");
190
+ mkdirSync(dir);
191
+ assert.equal(getRemoteOrg(dir), null);
192
+ });
193
+ });
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ //
4
+ // Working-tree resolution for the path-aware PostToolUse hook.
5
+ //
6
+ // Given an arbitrary file path the agent just wrote, find the git
7
+ // working tree it lives in (regular repo, worktree, or submodule), so
8
+ // the hook can apply *that* repo's classification + deny set rather
9
+ // than the launcher's cwd.
10
+ //
11
+ // Pure-ish: stat/realpath the path's ancestors; for org resolution,
12
+ // shells out to `git config --file <wt>/.git/config` (or the worktree's
13
+ // resolved gitdir/config). Total — never throws.
14
+
15
+ import { execFileSync } from "node:child_process";
16
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { parseRemoteUrl } from "./remote-url.js";
19
+
20
+ const MAX_ANCESTOR_HOPS = 64;
21
+
22
+ /**
23
+ * Walk up from `startPath` until a directory containing `.git` (file or
24
+ * dir) is found. Returns the working tree root (the directory holding
25
+ * the `.git` entry), or `null` if none found within
26
+ * {@link MAX_ANCESTOR_HOPS} hops.
27
+ *
28
+ * Both git-worktree files (`.git` is a file containing `gitdir: ...`)
29
+ * and submodule `.git` files are recognised — the working tree is the
30
+ * directory containing the file in either case. The actual gitdir is
31
+ * resolved separately via {@link resolveGitDir}.
32
+ *
33
+ * `startPath` is realpath'd first to defeat symlink-tricks: a symlink
34
+ * inside repo A pointing to a file in repo B resolves to repo B, and
35
+ * the hook applies repo B's rules — not repo A's.
36
+ */
37
+ export function findEnclosingWorkingTree(startPath: string): string | null {
38
+ let real: string;
39
+ try {
40
+ // Resolve as much of the path as exists. If the file itself doesn't
41
+ // exist (rare in PostToolUse — the write just happened — but
42
+ // possible if the file was concurrently removed), walk up to the
43
+ // first existing ancestor and realpath that.
44
+ real = realpathExisting(startPath);
45
+ } catch {
46
+ return null;
47
+ }
48
+
49
+ let cur = statSync(real).isDirectory() ? real : dirname(real);
50
+ for (let i = 0; i < MAX_ANCESTOR_HOPS; i++) {
51
+ const dotGit = join(cur, ".git");
52
+ if (existsSync(dotGit)) return cur;
53
+ const parent = dirname(cur);
54
+ if (parent === cur) return null;
55
+ cur = parent;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function realpathExisting(p: string): string {
61
+ let cur = resolve(p);
62
+ // Walk up until we find a path that exists (or hit /).
63
+ for (let i = 0; i < MAX_ANCESTOR_HOPS; i++) {
64
+ if (existsSync(cur)) return realpathSync(cur);
65
+ const parent = dirname(cur);
66
+ if (parent === cur) throw new Error("path has no existing ancestor");
67
+ cur = parent;
68
+ }
69
+ throw new Error("path has no existing ancestor within hop budget");
70
+ }
71
+
72
+ /**
73
+ * Given a working-tree root (from {@link findEnclosingWorkingTree}),
74
+ * return the absolute path of the actual git directory.
75
+ *
76
+ * - Regular repo: `<wt>/.git` is a directory; return that.
77
+ * - Worktree / submodule: `<wt>/.git` is a file with `gitdir: <path>`;
78
+ * resolve `<path>` relative to `<wt>` and return the absolute form.
79
+ * - Anything else (`.git` missing, malformed `gitdir:` line): `null`.
80
+ *
81
+ * Used to find the right `config` file to read remotes from.
82
+ */
83
+ export function resolveGitDir(workingTree: string): string | null {
84
+ const dotGit = join(workingTree, ".git");
85
+ if (!existsSync(dotGit)) return null;
86
+ const st = statSync(dotGit);
87
+ if (st.isDirectory()) return dotGit;
88
+ if (!st.isFile()) return null;
89
+
90
+ let body: string;
91
+ try {
92
+ body = readFileSync(dotGit, "utf8");
93
+ } catch {
94
+ return null;
95
+ }
96
+ // Format: `gitdir: <path>\n`. The path may be relative to the .git
97
+ // file's directory.
98
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(body);
99
+ if (!m || !m[1]) return null;
100
+ const target = m[1];
101
+ return resolve(workingTree, target);
102
+ }
103
+
104
+ /**
105
+ * Read `remote.origin.url` from the working tree's git config and
106
+ * return the GitHub org (lowercased), or `null` if no remote, the
107
+ * remote isn't GitHub, or anything fails.
108
+ *
109
+ * Total: never throws. Used by the hook to decide whether a
110
+ * cross-tree write stays inside one trust boundary.
111
+ */
112
+ export function getRemoteOrg(workingTree: string): string | null {
113
+ const gitDir = resolveGitDir(workingTree);
114
+ if (gitDir === null) return null;
115
+ const configPath = join(gitDir, "config");
116
+ if (!existsSync(configPath)) return null;
117
+ let url: string;
118
+ try {
119
+ url = execFileSync(
120
+ "git",
121
+ ["config", "--file", configPath, "--get", "remote.origin.url"],
122
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
123
+ ).trim();
124
+ } catch {
125
+ return null;
126
+ }
127
+ if (url.length === 0) return null;
128
+ const parsed = parseRemoteUrl(url);
129
+ return parsed?.org ?? null;
130
+ }