@bookedsolid/rea 0.1.0 → 0.2.1

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 (90) hide show
  1. package/.husky/commit-msg +130 -0
  2. package/.husky/pre-push +128 -0
  3. package/README.md +5 -5
  4. package/agents/codex-adversarial.md +23 -8
  5. package/commands/codex-review.md +2 -2
  6. package/dist/audit/append.d.ts +62 -0
  7. package/dist/audit/append.js +189 -0
  8. package/dist/audit/codex-event.d.ts +28 -0
  9. package/dist/audit/codex-event.js +15 -0
  10. package/dist/cli/doctor.d.ts +60 -1
  11. package/dist/cli/doctor.js +459 -20
  12. package/dist/cli/index.js +35 -5
  13. package/dist/cli/init.d.ts +13 -0
  14. package/dist/cli/init.js +278 -67
  15. package/dist/cli/install/canonical.d.ts +43 -0
  16. package/dist/cli/install/canonical.js +101 -0
  17. package/dist/cli/install/claude-md.d.ts +48 -0
  18. package/dist/cli/install/claude-md.js +93 -0
  19. package/dist/cli/install/commit-msg.d.ts +30 -0
  20. package/dist/cli/install/commit-msg.js +102 -0
  21. package/dist/cli/install/copy.d.ts +169 -0
  22. package/dist/cli/install/copy.js +455 -0
  23. package/dist/cli/install/fs-safe.d.ts +91 -0
  24. package/dist/cli/install/fs-safe.js +347 -0
  25. package/dist/cli/install/manifest-io.d.ts +12 -0
  26. package/dist/cli/install/manifest-io.js +44 -0
  27. package/dist/cli/install/manifest-schema.d.ts +83 -0
  28. package/dist/cli/install/manifest-schema.js +80 -0
  29. package/dist/cli/install/reagent.d.ts +59 -0
  30. package/dist/cli/install/reagent.js +160 -0
  31. package/dist/cli/install/settings-merge.d.ts +91 -0
  32. package/dist/cli/install/settings-merge.js +239 -0
  33. package/dist/cli/install/sha.d.ts +9 -0
  34. package/dist/cli/install/sha.js +21 -0
  35. package/dist/cli/serve.d.ts +11 -0
  36. package/dist/cli/serve.js +72 -6
  37. package/dist/cli/upgrade.d.ts +67 -0
  38. package/dist/cli/upgrade.js +509 -0
  39. package/dist/gateway/downstream-pool.d.ts +39 -0
  40. package/dist/gateway/downstream-pool.js +93 -0
  41. package/dist/gateway/downstream.d.ts +80 -0
  42. package/dist/gateway/downstream.js +196 -0
  43. package/dist/gateway/middleware/audit-types.d.ts +10 -0
  44. package/dist/gateway/middleware/audit.js +14 -0
  45. package/dist/gateway/middleware/injection.d.ts +59 -2
  46. package/dist/gateway/middleware/injection.js +91 -14
  47. package/dist/gateway/middleware/kill-switch.d.ts +20 -5
  48. package/dist/gateway/middleware/kill-switch.js +57 -35
  49. package/dist/gateway/middleware/redact.d.ts +83 -6
  50. package/dist/gateway/middleware/redact.js +133 -46
  51. package/dist/gateway/observability/codex-probe.d.ts +110 -0
  52. package/dist/gateway/observability/codex-probe.js +234 -0
  53. package/dist/gateway/observability/codex-telemetry.d.ts +93 -0
  54. package/dist/gateway/observability/codex-telemetry.js +221 -0
  55. package/dist/gateway/redact-safe/match-timeout.d.ts +83 -0
  56. package/dist/gateway/redact-safe/match-timeout.js +179 -0
  57. package/dist/gateway/reviewers/claude-self.d.ts +99 -0
  58. package/dist/gateway/reviewers/claude-self.js +316 -0
  59. package/dist/gateway/reviewers/codex.d.ts +64 -0
  60. package/dist/gateway/reviewers/codex.js +80 -0
  61. package/dist/gateway/reviewers/select.d.ts +64 -0
  62. package/dist/gateway/reviewers/select.js +102 -0
  63. package/dist/gateway/reviewers/types.d.ts +85 -0
  64. package/dist/gateway/reviewers/types.js +14 -0
  65. package/dist/gateway/server.d.ts +51 -0
  66. package/dist/gateway/server.js +258 -0
  67. package/dist/gateway/session.d.ts +9 -0
  68. package/dist/gateway/session.js +17 -0
  69. package/dist/policy/loader.d.ts +59 -0
  70. package/dist/policy/loader.js +65 -0
  71. package/dist/policy/profiles.d.ts +80 -0
  72. package/dist/policy/profiles.js +94 -0
  73. package/dist/policy/types.d.ts +38 -0
  74. package/dist/registry/loader.d.ts +98 -0
  75. package/dist/registry/loader.js +153 -0
  76. package/dist/registry/types.d.ts +44 -0
  77. package/dist/registry/types.js +6 -0
  78. package/dist/scripts/read-policy-field.d.ts +36 -0
  79. package/dist/scripts/read-policy-field.js +96 -0
  80. package/hooks/push-review-gate.sh +627 -17
  81. package/package.json +13 -2
  82. package/profiles/bst-internal-no-codex.yaml +40 -0
  83. package/profiles/bst-internal.yaml +23 -0
  84. package/profiles/client-engagement.yaml +23 -0
  85. package/profiles/lit-wc.yaml +17 -0
  86. package/profiles/minimal.yaml +11 -0
  87. package/profiles/open-source-no-codex.yaml +33 -0
  88. package/profiles/open-source.yaml +18 -0
  89. package/scripts/lint-safe-regex.mjs +78 -0
  90. package/scripts/postinstall.mjs +131 -0
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Copy hooks, commands, and agents from the installed package into a consumer's
3
+ * `.claude/` directory. This is the core of what makes `rea init` a real
4
+ * installer rather than a policy-file writer.
5
+ *
6
+ * Conflict policy:
7
+ *
8
+ * - `--force` (boolean): overwrite unconditionally. Reserved for power users
9
+ * who have intentionally modified their local `.claude/` and know they want
10
+ * fresh-from-package versions back.
11
+ * - `--yes` (boolean): non-interactive. Skips existing files — NEVER silently
12
+ * replaces a consumer-modified hook. This is the safe default for CI.
13
+ * - Default (interactive): prompts per conflict via `@clack/prompts`.
14
+ *
15
+ * Hook scripts are chmod'd to 0o755 so the shell hooks the harness fires can
16
+ * actually execute on a fresh clone.
17
+ *
18
+ * ## Symlink safety (finding #5)
19
+ *
20
+ * A prior malicious PR could leave a symlink at a destination path (e.g.
21
+ * `.claude/hooks/secret-scanner.sh` → `/etc/shadow`). Node's `copyFile` and
22
+ * `chmod` follow symlinks, so a subsequent `rea init --force` would overwrite
23
+ * the link target and chmod it 0o755. We defend in multiple layers:
24
+ *
25
+ * 1. Resolve the install root with `realpath` once per run.
26
+ * 2. Before any write, `lstat` the destination and REFUSE (hard error, named
27
+ * file + link target) if it is a symlink. The presence of a symlink is a
28
+ * signal worth surfacing to the operator — we do not silently rewrite it.
29
+ * 3. For every destination path, resolve it and assert containment within the
30
+ * resolved root. Anything escaping the root is refused.
31
+ *
32
+ * On overwrite we use `openSync(O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW)` and
33
+ * write the bytes ourselves — `O_EXCL` makes the create race-safe, and
34
+ * `O_NOFOLLOW` refuses any symlink that sneaks in between the lstat and the
35
+ * write. On fresh creates we use the same flags for the same reason.
36
+ *
37
+ * ## Parent-directory TOCTOU (finding R2-4)
38
+ *
39
+ * `assertSafeDirectory` validates a path *string*. A concurrent attacker with
40
+ * write access under the install tree could swap `.claude/hooks` for a symlink
41
+ * in the window between validation and the subsequent `copyFile`/`unlink`. To
42
+ * close this window:
43
+ *
44
+ * 1. After validating the install root, snapshot the realpath of every
45
+ * ancestor directory between the destination and the root.
46
+ * 2. Immediately before every mutation (`unlink`, then the file write), we
47
+ * re-realpath the same ancestors and refuse if any changed. This closes
48
+ * the practical exploit window to sub-millisecond.
49
+ * 3. `O_NOFOLLOW` on the write call catches a symlink that slips in at the
50
+ * leaf between the re-check and the `open` syscall.
51
+ *
52
+ * Residual risk: a race that wins between the re-realpath loop and the `open`
53
+ * syscall on the leaf still exists, but the `O_NOFOLLOW | O_EXCL` open will
54
+ * refuse any symlink that lands in that micro-window. Fully closing the
55
+ * ancestor-swap race would require dirfd-relative APIs (`openat`) which Node's
56
+ * core `fs` module does not expose. Documented; not a code-execution primitive.
57
+ *
58
+ * ## Ancestor baseline integrity (finding R3-1)
59
+ *
60
+ * `O_NOFOLLOW` only protects the leaf component of the open syscall — an
61
+ * attacker who swaps an intermediate directory for a symlink pointing outside
62
+ * the install root between `assertSafeDirectory` and the per-file `copyOne`
63
+ * call would otherwise get the snapshot to record the attacker's state as
64
+ * baseline. `verifyAncestorsUnchanged` would then pass (the symlink is
65
+ * stable) and the write would land wherever the symlink points.
66
+ *
67
+ * `snapshotAncestors` closes that primitive by (a) refusing any ancestor that
68
+ * is itself a symlink (`lstat`), (b) re-asserting containment via `realpath`
69
+ * at every level, and (c) requiring the walk to terminate at `resolvedRoot`.
70
+ * Those checks run before any write-side syscall; an escape attempt surfaces
71
+ * as `UnsafeInstallPathError` with `kind: 'symlink'` or `'escape'`.
72
+ */
73
+ import fs from 'node:fs';
74
+ import fsPromises from 'node:fs/promises';
75
+ import path from 'node:path';
76
+ import * as p from '@clack/prompts';
77
+ import { PKG_ROOT, warn } from '../utils.js';
78
+ /** Subdirectory names under `.claude/` that we manage. */
79
+ const COPY_DIRS = ['hooks', 'commands', 'agents'];
80
+ /**
81
+ * Thrown when a destination path is a symlink, escapes the install root, or a
82
+ * previously-validated ancestor directory changed shape between validation and
83
+ * the write (finding R2-4). Kept as a named class so callers (and tests) can
84
+ * match the shape without scraping the message.
85
+ */
86
+ export class UnsafeInstallPathError extends Error {
87
+ kind;
88
+ targetPath;
89
+ linkTarget;
90
+ constructor(kind, targetPath, linkTarget, message) {
91
+ super(message);
92
+ this.name = 'UnsafeInstallPathError';
93
+ this.kind = kind;
94
+ this.targetPath = targetPath;
95
+ if (linkTarget !== undefined)
96
+ this.linkTarget = linkTarget;
97
+ }
98
+ }
99
+ function relClaude(targetDir, absPath) {
100
+ return path.relative(targetDir, absPath);
101
+ }
102
+ async function decideConflict(relPath, options) {
103
+ if (options.force)
104
+ return 'overwrite';
105
+ if (options.yes)
106
+ return 'skip';
107
+ const answer = await p.select({
108
+ message: `${relPath} already exists — overwrite?`,
109
+ initialValue: 'skip',
110
+ options: [
111
+ { value: 'skip', label: 'skip', hint: 'keep existing file' },
112
+ { value: 'overwrite', label: 'overwrite', hint: 'replace with packaged version' },
113
+ ],
114
+ });
115
+ if (p.isCancel(answer))
116
+ return 'skip';
117
+ return answer;
118
+ }
119
+ async function ensureDir(dir) {
120
+ await fsPromises.mkdir(dir, { recursive: true });
121
+ }
122
+ /**
123
+ * Assert that `dstPath` resolves to a location inside `resolvedRoot` and is
124
+ * either absent or a regular file — never a symlink. Throws
125
+ * `UnsafeInstallPathError` with a clear diagnostic on any violation.
126
+ *
127
+ * Returns `true` if the destination already exists (regular file), `false` if
128
+ * it is absent. Any other shape (symlink, directory, device) → throw.
129
+ */
130
+ async function assertSafeDestination(resolvedRoot, dstPath) {
131
+ // Containment: resolve without following symlinks on the leaf so an attacker
132
+ // cannot smuggle us out via a symlink in the leaf itself.
133
+ const resolvedDst = path.resolve(dstPath);
134
+ const rootWithSep = resolvedRoot.endsWith(path.sep)
135
+ ? resolvedRoot
136
+ : resolvedRoot + path.sep;
137
+ if (resolvedDst !== resolvedRoot && !resolvedDst.startsWith(rootWithSep)) {
138
+ throw new UnsafeInstallPathError('escape', resolvedDst, undefined, `refusing to write outside install root: ${resolvedDst} is not under ${resolvedRoot}`);
139
+ }
140
+ let stat;
141
+ try {
142
+ stat = await fsPromises.lstat(dstPath);
143
+ }
144
+ catch (err) {
145
+ if (err.code === 'ENOENT')
146
+ return false;
147
+ throw err;
148
+ }
149
+ if (stat.isSymbolicLink()) {
150
+ let linkTarget = '<unreadable>';
151
+ try {
152
+ linkTarget = await fsPromises.readlink(dstPath);
153
+ }
154
+ catch {
155
+ // readlink can fail on broken or permission-restricted links; we still
156
+ // refuse the write but the target string is informational only.
157
+ }
158
+ throw new UnsafeInstallPathError('symlink', dstPath, linkTarget, `refusing to write through symlink at ${dstPath} → ${linkTarget}. ` +
159
+ `Remove the symlink manually after auditing where it points.`);
160
+ }
161
+ if (!stat.isFile()) {
162
+ throw new UnsafeInstallPathError('escape', dstPath, undefined, `refusing to write: ${dstPath} exists but is not a regular file (mode ${stat.mode.toString(8)})`);
163
+ }
164
+ return true;
165
+ }
166
+ /**
167
+ * Guard a directory path the same way we guard files: the directory itself
168
+ * must not be a symlink, and it must live inside the install root. We don't
169
+ * demand it already exists — `ensureDir` handles that. We only demand that if
170
+ * it does exist, it's a real directory owned by the tree we're writing into.
171
+ */
172
+ async function assertSafeDirectory(resolvedRoot, dirPath) {
173
+ const resolvedDir = path.resolve(dirPath);
174
+ const rootWithSep = resolvedRoot.endsWith(path.sep)
175
+ ? resolvedRoot
176
+ : resolvedRoot + path.sep;
177
+ if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(rootWithSep)) {
178
+ throw new UnsafeInstallPathError('escape', resolvedDir, undefined, `refusing to operate on directory outside install root: ${resolvedDir}`);
179
+ }
180
+ let stat;
181
+ try {
182
+ stat = await fsPromises.lstat(dirPath);
183
+ }
184
+ catch (err) {
185
+ if (err.code === 'ENOENT')
186
+ return;
187
+ throw err;
188
+ }
189
+ if (stat.isSymbolicLink()) {
190
+ let linkTarget = '<unreadable>';
191
+ try {
192
+ linkTarget = await fsPromises.readlink(dirPath);
193
+ }
194
+ catch {
195
+ /* informational only */
196
+ }
197
+ throw new UnsafeInstallPathError('symlink', dirPath, linkTarget, `refusing to traverse symlinked directory at ${dirPath} → ${linkTarget}`);
198
+ }
199
+ if (!stat.isDirectory()) {
200
+ throw new UnsafeInstallPathError('escape', dirPath, undefined, `refusing to operate: ${dirPath} exists but is not a directory`);
201
+ }
202
+ }
203
+ /**
204
+ * Snapshot the realpath of every ancestor directory between `dstPath` and
205
+ * `resolvedRoot` (inclusive of the root, exclusive of the leaf). The resulting
206
+ * map — `absolute ancestor path → realpath at snapshot time` — is later
207
+ * re-validated by {@link verifyAncestorsUnchanged} immediately before each
208
+ * mutation. If any entry has changed, the tree was swapped and we refuse.
209
+ *
210
+ * We deliberately skip the leaf: the leaf's safety is handled by the
211
+ * `lstat` check in `assertSafeDestination` and by `O_NOFOLLOW` on the open.
212
+ *
213
+ * ## Defense against ancestor escape (finding R3-1)
214
+ *
215
+ * The snapshot itself must not be allowed to record an attacker-controlled
216
+ * baseline. Without the checks below, an attacker who swaps an intermediate
217
+ * directory for a symlink to `/tmp/decoy` between `assertSafeDirectory` and
218
+ * `copyOne` could get the snapshot to record `.claude/hooks → /tmp/decoy` as
219
+ * the trusted state — {@link verifyAncestorsUnchanged} would then pass, and
220
+ * `writeFileExclusiveNoFollow` would land the payload outside the install
221
+ * root (O_NOFOLLOW only guards the leaf, not ancestor components).
222
+ *
223
+ * To close that primitive, for every ancestor we:
224
+ *
225
+ * 1. `lstat` the component. If it is a symbolic link, refuse — ancestor
226
+ * symlinks inside the install tree are never legitimate for us to walk
227
+ * through, regardless of where they point.
228
+ * 2. `realpath` the component and assert containment within `resolvedRoot`.
229
+ * Anything pointing outside is an attempted escape.
230
+ * 3. Require the walk to terminate at `resolvedRoot`. If the cursor reaches
231
+ * the filesystem root without passing through `resolvedRoot`, the
232
+ * destination was never under the install root to begin with — that
233
+ * means `assertSafeDestination` was bypassed and we refuse loudly.
234
+ */
235
+ async function snapshotAncestors(resolvedRoot, dstPath) {
236
+ const snapshot = new Map();
237
+ const rootWithSep = resolvedRoot.endsWith(path.sep)
238
+ ? resolvedRoot
239
+ : resolvedRoot + path.sep;
240
+ const leafDir = path.dirname(path.resolve(dstPath));
241
+ let cursor = leafDir;
242
+ let reachedRoot = false;
243
+ // Walk up until we pass the root. Every ancestor must resolve to something
244
+ // inside the root (including the root itself).
245
+ // path.parse('/').root === '/', so cursor === path.dirname(cursor) only at FS root.
246
+ while (true) {
247
+ // (1) Ancestor must not itself be a symbolic link. An attacker-planted
248
+ // symlink in the middle of the install tree is refused at snapshot time
249
+ // rather than accepted as baseline.
250
+ let lstat;
251
+ try {
252
+ lstat = await fsPromises.lstat(cursor);
253
+ }
254
+ catch (err) {
255
+ // ENOENT is acceptable only if `ensureDir` has not yet created the
256
+ // directory. We only snapshot dirs the caller has already ensured
257
+ // exist, so any error is a real problem — surface it.
258
+ throw err;
259
+ }
260
+ if (lstat.isSymbolicLink()) {
261
+ let linkTarget = '<unreadable>';
262
+ try {
263
+ linkTarget = await fsPromises.readlink(cursor);
264
+ }
265
+ catch {
266
+ /* informational only */
267
+ }
268
+ throw new UnsafeInstallPathError('symlink', cursor, linkTarget, `refusing to snapshot: ancestor ${cursor} is a symbolic link → ${linkTarget}. ` +
269
+ `Remove the symlink manually after auditing where it points.`);
270
+ }
271
+ // (2) Resolve and assert containment. Anything that resolves outside
272
+ // `resolvedRoot` is a confirmed escape primitive.
273
+ let real;
274
+ try {
275
+ real = await fsPromises.realpath(cursor);
276
+ }
277
+ catch (err) {
278
+ throw err;
279
+ }
280
+ if (real !== resolvedRoot && !real.startsWith(rootWithSep)) {
281
+ throw new UnsafeInstallPathError('escape', real, undefined, `refusing to snapshot: ancestor ${cursor} resolves to ${real}, which is outside install root ${resolvedRoot}`);
282
+ }
283
+ snapshot.set(cursor, real);
284
+ if (cursor === resolvedRoot) {
285
+ reachedRoot = true;
286
+ break;
287
+ }
288
+ const parent = path.dirname(cursor);
289
+ if (parent === cursor)
290
+ break; // reached filesystem root without hitting resolvedRoot
291
+ cursor = parent;
292
+ }
293
+ // (3) The walk must terminate at `resolvedRoot`. If we fell off the top of
294
+ // the filesystem without hitting the install root, the destination was
295
+ // never actually inside the install tree — `assertSafeDestination` was
296
+ // bypassed (bug), or the caller supplied a hostile path. Either way, refuse.
297
+ if (!reachedRoot) {
298
+ throw new UnsafeInstallPathError('escape', leafDir, undefined, `refusing to snapshot: ancestor walk from ${leafDir} never reached install root ${resolvedRoot}`);
299
+ }
300
+ return snapshot;
301
+ }
302
+ /**
303
+ * Re-realpath every ancestor captured in `snapshot` and throw
304
+ * {@link UnsafeInstallPathError} if any resolution has changed (an intermediate
305
+ * directory was replaced with a symlink, renamed, etc.) or disappeared.
306
+ */
307
+ async function verifyAncestorsUnchanged(snapshot) {
308
+ for (const [ancestor, originalReal] of snapshot) {
309
+ let currentReal;
310
+ try {
311
+ currentReal = await fsPromises.realpath(ancestor);
312
+ }
313
+ catch (err) {
314
+ throw new UnsafeInstallPathError('ancestor-changed', ancestor, undefined, `refusing to write: ancestor directory ${ancestor} disappeared or became unreadable between validation and write (${err.code ?? 'unknown'})`);
315
+ }
316
+ if (currentReal !== originalReal) {
317
+ throw new UnsafeInstallPathError('ancestor-changed', ancestor, currentReal, `refusing to write: ancestor directory ${ancestor} changed between validation and write (was ${originalReal}, now ${currentReal})`);
318
+ }
319
+ }
320
+ }
321
+ /**
322
+ * Write `srcPath`'s bytes to `dstPath` using `openSync(O_WRONLY | O_CREAT |
323
+ * O_EXCL | O_NOFOLLOW)`. This is the race-safe replacement for `copyFile` —
324
+ *
325
+ * - `O_EXCL`: the open fails with EEXIST if anything appears at the leaf
326
+ * between our pre-check and this call, including a symlink or regular file.
327
+ * - `O_NOFOLLOW`: the open fails with ELOOP if the leaf itself is a symlink
328
+ * that somehow bypassed EXCL (belt-and-suspenders; on most platforms EXCL
329
+ * alone is sufficient).
330
+ *
331
+ * Reads the source via the fs/promises API so we inherit standard error shapes.
332
+ * Source-side read follows symlinks, which is fine: `srcPath` is inside PKG_ROOT
333
+ * and the source tree is trusted.
334
+ */
335
+ async function writeFileExclusiveNoFollow(srcPath, dstPath) {
336
+ const contents = await fsPromises.readFile(srcPath);
337
+ const flags = fs.constants.O_WRONLY |
338
+ fs.constants.O_CREAT |
339
+ fs.constants.O_EXCL |
340
+ fs.constants.O_NOFOLLOW;
341
+ const fh = await fsPromises.open(dstPath, flags, 0o644);
342
+ try {
343
+ await fh.writeFile(contents);
344
+ }
345
+ finally {
346
+ await fh.close();
347
+ }
348
+ }
349
+ async function walkAndCopy(sourceRoot, destRoot, dirName, targetDir, options, result, ctx) {
350
+ const src = path.join(sourceRoot, dirName);
351
+ const dst = path.join(destRoot, dirName);
352
+ if (!fs.existsSync(src)) {
353
+ warn(`packaged directory missing: ${src} — skipping ${dirName} copy`);
354
+ return;
355
+ }
356
+ await assertSafeDirectory(ctx.resolvedRoot, dst);
357
+ await ensureDir(dst);
358
+ const entries = await fsPromises.readdir(src, { withFileTypes: true });
359
+ for (const entry of entries) {
360
+ const srcPath = path.join(src, entry.name);
361
+ const dstPath = path.join(dst, entry.name);
362
+ const relPath = relClaude(targetDir, dstPath);
363
+ if (entry.isDirectory()) {
364
+ // Recurse into subdirectories (e.g. hooks/_lib/).
365
+ await assertSafeDirectory(ctx.resolvedRoot, dstPath);
366
+ await ensureDir(dstPath);
367
+ const subEntries = await fsPromises.readdir(srcPath, { withFileTypes: true });
368
+ for (const sub of subEntries) {
369
+ const subSrc = path.join(srcPath, sub.name);
370
+ const subDst = path.join(dstPath, sub.name);
371
+ if (sub.isDirectory()) {
372
+ // Two levels of recursion is enough for the current layout; anything
373
+ // deeper is a design smell worth failing loudly on.
374
+ warn(`nested directory beyond depth 2 ignored: ${subSrc}`);
375
+ continue;
376
+ }
377
+ await copyOne(subSrc, subDst, relClaude(targetDir, subDst), dirName, options, result, ctx);
378
+ }
379
+ continue;
380
+ }
381
+ await copyOne(srcPath, dstPath, relPath, dirName, options, result, ctx);
382
+ }
383
+ }
384
+ async function copyOne(srcPath, dstPath, relPath, dirName, options, result, ctx) {
385
+ // Symlink + containment check. Throws UnsafeInstallPathError on violation —
386
+ // we let it propagate so the caller (`rea init`) prints a hard error and
387
+ // exits non-zero. Recovering silently would defeat the signal.
388
+ const exists = await assertSafeDestination(ctx.resolvedRoot, dstPath);
389
+ // Snapshot every ancestor directory between the destination and the install
390
+ // root (finding R2-4). We re-verify this snapshot immediately before each
391
+ // mutation to shrink the parent-directory TOCTOU window to sub-millisecond.
392
+ const ancestorSnapshot = await snapshotAncestors(ctx.resolvedRoot, dstPath);
393
+ if (exists) {
394
+ const decision = await decideConflict(relPath, options);
395
+ if (decision === 'skip') {
396
+ result.skipped.push(relPath);
397
+ return;
398
+ }
399
+ // Overwrite path: re-verify ancestors, then unlink, then re-verify again
400
+ // before writing. Writes use O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW so
401
+ // any symlink/file that slips in between the unlink and the open fails
402
+ // the syscall rather than letting us clobber something we shouldn't.
403
+ await verifyAncestorsUnchanged(ancestorSnapshot);
404
+ await fsPromises.unlink(dstPath);
405
+ await verifyAncestorsUnchanged(ancestorSnapshot);
406
+ await writeFileExclusiveNoFollow(srcPath, dstPath);
407
+ if (dirName === 'hooks')
408
+ await fsPromises.chmod(dstPath, 0o755);
409
+ result.overwritten.push(relPath);
410
+ return;
411
+ }
412
+ // Fresh create: re-verify ancestors, then open with EXCL|NOFOLLOW. EXCL
413
+ // guarantees we fail if something appeared at the leaf; NOFOLLOW guarantees
414
+ // we fail if the leaf is a symlink.
415
+ await verifyAncestorsUnchanged(ancestorSnapshot);
416
+ await writeFileExclusiveNoFollow(srcPath, dstPath);
417
+ if (dirName === 'hooks')
418
+ await fsPromises.chmod(dstPath, 0o755);
419
+ result.copied.push(relPath);
420
+ }
421
+ /**
422
+ * Copy hooks/commands/agents from the package root into `${targetDir}/.claude/`.
423
+ *
424
+ * Caller is responsible for ensuring `targetDir` is a real directory — this
425
+ * function creates `.claude/` and the three subdirectories if missing.
426
+ *
427
+ * Throws {@link UnsafeInstallPathError} if any destination is a symlink or
428
+ * would escape the resolved install root. The caller should surface this as a
429
+ * named failure and exit non-zero; do not wrap-and-swallow.
430
+ */
431
+ export async function copyArtifacts(targetDir, options) {
432
+ // Resolve the install root up front — `realpath` so a symlinked targetDir
433
+ // (e.g. `/tmp` on macOS → `/private/tmp`) still produces a correct
434
+ // containment root.
435
+ const resolvedTarget = await fsPromises.realpath(targetDir);
436
+ const claudeDir = path.join(resolvedTarget, '.claude');
437
+ await assertSafeDirectory(resolvedTarget, claudeDir);
438
+ await ensureDir(claudeDir);
439
+ const ctx = { resolvedRoot: resolvedTarget };
440
+ const result = { copied: [], skipped: [], overwritten: [] };
441
+ for (const dir of COPY_DIRS) {
442
+ await walkAndCopy(PKG_ROOT, claudeDir, dir, resolvedTarget, options, result, ctx);
443
+ }
444
+ return result;
445
+ }
446
+ /**
447
+ * Internal helpers exposed for unit tests only. Not part of the public API —
448
+ * do not import from outside `./copy.test.ts`. Grouped under `__internal` so
449
+ * consumers can grep and stay away.
450
+ */
451
+ export const __internal = {
452
+ snapshotAncestors,
453
+ verifyAncestorsUnchanged,
454
+ writeFileExclusiveNoFollow,
455
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Shared safe-filesystem primitives for install-time writes (G12).
3
+ *
4
+ * Extracted from `copy.ts` so `upgrade.ts` inherits the same defenses:
5
+ * - path containment (destinations must live inside a resolved root)
6
+ * - symlink refusal (never write through a link — `rea init` originally
7
+ * defended against a malicious PR planting `.claude/hooks/x → /etc/shadow`)
8
+ * - parent-directory TOCTOU: `snapshotAncestors` + `verifyAncestorsUnchanged`
9
+ * - leaf-level race safety via `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW`
10
+ *
11
+ * Every mutation in `copy.ts` and `upgrade.ts` must go through helpers here.
12
+ * Adding a new write path and *not* using these helpers is a security bug.
13
+ */
14
+ export declare class UnsafeInstallPathError extends Error {
15
+ readonly kind: 'symlink' | 'escape' | 'ancestor-changed';
16
+ readonly targetPath: string;
17
+ readonly linkTarget?: string;
18
+ constructor(kind: 'symlink' | 'escape' | 'ancestor-changed', targetPath: string, linkTarget: string | undefined, message: string);
19
+ }
20
+ /**
21
+ * Validate that `candidate` is a relative path that stays inside `resolvedRoot`.
22
+ * Rejects absolute paths, `..` segments, and anything that resolves outside the
23
+ * root. Returns the fully-resolved absolute path on success.
24
+ *
25
+ * Use this on every path that originates from user- or manifest-supplied data
26
+ * before it touches disk. An attacker who plants
27
+ * `{"path": "../../../etc/passwd"}` in `.rea/install-manifest.json` must be
28
+ * refused before any `unlink` / `open` / `copyFile` / `readFile` runs.
29
+ */
30
+ export declare function resolveContained(resolvedRoot: string, candidate: string): string;
31
+ /**
32
+ * Assert that `dstPath` resolves to a location inside `resolvedRoot` and is
33
+ * either absent or a regular file — never a symlink. Returns `true` if the
34
+ * destination already exists (regular file), `false` if absent.
35
+ */
36
+ export declare function assertSafeDestination(resolvedRoot: string, dstPath: string): Promise<boolean>;
37
+ export declare function assertSafeDirectory(resolvedRoot: string, dirPath: string): Promise<void>;
38
+ export declare function snapshotAncestors(resolvedRoot: string, dstPath: string): Promise<Map<string, string>>;
39
+ export declare function verifyAncestorsUnchanged(snapshot: Map<string, string>): Promise<void>;
40
+ /**
41
+ * Race-safe write: `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW`. Caller must
42
+ * ensure the leaf does not already exist — `upgrade.ts` overwrite path
43
+ * `unlink`s first and then calls this.
44
+ */
45
+ export declare function writeFileExclusiveNoFollow(dstPath: string, contents: Buffer, mode?: number): Promise<void>;
46
+ export interface SafeWriteOptions {
47
+ /** Absolute path to the canonical source file. Reads follow symlinks (trusted). */
48
+ srcAbsPath: string;
49
+ /** Resolved install root (from `fs.realpath(targetDir)`). */
50
+ resolvedRoot: string;
51
+ /** Destination relative to `resolvedRoot`. Validated for containment. */
52
+ destRelPath: string;
53
+ /** Mode to chmod the destination to after writing. */
54
+ mode: number;
55
+ }
56
+ /**
57
+ * Atomic-ish safe copy: validate containment, snapshot ancestors, `unlink` any
58
+ * existing regular file (refuse symlinks), write via `O_NOFOLLOW|O_EXCL`, then
59
+ * `chmod`. Every mutation is bracketed by `verifyAncestorsUnchanged` to close
60
+ * the TOCTOU window on ancestor swaps.
61
+ *
62
+ * Returns the resolved absolute destination path on success.
63
+ */
64
+ export declare function safeInstallFile(opts: SafeWriteOptions): Promise<string>;
65
+ /**
66
+ * Safe delete: validate containment, refuse if the leaf is a symlink or not a
67
+ * regular file. Used by `rea upgrade` on `removed-upstream` classifications
68
+ * where the path comes from a manifest (attacker-controllable).
69
+ */
70
+ export declare function safeDeleteFile(resolvedRoot: string, destRelPath: string): Promise<void>;
71
+ /**
72
+ * Safe read: validate containment and that the leaf is a regular file
73
+ * (refuses symlinks). Used by the drift report and by upgrade's SHA readers
74
+ * when the path originates from the manifest.
75
+ */
76
+ export declare function safeReadFile(resolvedRoot: string, destRelPath: string): Promise<Buffer | null>;
77
+ /**
78
+ * Atomic tmp + rename with a three-file dance that avoids the Windows
79
+ * data-loss window. On POSIX, `rename(2)` replaces the destination atomically
80
+ * and the dance collapses. On Windows, when `rename(tmp → final)` fails with
81
+ * EEXIST/EPERM:
82
+ *
83
+ * 1. `rename(final → final.bak)` — preserves the previous manifest
84
+ * 2. `rename(tmp → final)` — installs the new one
85
+ * 3. On success: `unlink(final.bak)`
86
+ * 4. On failure: `rename(final.bak → final)` to restore; throw
87
+ *
88
+ * At every point on disk there is exactly one valid file (either `final` or
89
+ * `final.bak`). A crash in the middle leaves `final.bak` recoverable.
90
+ */
91
+ export declare function atomicReplaceFile(finalPath: string, contents: string | Buffer): Promise<void>;