@bookedsolid/rea 0.1.0 → 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.
- 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,347 @@
|
|
|
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
|
+
import fs from 'node:fs';
|
|
15
|
+
import fsPromises from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
export class UnsafeInstallPathError extends Error {
|
|
18
|
+
kind;
|
|
19
|
+
targetPath;
|
|
20
|
+
linkTarget;
|
|
21
|
+
constructor(kind, targetPath, linkTarget, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'UnsafeInstallPathError';
|
|
24
|
+
this.kind = kind;
|
|
25
|
+
this.targetPath = targetPath;
|
|
26
|
+
if (linkTarget !== undefined)
|
|
27
|
+
this.linkTarget = linkTarget;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Validate that `candidate` is a relative path that stays inside `resolvedRoot`.
|
|
32
|
+
* Rejects absolute paths, `..` segments, and anything that resolves outside the
|
|
33
|
+
* root. Returns the fully-resolved absolute path on success.
|
|
34
|
+
*
|
|
35
|
+
* Use this on every path that originates from user- or manifest-supplied data
|
|
36
|
+
* before it touches disk. An attacker who plants
|
|
37
|
+
* `{"path": "../../../etc/passwd"}` in `.rea/install-manifest.json` must be
|
|
38
|
+
* refused before any `unlink` / `open` / `copyFile` / `readFile` runs.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveContained(resolvedRoot, candidate) {
|
|
41
|
+
if (path.isAbsolute(candidate)) {
|
|
42
|
+
throw new UnsafeInstallPathError('escape', candidate, undefined, `refusing absolute path: ${candidate}`);
|
|
43
|
+
}
|
|
44
|
+
// Reject `..` segments on either separator, independent of OS.
|
|
45
|
+
const parts = candidate.split(/[\\/]/);
|
|
46
|
+
if (parts.includes('..')) {
|
|
47
|
+
throw new UnsafeInstallPathError('escape', candidate, undefined, `refusing path with parent-directory segments: ${candidate}`);
|
|
48
|
+
}
|
|
49
|
+
const absolute = path.resolve(resolvedRoot, candidate);
|
|
50
|
+
const rootWithSep = resolvedRoot.endsWith(path.sep)
|
|
51
|
+
? resolvedRoot
|
|
52
|
+
: resolvedRoot + path.sep;
|
|
53
|
+
if (absolute !== resolvedRoot && !absolute.startsWith(rootWithSep)) {
|
|
54
|
+
throw new UnsafeInstallPathError('escape', absolute, undefined, `refusing to resolve outside install root: ${absolute} is not under ${resolvedRoot}`);
|
|
55
|
+
}
|
|
56
|
+
return absolute;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Assert that `dstPath` resolves to a location inside `resolvedRoot` and is
|
|
60
|
+
* either absent or a regular file — never a symlink. Returns `true` if the
|
|
61
|
+
* destination already exists (regular file), `false` if absent.
|
|
62
|
+
*/
|
|
63
|
+
export async function assertSafeDestination(resolvedRoot, dstPath) {
|
|
64
|
+
const resolvedDst = path.resolve(dstPath);
|
|
65
|
+
const rootWithSep = resolvedRoot.endsWith(path.sep)
|
|
66
|
+
? resolvedRoot
|
|
67
|
+
: resolvedRoot + path.sep;
|
|
68
|
+
if (resolvedDst !== resolvedRoot && !resolvedDst.startsWith(rootWithSep)) {
|
|
69
|
+
throw new UnsafeInstallPathError('escape', resolvedDst, undefined, `refusing to write outside install root: ${resolvedDst} is not under ${resolvedRoot}`);
|
|
70
|
+
}
|
|
71
|
+
let stat;
|
|
72
|
+
try {
|
|
73
|
+
stat = await fsPromises.lstat(dstPath);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (err.code === 'ENOENT')
|
|
77
|
+
return false;
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
if (stat.isSymbolicLink()) {
|
|
81
|
+
let linkTarget = '<unreadable>';
|
|
82
|
+
try {
|
|
83
|
+
linkTarget = await fsPromises.readlink(dstPath);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* informational only */
|
|
87
|
+
}
|
|
88
|
+
throw new UnsafeInstallPathError('symlink', dstPath, linkTarget, `refusing to write through symlink at ${dstPath} → ${linkTarget}. ` +
|
|
89
|
+
`Remove the symlink manually after auditing where it points.`);
|
|
90
|
+
}
|
|
91
|
+
if (!stat.isFile()) {
|
|
92
|
+
throw new UnsafeInstallPathError('escape', dstPath, undefined, `refusing to write: ${dstPath} exists but is not a regular file (mode ${stat.mode.toString(8)})`);
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
export async function assertSafeDirectory(resolvedRoot, dirPath) {
|
|
97
|
+
const resolvedDir = path.resolve(dirPath);
|
|
98
|
+
const rootWithSep = resolvedRoot.endsWith(path.sep)
|
|
99
|
+
? resolvedRoot
|
|
100
|
+
: resolvedRoot + path.sep;
|
|
101
|
+
if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(rootWithSep)) {
|
|
102
|
+
throw new UnsafeInstallPathError('escape', resolvedDir, undefined, `refusing to operate on directory outside install root: ${resolvedDir}`);
|
|
103
|
+
}
|
|
104
|
+
let stat;
|
|
105
|
+
try {
|
|
106
|
+
stat = await fsPromises.lstat(dirPath);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (err.code === 'ENOENT')
|
|
110
|
+
return;
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
if (stat.isSymbolicLink()) {
|
|
114
|
+
let linkTarget = '<unreadable>';
|
|
115
|
+
try {
|
|
116
|
+
linkTarget = await fsPromises.readlink(dirPath);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* informational only */
|
|
120
|
+
}
|
|
121
|
+
throw new UnsafeInstallPathError('symlink', dirPath, linkTarget, `refusing to traverse symlinked directory at ${dirPath} → ${linkTarget}`);
|
|
122
|
+
}
|
|
123
|
+
if (!stat.isDirectory()) {
|
|
124
|
+
throw new UnsafeInstallPathError('escape', dirPath, undefined, `refusing to operate: ${dirPath} exists but is not a directory`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export async function snapshotAncestors(resolvedRoot, dstPath) {
|
|
128
|
+
const snapshot = new Map();
|
|
129
|
+
const rootWithSep = resolvedRoot.endsWith(path.sep)
|
|
130
|
+
? resolvedRoot
|
|
131
|
+
: resolvedRoot + path.sep;
|
|
132
|
+
const leafDir = path.dirname(path.resolve(dstPath));
|
|
133
|
+
let cursor = leafDir;
|
|
134
|
+
let reachedRoot = false;
|
|
135
|
+
while (true) {
|
|
136
|
+
let lstat;
|
|
137
|
+
try {
|
|
138
|
+
lstat = await fsPromises.lstat(cursor);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
if (lstat.isSymbolicLink()) {
|
|
144
|
+
let linkTarget = '<unreadable>';
|
|
145
|
+
try {
|
|
146
|
+
linkTarget = await fsPromises.readlink(cursor);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* informational only */
|
|
150
|
+
}
|
|
151
|
+
throw new UnsafeInstallPathError('symlink', cursor, linkTarget, `refusing to snapshot: ancestor ${cursor} is a symbolic link → ${linkTarget}. ` +
|
|
152
|
+
`Remove the symlink manually after auditing where it points.`);
|
|
153
|
+
}
|
|
154
|
+
let real;
|
|
155
|
+
try {
|
|
156
|
+
real = await fsPromises.realpath(cursor);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
if (real !== resolvedRoot && !real.startsWith(rootWithSep)) {
|
|
162
|
+
throw new UnsafeInstallPathError('escape', real, undefined, `refusing to snapshot: ancestor ${cursor} resolves to ${real}, which is outside install root ${resolvedRoot}`);
|
|
163
|
+
}
|
|
164
|
+
snapshot.set(cursor, real);
|
|
165
|
+
if (cursor === resolvedRoot) {
|
|
166
|
+
reachedRoot = true;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
const parent = path.dirname(cursor);
|
|
170
|
+
if (parent === cursor)
|
|
171
|
+
break;
|
|
172
|
+
cursor = parent;
|
|
173
|
+
}
|
|
174
|
+
if (!reachedRoot) {
|
|
175
|
+
throw new UnsafeInstallPathError('escape', leafDir, undefined, `refusing to snapshot: ancestor walk from ${leafDir} never reached install root ${resolvedRoot}`);
|
|
176
|
+
}
|
|
177
|
+
return snapshot;
|
|
178
|
+
}
|
|
179
|
+
export async function verifyAncestorsUnchanged(snapshot) {
|
|
180
|
+
for (const [ancestor, originalReal] of snapshot) {
|
|
181
|
+
let currentReal;
|
|
182
|
+
try {
|
|
183
|
+
currentReal = await fsPromises.realpath(ancestor);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
throw new UnsafeInstallPathError('ancestor-changed', ancestor, undefined, `refusing to write: ancestor directory ${ancestor} disappeared or became unreadable between validation and write (${err.code ?? 'unknown'})`);
|
|
187
|
+
}
|
|
188
|
+
if (currentReal !== originalReal) {
|
|
189
|
+
throw new UnsafeInstallPathError('ancestor-changed', ancestor, currentReal, `refusing to write: ancestor directory ${ancestor} changed between validation and write (was ${originalReal}, now ${currentReal})`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Race-safe write: `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW`. Caller must
|
|
195
|
+
* ensure the leaf does not already exist — `upgrade.ts` overwrite path
|
|
196
|
+
* `unlink`s first and then calls this.
|
|
197
|
+
*/
|
|
198
|
+
export async function writeFileExclusiveNoFollow(dstPath, contents, mode = 0o644) {
|
|
199
|
+
const flags = fs.constants.O_WRONLY |
|
|
200
|
+
fs.constants.O_CREAT |
|
|
201
|
+
fs.constants.O_EXCL |
|
|
202
|
+
fs.constants.O_NOFOLLOW;
|
|
203
|
+
const fh = await fsPromises.open(dstPath, flags, mode);
|
|
204
|
+
try {
|
|
205
|
+
await fh.writeFile(contents);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
await fh.close();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Atomic-ish safe copy: validate containment, snapshot ancestors, `unlink` any
|
|
213
|
+
* existing regular file (refuse symlinks), write via `O_NOFOLLOW|O_EXCL`, then
|
|
214
|
+
* `chmod`. Every mutation is bracketed by `verifyAncestorsUnchanged` to close
|
|
215
|
+
* the TOCTOU window on ancestor swaps.
|
|
216
|
+
*
|
|
217
|
+
* Returns the resolved absolute destination path on success.
|
|
218
|
+
*/
|
|
219
|
+
export async function safeInstallFile(opts) {
|
|
220
|
+
const dstAbs = resolveContained(opts.resolvedRoot, opts.destRelPath);
|
|
221
|
+
const exists = await assertSafeDestination(opts.resolvedRoot, dstAbs);
|
|
222
|
+
await fsPromises.mkdir(path.dirname(dstAbs), { recursive: true });
|
|
223
|
+
const ancestors = await snapshotAncestors(opts.resolvedRoot, dstAbs);
|
|
224
|
+
const contents = await fsPromises.readFile(opts.srcAbsPath);
|
|
225
|
+
if (exists) {
|
|
226
|
+
await verifyAncestorsUnchanged(ancestors);
|
|
227
|
+
await fsPromises.unlink(dstAbs);
|
|
228
|
+
}
|
|
229
|
+
await verifyAncestorsUnchanged(ancestors);
|
|
230
|
+
await writeFileExclusiveNoFollow(dstAbs, contents, opts.mode);
|
|
231
|
+
await fsPromises.chmod(dstAbs, opts.mode);
|
|
232
|
+
return dstAbs;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Safe delete: validate containment, refuse if the leaf is a symlink or not a
|
|
236
|
+
* regular file. Used by `rea upgrade` on `removed-upstream` classifications
|
|
237
|
+
* where the path comes from a manifest (attacker-controllable).
|
|
238
|
+
*/
|
|
239
|
+
export async function safeDeleteFile(resolvedRoot, destRelPath) {
|
|
240
|
+
const abs = resolveContained(resolvedRoot, destRelPath);
|
|
241
|
+
let stat;
|
|
242
|
+
try {
|
|
243
|
+
stat = await fsPromises.lstat(abs);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
if (err.code === 'ENOENT')
|
|
247
|
+
return;
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
if (stat.isSymbolicLink()) {
|
|
251
|
+
let linkTarget = '<unreadable>';
|
|
252
|
+
try {
|
|
253
|
+
linkTarget = await fsPromises.readlink(abs);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
/* informational */
|
|
257
|
+
}
|
|
258
|
+
throw new UnsafeInstallPathError('symlink', abs, linkTarget, `refusing to delete symlink at ${abs} → ${linkTarget}. Audit and remove manually.`);
|
|
259
|
+
}
|
|
260
|
+
if (!stat.isFile()) {
|
|
261
|
+
throw new UnsafeInstallPathError('escape', abs, undefined, `refusing to delete: ${abs} is not a regular file (mode ${stat.mode.toString(8)})`);
|
|
262
|
+
}
|
|
263
|
+
// Ancestors must be clean. Snapshot + re-verify to close the TOCTOU window.
|
|
264
|
+
const ancestors = await snapshotAncestors(resolvedRoot, abs);
|
|
265
|
+
await verifyAncestorsUnchanged(ancestors);
|
|
266
|
+
await fsPromises.unlink(abs);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Safe read: validate containment and that the leaf is a regular file
|
|
270
|
+
* (refuses symlinks). Used by the drift report and by upgrade's SHA readers
|
|
271
|
+
* when the path originates from the manifest.
|
|
272
|
+
*/
|
|
273
|
+
export async function safeReadFile(resolvedRoot, destRelPath) {
|
|
274
|
+
const abs = resolveContained(resolvedRoot, destRelPath);
|
|
275
|
+
let stat;
|
|
276
|
+
try {
|
|
277
|
+
stat = await fsPromises.lstat(abs);
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
if (err.code === 'ENOENT')
|
|
281
|
+
return null;
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
284
|
+
if (stat.isSymbolicLink()) {
|
|
285
|
+
throw new UnsafeInstallPathError('symlink', abs, undefined, `refusing to read symlink at ${abs}`);
|
|
286
|
+
}
|
|
287
|
+
if (!stat.isFile())
|
|
288
|
+
return null;
|
|
289
|
+
return fsPromises.readFile(abs);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Atomic tmp + rename with a three-file dance that avoids the Windows
|
|
293
|
+
* data-loss window. On POSIX, `rename(2)` replaces the destination atomically
|
|
294
|
+
* and the dance collapses. On Windows, when `rename(tmp → final)` fails with
|
|
295
|
+
* EEXIST/EPERM:
|
|
296
|
+
*
|
|
297
|
+
* 1. `rename(final → final.bak)` — preserves the previous manifest
|
|
298
|
+
* 2. `rename(tmp → final)` — installs the new one
|
|
299
|
+
* 3. On success: `unlink(final.bak)`
|
|
300
|
+
* 4. On failure: `rename(final.bak → final)` to restore; throw
|
|
301
|
+
*
|
|
302
|
+
* At every point on disk there is exactly one valid file (either `final` or
|
|
303
|
+
* `final.bak`). A crash in the middle leaves `final.bak` recoverable.
|
|
304
|
+
*/
|
|
305
|
+
export async function atomicReplaceFile(finalPath, contents) {
|
|
306
|
+
const dir = path.dirname(finalPath);
|
|
307
|
+
await fsPromises.mkdir(dir, { recursive: true });
|
|
308
|
+
const tmp = `${finalPath}.tmp`;
|
|
309
|
+
const bak = `${finalPath}.bak`;
|
|
310
|
+
const bytes = typeof contents === 'string' ? Buffer.from(contents, 'utf8') : contents;
|
|
311
|
+
await fsPromises.writeFile(tmp, bytes);
|
|
312
|
+
try {
|
|
313
|
+
await fsPromises.rename(tmp, finalPath);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
const code = err.code;
|
|
318
|
+
if (code !== 'EEXIST' && code !== 'EPERM') {
|
|
319
|
+
await fsPromises.unlink(tmp).catch(() => undefined);
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Windows replace path: preserve the current file under .bak first.
|
|
324
|
+
let movedToBak = false;
|
|
325
|
+
try {
|
|
326
|
+
await fsPromises.rename(finalPath, bak);
|
|
327
|
+
movedToBak = true;
|
|
328
|
+
await fsPromises.rename(tmp, finalPath);
|
|
329
|
+
await fsPromises.unlink(bak).catch(() => undefined);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
catch (retryErr) {
|
|
333
|
+
// Restore: if we moved the old file to .bak but failed to install the new
|
|
334
|
+
// one, put the old one back. Never leave the caller with no file at all.
|
|
335
|
+
if (movedToBak) {
|
|
336
|
+
try {
|
|
337
|
+
await fsPromises.rename(bak, finalPath);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// Best-effort. If even the restore fails, the .bak file remains on
|
|
341
|
+
// disk as a recoverable artifact.
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
await fsPromises.unlink(tmp).catch(() => undefined);
|
|
345
|
+
throw retryErr;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic read/write for `.rea/install-manifest.json` (G12).
|
|
3
|
+
*
|
|
4
|
+
* Write uses the three-file dance in `fs-safe.atomicReplaceFile` so that a
|
|
5
|
+
* Windows rename-retry never leaves the user with *no* manifest — there is
|
|
6
|
+
* always either `install-manifest.json` or `install-manifest.json.bak` on
|
|
7
|
+
* disk to recover from.
|
|
8
|
+
*/
|
|
9
|
+
import { type InstallManifest } from './manifest-schema.js';
|
|
10
|
+
export declare function manifestExists(baseDir: string): boolean;
|
|
11
|
+
export declare function readManifest(baseDir: string): Promise<InstallManifest | null>;
|
|
12
|
+
export declare function writeManifestAtomic(baseDir: string, manifest: InstallManifest): Promise<string>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic read/write for `.rea/install-manifest.json` (G12).
|
|
3
|
+
*
|
|
4
|
+
* Write uses the three-file dance in `fs-safe.atomicReplaceFile` so that a
|
|
5
|
+
* Windows rename-retry never leaves the user with *no* manifest — there is
|
|
6
|
+
* always either `install-manifest.json` or `install-manifest.json.bak` on
|
|
7
|
+
* disk to recover from.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import fsPromises from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { atomicReplaceFile } from './fs-safe.js';
|
|
13
|
+
import { InstallManifestSchema, MANIFEST_RELPATH, serializeManifest, } from './manifest-schema.js';
|
|
14
|
+
function manifestPath(baseDir) {
|
|
15
|
+
return path.join(baseDir, MANIFEST_RELPATH);
|
|
16
|
+
}
|
|
17
|
+
export function manifestExists(baseDir) {
|
|
18
|
+
return fs.existsSync(manifestPath(baseDir));
|
|
19
|
+
}
|
|
20
|
+
export async function readManifest(baseDir) {
|
|
21
|
+
const filePath = manifestPath(baseDir);
|
|
22
|
+
if (!fs.existsSync(filePath))
|
|
23
|
+
return null;
|
|
24
|
+
const raw = await fsPromises.readFile(filePath, 'utf8');
|
|
25
|
+
let parsedJson;
|
|
26
|
+
try {
|
|
27
|
+
parsedJson = JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
throw new Error(`install manifest is not valid JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
31
|
+
`Delete \`.rea/install-manifest.json\` and run \`rea upgrade\` to rebuild from current disk state.`);
|
|
32
|
+
}
|
|
33
|
+
const parsed = InstallManifestSchema.safeParse(parsedJson);
|
|
34
|
+
if (!parsed.success) {
|
|
35
|
+
throw new Error(`invalid install manifest at ${filePath}: ${parsed.error.message}. ` +
|
|
36
|
+
`Delete \`.rea/install-manifest.json\` and run \`rea upgrade\` to rebuild.`);
|
|
37
|
+
}
|
|
38
|
+
return parsed.data;
|
|
39
|
+
}
|
|
40
|
+
export async function writeManifestAtomic(baseDir, manifest) {
|
|
41
|
+
const filePath = manifestPath(baseDir);
|
|
42
|
+
await atomicReplaceFile(filePath, serializeManifest(manifest));
|
|
43
|
+
return filePath;
|
|
44
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G12 — Install manifest schema.
|
|
3
|
+
*
|
|
4
|
+
* `rea init` writes `.rea/install-manifest.json` alongside `.rea/policy.yaml`.
|
|
5
|
+
* `rea upgrade` reads it to classify each canonical shipped file as
|
|
6
|
+
* `unmodified | drifted | removed-upstream` and decide what to do. `rea doctor
|
|
7
|
+
* --drift` uses the same data.
|
|
8
|
+
*
|
|
9
|
+
* The manifest is strict (zod `.strict()`): unknown fields are rejected at load
|
|
10
|
+
* time so a newer rea version writing new fields does not silently get
|
|
11
|
+
* downgraded by an older rea.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
export declare const MANIFEST_RELPATH = ".rea/install-manifest.json";
|
|
15
|
+
export declare const SourceKindSchema: z.ZodEnum<["hook", "agent", "command", "husky", "claude-md", "settings"]>;
|
|
16
|
+
export type SourceKind = z.infer<typeof SourceKindSchema>;
|
|
17
|
+
export declare const ManifestEntrySchema: z.ZodObject<{
|
|
18
|
+
path: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
|
|
19
|
+
sha256: z.ZodString;
|
|
20
|
+
source: z.ZodEnum<["hook", "agent", "command", "husky", "claude-md", "settings"]>;
|
|
21
|
+
mode: z.ZodOptional<z.ZodNumber>;
|
|
22
|
+
}, "strict", z.ZodTypeAny, {
|
|
23
|
+
path: string;
|
|
24
|
+
sha256: string;
|
|
25
|
+
source: "command" | "hook" | "agent" | "husky" | "claude-md" | "settings";
|
|
26
|
+
mode?: number | undefined;
|
|
27
|
+
}, {
|
|
28
|
+
path: string;
|
|
29
|
+
sha256: string;
|
|
30
|
+
source: "command" | "hook" | "agent" | "husky" | "claude-md" | "settings";
|
|
31
|
+
mode?: number | undefined;
|
|
32
|
+
}>;
|
|
33
|
+
export type ManifestEntry = z.infer<typeof ManifestEntrySchema>;
|
|
34
|
+
export declare const InstallManifestSchema: z.ZodObject<{
|
|
35
|
+
version: z.ZodString;
|
|
36
|
+
profile: z.ZodString;
|
|
37
|
+
installed_at: z.ZodString;
|
|
38
|
+
upgraded_at: z.ZodOptional<z.ZodString>;
|
|
39
|
+
bootstrap: z.ZodOptional<z.ZodBoolean>;
|
|
40
|
+
files: z.ZodArray<z.ZodObject<{
|
|
41
|
+
path: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
|
|
42
|
+
sha256: z.ZodString;
|
|
43
|
+
source: z.ZodEnum<["hook", "agent", "command", "husky", "claude-md", "settings"]>;
|
|
44
|
+
mode: z.ZodOptional<z.ZodNumber>;
|
|
45
|
+
}, "strict", z.ZodTypeAny, {
|
|
46
|
+
path: string;
|
|
47
|
+
sha256: string;
|
|
48
|
+
source: "command" | "hook" | "agent" | "husky" | "claude-md" | "settings";
|
|
49
|
+
mode?: number | undefined;
|
|
50
|
+
}, {
|
|
51
|
+
path: string;
|
|
52
|
+
sha256: string;
|
|
53
|
+
source: "command" | "hook" | "agent" | "husky" | "claude-md" | "settings";
|
|
54
|
+
mode?: number | undefined;
|
|
55
|
+
}>, "many">;
|
|
56
|
+
}, "strict", z.ZodTypeAny, {
|
|
57
|
+
version: string;
|
|
58
|
+
profile: string;
|
|
59
|
+
installed_at: string;
|
|
60
|
+
files: {
|
|
61
|
+
path: string;
|
|
62
|
+
sha256: string;
|
|
63
|
+
source: "command" | "hook" | "agent" | "husky" | "claude-md" | "settings";
|
|
64
|
+
mode?: number | undefined;
|
|
65
|
+
}[];
|
|
66
|
+
upgraded_at?: string | undefined;
|
|
67
|
+
bootstrap?: boolean | undefined;
|
|
68
|
+
}, {
|
|
69
|
+
version: string;
|
|
70
|
+
profile: string;
|
|
71
|
+
installed_at: string;
|
|
72
|
+
files: {
|
|
73
|
+
path: string;
|
|
74
|
+
sha256: string;
|
|
75
|
+
source: "command" | "hook" | "agent" | "husky" | "claude-md" | "settings";
|
|
76
|
+
mode?: number | undefined;
|
|
77
|
+
}[];
|
|
78
|
+
upgraded_at?: string | undefined;
|
|
79
|
+
bootstrap?: boolean | undefined;
|
|
80
|
+
}>;
|
|
81
|
+
export type InstallManifest = z.infer<typeof InstallManifestSchema>;
|
|
82
|
+
export declare function parseManifest(raw: unknown): InstallManifest;
|
|
83
|
+
export declare function serializeManifest(manifest: InstallManifest): string;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G12 — Install manifest schema.
|
|
3
|
+
*
|
|
4
|
+
* `rea init` writes `.rea/install-manifest.json` alongside `.rea/policy.yaml`.
|
|
5
|
+
* `rea upgrade` reads it to classify each canonical shipped file as
|
|
6
|
+
* `unmodified | drifted | removed-upstream` and decide what to do. `rea doctor
|
|
7
|
+
* --drift` uses the same data.
|
|
8
|
+
*
|
|
9
|
+
* The manifest is strict (zod `.strict()`): unknown fields are rejected at load
|
|
10
|
+
* time so a newer rea version writing new fields does not silently get
|
|
11
|
+
* downgraded by an older rea.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
export const MANIFEST_RELPATH = '.rea/install-manifest.json';
|
|
15
|
+
export const SourceKindSchema = z.enum([
|
|
16
|
+
'hook',
|
|
17
|
+
'agent',
|
|
18
|
+
'command',
|
|
19
|
+
'husky',
|
|
20
|
+
'claude-md',
|
|
21
|
+
'settings',
|
|
22
|
+
]);
|
|
23
|
+
const Sha256Hex = z.string().regex(/^[a-f0-9]{64}$/, 'expected lowercase hex sha256');
|
|
24
|
+
/**
|
|
25
|
+
* Path validation for manifest entries. The manifest is attacker-controllable
|
|
26
|
+
* (it lives in `.rea/install-manifest.json`, a regular repo file that a
|
|
27
|
+
* compromised PR could mutate), so every entry path must be:
|
|
28
|
+
*
|
|
29
|
+
* - relative (no leading `/` or drive letter)
|
|
30
|
+
* - free of `..` segments on either separator
|
|
31
|
+
* - free of ASCII control characters (including `\x1b` terminal escapes
|
|
32
|
+
* that could corrupt a doctor/upgrade report display)
|
|
33
|
+
* - either a canonical install path (plain relative path) or one of two
|
|
34
|
+
* synthetic entries: `CLAUDE.md#rea:managed:v1`,
|
|
35
|
+
* `.claude/settings.json#rea:desired`
|
|
36
|
+
*
|
|
37
|
+
* Absolute paths, parent-directory segments, and control chars all throw at
|
|
38
|
+
* load time — before any write or delete runs against the entry.
|
|
39
|
+
*/
|
|
40
|
+
const ManifestPath = z
|
|
41
|
+
.string()
|
|
42
|
+
.min(1)
|
|
43
|
+
.refine((p) => !/[\x00-\x1f\x7f]/.test(p), 'path contains control characters')
|
|
44
|
+
.refine((p) => {
|
|
45
|
+
// `#` is allowed only for the two synthetic entries (see canonical.ts).
|
|
46
|
+
// Everything else must be a clean relative path with no `#` and no
|
|
47
|
+
// absolute-path leading characters.
|
|
48
|
+
if (p.includes('#'))
|
|
49
|
+
return true;
|
|
50
|
+
if (/^[A-Za-z]:[\\/]/.test(p))
|
|
51
|
+
return false; // windows drive letter
|
|
52
|
+
if (p.startsWith('/') || p.startsWith('\\'))
|
|
53
|
+
return false;
|
|
54
|
+
const parts = p.split(/[\\/]/);
|
|
55
|
+
return !parts.includes('..');
|
|
56
|
+
}, 'path must be relative and must not contain `..` segments');
|
|
57
|
+
export const ManifestEntrySchema = z
|
|
58
|
+
.object({
|
|
59
|
+
path: ManifestPath,
|
|
60
|
+
sha256: Sha256Hex,
|
|
61
|
+
source: SourceKindSchema,
|
|
62
|
+
mode: z.number().int().nonnegative().optional(),
|
|
63
|
+
})
|
|
64
|
+
.strict();
|
|
65
|
+
export const InstallManifestSchema = z
|
|
66
|
+
.object({
|
|
67
|
+
version: z.string().min(1),
|
|
68
|
+
profile: z.string().min(1),
|
|
69
|
+
installed_at: z.string().min(1),
|
|
70
|
+
upgraded_at: z.string().min(1).optional(),
|
|
71
|
+
bootstrap: z.boolean().optional(),
|
|
72
|
+
files: z.array(ManifestEntrySchema),
|
|
73
|
+
})
|
|
74
|
+
.strict();
|
|
75
|
+
export function parseManifest(raw) {
|
|
76
|
+
return InstallManifestSchema.parse(raw);
|
|
77
|
+
}
|
|
78
|
+
export function serializeManifest(manifest) {
|
|
79
|
+
return JSON.stringify(manifest, null, 2) + '\n';
|
|
80
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translate a `.reagent/policy.yaml` into a `.rea/policy.yaml`-shaped payload.
|
|
3
|
+
*
|
|
4
|
+
* ## Explicit contract
|
|
5
|
+
*
|
|
6
|
+
* Reagent had a broader policy schema than rea. Most top-level fields either
|
|
7
|
+
* transfer directly (same semantics) or are dropped because their governance
|
|
8
|
+
* model changed. Dropping a security-relevant field silently would downgrade a
|
|
9
|
+
* guarantee the user already expected — that is forbidden.
|
|
10
|
+
*
|
|
11
|
+
* Fields fall into one of three lists:
|
|
12
|
+
*
|
|
13
|
+
* - **copy list**: copy verbatim into the rea policy.
|
|
14
|
+
* - **drop list**: SECURITY-RELEVANT fields that were removed or restructured
|
|
15
|
+
* in rea. If any drop-list field is present in the input policy, this
|
|
16
|
+
* function refuses to translate unless `acceptDropped === true`.
|
|
17
|
+
* - **ignore list**: non-governance fields (metadata, project name, notes)
|
|
18
|
+
* that are simply not written to the rea policy. No warning emitted.
|
|
19
|
+
*
|
|
20
|
+
* ## Autonomy clamping
|
|
21
|
+
*
|
|
22
|
+
* If the reagent policy's `max_autonomy_level` exceeds the chosen profile's
|
|
23
|
+
* ceiling, we clamp down to the profile ceiling and record a notice. A reagent
|
|
24
|
+
* install that allowed L3 cannot silently survive a migration into an
|
|
25
|
+
* `open-source` profile capped at L2.
|
|
26
|
+
*/
|
|
27
|
+
import { AutonomyLevel } from '../../policy/types.js';
|
|
28
|
+
import { type Profile } from '../../policy/profiles.js';
|
|
29
|
+
export interface TranslateOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Upper bound on `max_autonomy_level`. Profile ceiling (typically L2).
|
|
32
|
+
* If the reagent file declares a higher ceiling, we clamp and warn.
|
|
33
|
+
*/
|
|
34
|
+
profileCeiling: AutonomyLevel;
|
|
35
|
+
/** Set by `--accept-dropped-fields` on the CLI. */
|
|
36
|
+
acceptDropped: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface TranslateResult {
|
|
39
|
+
translated: Profile;
|
|
40
|
+
notices: string[];
|
|
41
|
+
droppedFields: string[];
|
|
42
|
+
clampedAutonomy: boolean;
|
|
43
|
+
}
|
|
44
|
+
export declare class ReagentDroppedFieldsError extends Error {
|
|
45
|
+
readonly dropped: string[];
|
|
46
|
+
constructor(dropped: string[]);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Translate the reagent policy at `reagentPath`, enforcing drop-list rules.
|
|
50
|
+
*
|
|
51
|
+
* @throws {ReagentDroppedFieldsError} if drop-list fields are present and
|
|
52
|
+
* `acceptDropped` is false.
|
|
53
|
+
*/
|
|
54
|
+
export declare function translateReagentPolicy(reagentPath: string, options: TranslateOptions): TranslateResult;
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the default reagent policy path inside a target directory.
|
|
57
|
+
* Convenience for the CLI's `--from-reagent` flag.
|
|
58
|
+
*/
|
|
59
|
+
export declare function defaultReagentPath(targetDir: string): string;
|