@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.
- package/.husky/commit-msg +130 -0
- package/.husky/pre-push +128 -0
- package/README.md +5 -5
- package/agents/codex-adversarial.md +23 -8
- package/commands/codex-review.md +2 -2
- package/dist/audit/append.d.ts +62 -0
- package/dist/audit/append.js +189 -0
- package/dist/audit/codex-event.d.ts +28 -0
- package/dist/audit/codex-event.js +15 -0
- package/dist/cli/doctor.d.ts +60 -1
- package/dist/cli/doctor.js +459 -20
- package/dist/cli/index.js +35 -5
- package/dist/cli/init.d.ts +13 -0
- package/dist/cli/init.js +278 -67
- package/dist/cli/install/canonical.d.ts +43 -0
- package/dist/cli/install/canonical.js +101 -0
- package/dist/cli/install/claude-md.d.ts +48 -0
- package/dist/cli/install/claude-md.js +93 -0
- package/dist/cli/install/commit-msg.d.ts +30 -0
- package/dist/cli/install/commit-msg.js +102 -0
- package/dist/cli/install/copy.d.ts +169 -0
- package/dist/cli/install/copy.js +455 -0
- package/dist/cli/install/fs-safe.d.ts +91 -0
- package/dist/cli/install/fs-safe.js +347 -0
- package/dist/cli/install/manifest-io.d.ts +12 -0
- package/dist/cli/install/manifest-io.js +44 -0
- package/dist/cli/install/manifest-schema.d.ts +83 -0
- package/dist/cli/install/manifest-schema.js +80 -0
- package/dist/cli/install/reagent.d.ts +59 -0
- package/dist/cli/install/reagent.js +160 -0
- package/dist/cli/install/settings-merge.d.ts +91 -0
- package/dist/cli/install/settings-merge.js +239 -0
- package/dist/cli/install/sha.d.ts +9 -0
- package/dist/cli/install/sha.js +21 -0
- package/dist/cli/serve.d.ts +11 -0
- package/dist/cli/serve.js +72 -6
- package/dist/cli/upgrade.d.ts +67 -0
- package/dist/cli/upgrade.js +509 -0
- package/dist/gateway/downstream-pool.d.ts +39 -0
- package/dist/gateway/downstream-pool.js +93 -0
- package/dist/gateway/downstream.d.ts +80 -0
- package/dist/gateway/downstream.js +196 -0
- package/dist/gateway/middleware/audit-types.d.ts +10 -0
- package/dist/gateway/middleware/audit.js +14 -0
- package/dist/gateway/middleware/injection.d.ts +59 -2
- package/dist/gateway/middleware/injection.js +91 -14
- package/dist/gateway/middleware/kill-switch.d.ts +20 -5
- package/dist/gateway/middleware/kill-switch.js +57 -35
- package/dist/gateway/middleware/redact.d.ts +83 -6
- package/dist/gateway/middleware/redact.js +133 -46
- package/dist/gateway/observability/codex-probe.d.ts +110 -0
- package/dist/gateway/observability/codex-probe.js +234 -0
- package/dist/gateway/observability/codex-telemetry.d.ts +93 -0
- package/dist/gateway/observability/codex-telemetry.js +221 -0
- package/dist/gateway/redact-safe/match-timeout.d.ts +83 -0
- package/dist/gateway/redact-safe/match-timeout.js +179 -0
- package/dist/gateway/reviewers/claude-self.d.ts +99 -0
- package/dist/gateway/reviewers/claude-self.js +316 -0
- package/dist/gateway/reviewers/codex.d.ts +64 -0
- package/dist/gateway/reviewers/codex.js +80 -0
- package/dist/gateway/reviewers/select.d.ts +64 -0
- package/dist/gateway/reviewers/select.js +102 -0
- package/dist/gateway/reviewers/types.d.ts +85 -0
- package/dist/gateway/reviewers/types.js +14 -0
- package/dist/gateway/server.d.ts +51 -0
- package/dist/gateway/server.js +258 -0
- package/dist/gateway/session.d.ts +9 -0
- package/dist/gateway/session.js +17 -0
- package/dist/policy/loader.d.ts +59 -0
- package/dist/policy/loader.js +65 -0
- package/dist/policy/profiles.d.ts +80 -0
- package/dist/policy/profiles.js +94 -0
- package/dist/policy/types.d.ts +38 -0
- package/dist/registry/loader.d.ts +98 -0
- package/dist/registry/loader.js +153 -0
- package/dist/registry/types.d.ts +44 -0
- package/dist/registry/types.js +6 -0
- package/dist/scripts/read-policy-field.d.ts +36 -0
- package/dist/scripts/read-policy-field.js +96 -0
- package/hooks/push-review-gate.sh +627 -17
- package/package.json +13 -2
- package/profiles/bst-internal-no-codex.yaml +40 -0
- package/profiles/bst-internal.yaml +23 -0
- package/profiles/client-engagement.yaml +23 -0
- package/profiles/lit-wc.yaml +17 -0
- package/profiles/minimal.yaml +11 -0
- package/profiles/open-source-no-codex.yaml +33 -0
- package/profiles/open-source.yaml +18 -0
- package/scripts/lint-safe-regex.mjs +78 -0
- 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>;
|