@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,151 @@
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 { mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import lockfile from "proper-lockfile";
9
+ import { withLock, withLockSync } from "./lock.js";
10
+ import { LockTimeoutError } from "./exceptions.js";
11
+
12
+ let tmp: string;
13
+
14
+ before(() => {
15
+ tmp = mkdtempSync(join(tmpdir(), "repo-aegis-lock-test-"));
16
+ });
17
+
18
+ after(() => {
19
+ rmSync(tmp, { recursive: true, force: true });
20
+ });
21
+
22
+ function lockPathFor(name: string): string {
23
+ const p = join(tmp, name);
24
+ writeFileSync(p, "");
25
+ return p;
26
+ }
27
+
28
+ describe("withLockSync", () => {
29
+ it("runs fn while holding the lock and returns its result", () => {
30
+ const lp = lockPathFor("happy.lock");
31
+ const result = withLockSync(() => 42, { lockPath: lp });
32
+ assert.equal(result, 42);
33
+ });
34
+
35
+ it("releases the lock even when fn throws", () => {
36
+ const lp = lockPathFor("throw.lock");
37
+ assert.throws(() => withLockSync(() => { throw new Error("boom"); }, { lockPath: lp }), /boom/);
38
+ // Should be able to acquire it again immediately.
39
+ const r = withLockSync(() => "ok", { lockPath: lp });
40
+ assert.equal(r, "ok");
41
+ });
42
+
43
+ it("creates the lock file if missing", () => {
44
+ const lp = join(tmp, "auto-create.lock");
45
+ assert.equal(existsSync(lp), false);
46
+ withLockSync(() => 1, { lockPath: lp });
47
+ assert.ok(existsSync(lp));
48
+ });
49
+
50
+ it("throws LockTimeoutError when another process holds the lock", () => {
51
+ const lp = lockPathFor("contended.lock");
52
+ const release = lockfile.lockSync(lp, { stale: 30_000 });
53
+ try {
54
+ assert.throws(() => withLockSync(() => 1, { lockPath: lp }), LockTimeoutError);
55
+ } finally {
56
+ release();
57
+ }
58
+ });
59
+ });
60
+
61
+ describe("withLock (async)", () => {
62
+ it("runs fn and returns its result", async () => {
63
+ const lp = lockPathFor("async-happy.lock");
64
+ const result = await withLock(() => 99, { lockPath: lp });
65
+ assert.equal(result, 99);
66
+ });
67
+
68
+ it("supports async fn", async () => {
69
+ const lp = lockPathFor("async-fn.lock");
70
+ const result = await withLock(async () => {
71
+ await new Promise(r => setTimeout(r, 5));
72
+ return "done";
73
+ }, { lockPath: lp });
74
+ assert.equal(result, "done");
75
+ });
76
+
77
+ it("releases the lock when fn rejects", async () => {
78
+ const lp = lockPathFor("async-reject.lock");
79
+ await assert.rejects(withLock(async () => { throw new Error("x"); }, { lockPath: lp }), /x/);
80
+ const r = await withLock(() => "ok", { lockPath: lp });
81
+ assert.equal(r, "ok");
82
+ });
83
+
84
+ it("serializes two parallel withLock calls against the same lockPath", async () => {
85
+ // Two concurrent withLock calls on the same path must NOT overlap.
86
+ // We record entry/exit timestamps for each and assert one's interval
87
+ // fully precedes the other.
88
+ const lp = lockPathFor("async-serialize.lock");
89
+ const HOLD_MS = 60;
90
+
91
+ interface Span { start: number; end: number; label: string }
92
+ const spans: Span[] = [];
93
+
94
+ async function critical(label: string): Promise<Span> {
95
+ return withLock(async () => {
96
+ const start = Date.now();
97
+ await new Promise(r => setTimeout(r, HOLD_MS));
98
+ const end = Date.now();
99
+ const span: Span = { start, end, label };
100
+ spans.push(span);
101
+ return span;
102
+ }, { lockPath: lp, timeoutMs: 5000 });
103
+ }
104
+
105
+ const [a, b] = await Promise.all([critical("a"), critical("b")]);
106
+ // Sort by start time and assert non-overlap.
107
+ const sorted = [a, b].sort((x, y) => x.start - y.start);
108
+ assert.ok(
109
+ sorted[0]!.end <= sorted[1]!.start,
110
+ `expected serialized intervals; got a=[${a.start},${a.end}] b=[${b.start},${b.end}]`,
111
+ );
112
+ assert.equal(spans.length, 2);
113
+ });
114
+
115
+ it("throws LockTimeoutError when the path is held by lockfile.lockSync", async () => {
116
+ const lp = lockPathFor("async-contended.lock");
117
+ const release = lockfile.lockSync(lp, { stale: 30_000 });
118
+ try {
119
+ await assert.rejects(
120
+ withLock(() => 1, { lockPath: lp, timeoutMs: 100 }),
121
+ LockTimeoutError,
122
+ );
123
+ } finally {
124
+ release();
125
+ }
126
+ });
127
+
128
+ it("honours timeoutMs (rejects within the timeout window)", async () => {
129
+ const lp = lockPathFor("async-timeout.lock");
130
+ const release = lockfile.lockSync(lp, { stale: 30_000 });
131
+ try {
132
+ const t0 = Date.now();
133
+ await assert.rejects(
134
+ withLock(() => 1, { lockPath: lp, timeoutMs: 100 }),
135
+ LockTimeoutError,
136
+ );
137
+ const elapsed = Date.now() - t0;
138
+ // proper-lockfile retry math + jitter: a 100ms timeout typically
139
+ // settles in well under 500ms. Generous upper bound to avoid CI
140
+ // flake on busy runners; the lower bound asserts we waited at all
141
+ // (i.e. that timeoutMs wasn't ignored entirely and rejected
142
+ // immediately at the very first retry).
143
+ assert.ok(
144
+ elapsed < 1500,
145
+ `withLock should reject promptly under timeoutMs=100; elapsed=${elapsed}ms`,
146
+ );
147
+ } finally {
148
+ release();
149
+ }
150
+ });
151
+ });
package/src/lock.ts ADDED
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { dirname } from "node:path";
5
+ import lockfile from "proper-lockfile";
6
+ import { lockFilePath } from "./paths.js";
7
+ import { LockTimeoutError } from "./exceptions.js";
8
+
9
+ export interface LockOptions {
10
+ /** ms to wait for the lock before throwing LockTimeoutError. Default 5000. */
11
+ timeoutMs?: number;
12
+ /** Override the lock target file. Default: lockFilePath() under the repo-aegis home. */
13
+ lockPath?: string;
14
+ }
15
+
16
+ /**
17
+ * Run `fn` while holding the registry lock. Synchronous-friendly: `fn`
18
+ * may be sync or async; returns the function's return value. The lock
19
+ * is released even if `fn` throws.
20
+ *
21
+ * Stale locks (process died) are auto-cleared by proper-lockfile after
22
+ * 30s.
23
+ */
24
+ export async function withLock<T>(fn: () => T | Promise<T>, opts: LockOptions = {}): Promise<T> {
25
+ const path = opts.lockPath ?? lockFilePath();
26
+ const dir = dirname(path);
27
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
28
+ // proper-lockfile locks the *file* — needs to exist.
29
+ if (!existsSync(path)) writeFileSync(path, "");
30
+
31
+ const timeout = opts.timeoutMs ?? 5000;
32
+ let release: () => Promise<void>;
33
+ try {
34
+ release = await lockfile.lock(path, {
35
+ stale: 30_000,
36
+ retries: { retries: 10, factor: 1.5, minTimeout: 50, maxTimeout: timeout, randomize: true },
37
+ });
38
+ } catch (err) {
39
+ const code = (err as { code?: string }).code;
40
+ if (code === "ELOCKED" || code === "ENOTACQUIRED") {
41
+ throw new LockTimeoutError(path);
42
+ }
43
+ throw err;
44
+ }
45
+
46
+ try {
47
+ return await fn();
48
+ } finally {
49
+ try {
50
+ await release();
51
+ } catch {
52
+ /* lock already released or compromised; nothing useful to do here */
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Synchronous variant. Used by code paths that can't easily go async
59
+ * (e.g. existing CLI commands). proper-lockfile's lockSync exists for
60
+ * this case.
61
+ */
62
+ export function withLockSync<T>(fn: () => T, opts: LockOptions = {}): T {
63
+ const path = opts.lockPath ?? lockFilePath();
64
+ const dir = dirname(path);
65
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
66
+ if (!existsSync(path)) writeFileSync(path, "");
67
+
68
+ let release: () => void;
69
+ try {
70
+ release = lockfile.lockSync(path, { stale: 30_000 });
71
+ } catch (err) {
72
+ const code = (err as { code?: string }).code;
73
+ if (code === "ELOCKED" || code === "ENOTACQUIRED") {
74
+ throw new LockTimeoutError(path);
75
+ }
76
+ throw err;
77
+ }
78
+
79
+ try {
80
+ return fn();
81
+ } finally {
82
+ try {
83
+ release();
84
+ } catch {
85
+ /* lock already released or compromised */
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,94 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ import { describe, it, beforeEach, afterEach } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import {
6
+ repoAegisHome,
7
+ registryPath,
8
+ markersDir,
9
+ flatMarkersPath,
10
+ statePath,
11
+ leakContextFlagPath,
12
+ lockFilePath,
13
+ isHomeOverridden,
14
+ } from "./paths.js";
15
+
16
+ let originalHome: string | undefined;
17
+ let originalRegistry: string | undefined;
18
+ let originalMarkersDir: string | undefined;
19
+
20
+ beforeEach(() => {
21
+ originalHome = process.env["REPO_AEGIS_HOME"];
22
+ originalRegistry = process.env["REPO_AEGIS_REGISTRY"];
23
+ originalMarkersDir = process.env["REPO_AEGIS_MARKERS_DIR"];
24
+ delete process.env["REPO_AEGIS_HOME"];
25
+ delete process.env["REPO_AEGIS_REGISTRY"];
26
+ delete process.env["REPO_AEGIS_MARKERS_DIR"];
27
+ });
28
+
29
+ afterEach(() => {
30
+ if (originalHome !== undefined) process.env["REPO_AEGIS_HOME"] = originalHome;
31
+ else delete process.env["REPO_AEGIS_HOME"];
32
+ if (originalRegistry !== undefined) process.env["REPO_AEGIS_REGISTRY"] = originalRegistry;
33
+ else delete process.env["REPO_AEGIS_REGISTRY"];
34
+ if (originalMarkersDir !== undefined) process.env["REPO_AEGIS_MARKERS_DIR"] = originalMarkersDir;
35
+ else delete process.env["REPO_AEGIS_MARKERS_DIR"];
36
+ });
37
+
38
+ describe("paths", () => {
39
+ it("default home is ~/.config/repo-aegis", () => {
40
+ const h = repoAegisHome();
41
+ assert.match(h, /\.config\/repo-aegis$/);
42
+ });
43
+
44
+ it("REPO_AEGIS_HOME env overrides home", () => {
45
+ process.env["REPO_AEGIS_HOME"] = "/tmp/custom";
46
+ assert.equal(repoAegisHome(), "/tmp/custom");
47
+ });
48
+
49
+ it("REPO_AEGIS_REGISTRY overrides registry path", () => {
50
+ process.env["REPO_AEGIS_REGISTRY"] = "/tmp/custom-registry.yaml";
51
+ assert.equal(registryPath(), "/tmp/custom-registry.yaml");
52
+ });
53
+
54
+ it("registryPath defaults to home/engagements.yaml", () => {
55
+ process.env["REPO_AEGIS_HOME"] = "/tmp/x";
56
+ assert.equal(registryPath(), "/tmp/x/engagements.yaml");
57
+ });
58
+
59
+ it("REPO_AEGIS_MARKERS_DIR overrides markers path", () => {
60
+ process.env["REPO_AEGIS_MARKERS_DIR"] = "/tmp/custom-markers";
61
+ assert.equal(markersDir(), "/tmp/custom-markers");
62
+ });
63
+
64
+ it("markersDir defaults to home/markers", () => {
65
+ process.env["REPO_AEGIS_HOME"] = "/tmp/x";
66
+ assert.equal(markersDir(), "/tmp/x/markers");
67
+ });
68
+
69
+ it("flatMarkersPath is home/markers.txt", () => {
70
+ process.env["REPO_AEGIS_HOME"] = "/tmp/x";
71
+ assert.equal(flatMarkersPath(), "/tmp/x/markers.txt");
72
+ });
73
+
74
+ it("statePath is home/state", () => {
75
+ process.env["REPO_AEGIS_HOME"] = "/tmp/x";
76
+ assert.equal(statePath(), "/tmp/x/state");
77
+ });
78
+
79
+ it("leakContextFlagPath is home/state/leak-context-mode", () => {
80
+ process.env["REPO_AEGIS_HOME"] = "/tmp/x";
81
+ assert.equal(leakContextFlagPath(), "/tmp/x/state/leak-context-mode");
82
+ });
83
+
84
+ it("lockFilePath is home/state/.lock", () => {
85
+ process.env["REPO_AEGIS_HOME"] = "/tmp/x";
86
+ assert.equal(lockFilePath(), "/tmp/x/state/.lock");
87
+ });
88
+
89
+ it("isHomeOverridden reflects env var presence", () => {
90
+ assert.equal(isHomeOverridden(), false);
91
+ process.env["REPO_AEGIS_HOME"] = "/tmp/x";
92
+ assert.equal(isHomeOverridden(), true);
93
+ });
94
+ });
package/src/paths.ts ADDED
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export function repoAegisHome(): string {
7
+ return process.env["REPO_AEGIS_HOME"] ?? join(homedir(), ".config", "repo-aegis");
8
+ }
9
+
10
+ export function registryPath(home: string = repoAegisHome()): string {
11
+ return process.env["REPO_AEGIS_REGISTRY"] ?? join(home, "engagements.yaml");
12
+ }
13
+
14
+ export function markersDir(home: string = repoAegisHome()): string {
15
+ return process.env["REPO_AEGIS_MARKERS_DIR"] ?? join(home, "markers");
16
+ }
17
+
18
+ export function flatMarkersPath(home: string = repoAegisHome()): string {
19
+ return join(home, "markers.txt");
20
+ }
21
+
22
+ export function statePath(home: string = repoAegisHome()): string {
23
+ return join(home, "state");
24
+ }
25
+
26
+ export function leakContextFlagPath(home: string = repoAegisHome()): string {
27
+ return join(statePath(home), "leak-context-mode");
28
+ }
29
+
30
+ export function lockFilePath(home: string = repoAegisHome()): string {
31
+ return join(statePath(home), ".lock");
32
+ }
33
+
34
+ export function denySetCachePath(home: string = repoAegisHome()): string {
35
+ return join(statePath(home), "deny-set.cache.json");
36
+ }
37
+
38
+ /**
39
+ * Path of the operator audit log. JSON Lines, append-only, chmod 600.
40
+ * Off by default; opted into via `~/.config/repo-aegis/state/audit-log.json`
41
+ * (`{ "enabled": true }`). See `audit-log.ts` for the writer and
42
+ * `cli/commands/audit-log.ts` for the on/off/show/path subcommands.
43
+ */
44
+ export function auditLogPath(home: string = repoAegisHome()): string {
45
+ return join(statePath(home), "audit.log");
46
+ }
47
+
48
+ /**
49
+ * True if `REPO_AEGIS_HOME` is set in the environment, indicating the user
50
+ * (or a parent process) has overridden the default home. CLI uses this to
51
+ * print a stderr warning that the override is in effect.
52
+ */
53
+ export function isHomeOverridden(): boolean {
54
+ return process.env["REPO_AEGIS_HOME"] !== undefined;
55
+ }
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { redactMatch, revealMatch } from "./redaction.js";
6
+
7
+ describe("redactMatch", () => {
8
+ it("preview mode: shows first 3 chars + length for long strings", () => {
9
+ assert.equal(redactMatch("acme-corp", "preview"), "acm***9");
10
+ assert.equal(redactMatch("betaco", "preview"), "bet***6");
11
+ });
12
+
13
+ it("preview mode: redacts short strings entirely", () => {
14
+ assert.equal(redactMatch("abc", "preview"), "[redacted]");
15
+ assert.equal(redactMatch("ab", "preview"), "[redacted]");
16
+ assert.equal(redactMatch("", "preview"), "[redacted]");
17
+ });
18
+
19
+ it("preview mode is the default", () => {
20
+ assert.equal(redactMatch("acme-corp"), "acm***9");
21
+ });
22
+
23
+ it("hash mode: returns hex hash + length, deterministic", () => {
24
+ const a = redactMatch("acme-corp", "hash");
25
+ const b = redactMatch("acme-corp", "hash");
26
+ assert.equal(a, b);
27
+ assert.match(a, /^\[hash:[a-f0-9]{8}:9\]$/);
28
+ });
29
+
30
+ it("hash mode: distinct inputs produce distinct outputs", () => {
31
+ assert.notEqual(
32
+ redactMatch("acme-corp", "hash"),
33
+ redactMatch("betaco", "hash"),
34
+ );
35
+ });
36
+
37
+ it("position-only mode: always [redacted]", () => {
38
+ assert.equal(redactMatch("acme-corp", "position-only"), "[redacted]");
39
+ assert.equal(redactMatch("anything", "position-only"), "[redacted]");
40
+ });
41
+
42
+ it("never includes the literal in any redaction mode", () => {
43
+ const literal = "veryspecificcustomername";
44
+ const modes = ["preview", "hash", "position-only"] as const;
45
+ for (const m of modes) {
46
+ const out = redactMatch(literal, m);
47
+ assert.ok(!out.includes(literal), `mode ${m} leaked literal`);
48
+ }
49
+ });
50
+
51
+ it("handles unicode without crashing", () => {
52
+ assert.ok(redactMatch("éâô-corp", "preview").length > 0);
53
+ assert.ok(redactMatch("中文corp", "hash").length > 0);
54
+ });
55
+
56
+ it("preview mode does not split surrogate pairs", () => {
57
+ // 🔒 is U+1F512, a surrogate pair in UTF-16. .slice(0,3) on the raw
58
+ // string would split the surrogate; code-point iteration must not.
59
+ const s = "🔒🔑🔓abcd";
60
+ const out = redactMatch(s, "preview");
61
+ // Length is 7 code points; preview returns the first 3 code points + ***N
62
+ assert.match(out, /^🔒🔑🔓\*\*\*7$/);
63
+ });
64
+
65
+ it("preview mode counts length in code points, not UTF-16 units", () => {
66
+ const s = "🔒🔑🔓"; // 3 code points, 6 UTF-16 units
67
+ assert.equal(redactMatch(s, "preview"), "[redacted]"); // length 3 < 4
68
+ });
69
+ });
70
+
71
+ describe("revealMatch", () => {
72
+ it("returns the literal", () => {
73
+ assert.equal(revealMatch("acme-corp"), "acme-corp");
74
+ });
75
+
76
+ it("is the only function that returns the literal", () => {
77
+ // Documentary test: enforces by convention that revealMatch is the
78
+ // single point of literal exposure in the redaction module.
79
+ assert.equal(typeof revealMatch, "function");
80
+ });
81
+ });
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ import { createHash } from "node:crypto";
4
+
5
+ export type RedactionMode = "preview" | "hash" | "position-only";
6
+
7
+ /**
8
+ * Redact a matched marker string for safe display.
9
+ *
10
+ * Default mode `preview` returns `<first-3-chars>***<length-N>` for matches
11
+ * of length >= 4, else `[redacted]`. The literal match value never appears
12
+ * in output that flows to AI agent context (PostToolUse hook output, error
13
+ * messages, JSON payloads), because the literal match in recent context is
14
+ * exactly the recency-pressure failure mode the tool exists to prevent.
15
+ *
16
+ * `hash` mode returns the first 8 hex chars of SHA-256 + length, useful
17
+ * when distinct hits should be distinguishable from each other but the
18
+ * value itself remains opaque.
19
+ *
20
+ * `position-only` returns the constant `[redacted]`.
21
+ */
22
+ export function redactMatch(match: string, mode: RedactionMode = "preview"): string {
23
+ // Use code-point iteration to avoid splitting surrogate pairs in the
24
+ // first 3 characters of multi-byte unicode markers.
25
+ const codePoints = Array.from(match);
26
+ if (mode === "position-only") return "[redacted]";
27
+ if (mode === "hash") {
28
+ const h = createHash("sha256").update(match).digest("hex").slice(0, 8);
29
+ return `[hash:${h}:${codePoints.length}]`;
30
+ }
31
+ // preview
32
+ if (codePoints.length < 4) return "[redacted]";
33
+ const head = codePoints.slice(0, 3).join("");
34
+ return `${head}***${codePoints.length}`;
35
+ }
36
+
37
+ /**
38
+ * Pass-through; explicit "this is the literal".
39
+ *
40
+ * Should ONLY be called when the user has explicitly opted in via the
41
+ * `--verbose` CLI flag. Hooks must never pass through this function.
42
+ *
43
+ * The previous `REPO_AEGIS_REVEAL_MATCHES` env-var was removed: env
44
+ * vars propagate to subprocess hooks unintentionally and could cause
45
+ * literal markers to flow into AI tool-result context.
46
+ */
47
+ export function revealMatch(match: string): string {
48
+ return match;
49
+ }