@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,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G12 — `rea upgrade`.
|
|
3
|
+
*
|
|
4
|
+
* Classify every canonical shipped file against the consumer's installed copy
|
|
5
|
+
* and the last manifest entry, then act:
|
|
6
|
+
*
|
|
7
|
+
* - NEW (not in manifest, not on disk) → install the canonical version.
|
|
8
|
+
* - UNMODIFIED (on-disk SHA matches manifest SHA) → silently overwrite with
|
|
9
|
+
* canonical version. The consumer never changed it; they get updates for
|
|
10
|
+
* free.
|
|
11
|
+
* - DRIFTED (on-disk SHA ≠ manifest SHA) → interactive prompt:
|
|
12
|
+
* keep | overwrite | diff (show unified diff, then re-prompt)
|
|
13
|
+
* Non-interactive (`--yes`) defaults to KEEP (safe). `--force` defaults
|
|
14
|
+
* to OVERWRITE and skips the prompt.
|
|
15
|
+
* - REMOVED-UPSTREAM (in manifest, no longer canonical) → prompt to delete.
|
|
16
|
+
* Non-interactive defaults to SKIP; `--force` deletes.
|
|
17
|
+
*
|
|
18
|
+
* After processing, the manifest is rewritten with fresh SHAs + `upgraded_at`.
|
|
19
|
+
*
|
|
20
|
+
* Bootstrap path: if no manifest is found, we record current on-disk SHAs
|
|
21
|
+
* as the baseline and mark `bootstrap: true`. This gives pre-G12 installs a
|
|
22
|
+
* manifest without pretending we know what was originally shipped. The NEXT
|
|
23
|
+
* `rea upgrade` compares against canonical normally.
|
|
24
|
+
*
|
|
25
|
+
* Dogfood caveat: running `rea upgrade` on this repo via a Claude Code
|
|
26
|
+
* session will be blocked by `settings-protection.sh` (`.claude/hooks/*`,
|
|
27
|
+
* `.claude/settings.json`, `.husky/*` all protected from Write/Edit). Invoke
|
|
28
|
+
* `rea upgrade` directly from a terminal outside the Claude Code session.
|
|
29
|
+
* The `rea upgrade` code itself performs writes via node `fs` calls which
|
|
30
|
+
* are not hook-gated — but a Claude Code-hosted Bash invocation is. This is
|
|
31
|
+
* intentional: upgrade is an authorized-human action by design.
|
|
32
|
+
*
|
|
33
|
+
* Security note: every on-disk mutation flows through `safeInstallFile` or
|
|
34
|
+
* `safeDeleteFile` in `install/fs-safe.ts`. Path values that originate from
|
|
35
|
+
* `.rea/install-manifest.json` (attacker-controllable) are validated at
|
|
36
|
+
* schema-load time AND re-validated at each filesystem call. See
|
|
37
|
+
* `install/fs-safe.ts` header for the full TOCTOU argument.
|
|
38
|
+
*/
|
|
39
|
+
import { type CanonicalFile } from './install/canonical.js';
|
|
40
|
+
import { type ManifestEntry } from './install/manifest-schema.js';
|
|
41
|
+
export interface UpgradeOptions {
|
|
42
|
+
dryRun?: boolean | undefined;
|
|
43
|
+
yes?: boolean | undefined;
|
|
44
|
+
force?: boolean | undefined;
|
|
45
|
+
}
|
|
46
|
+
type Classification = {
|
|
47
|
+
kind: 'new';
|
|
48
|
+
canonical: CanonicalFile;
|
|
49
|
+
canonicalSha: string;
|
|
50
|
+
} | {
|
|
51
|
+
kind: 'unmodified';
|
|
52
|
+
canonical: CanonicalFile;
|
|
53
|
+
canonicalSha: string;
|
|
54
|
+
localSha: string;
|
|
55
|
+
entry: ManifestEntry;
|
|
56
|
+
} | {
|
|
57
|
+
kind: 'drifted';
|
|
58
|
+
canonical: CanonicalFile;
|
|
59
|
+
canonicalSha: string;
|
|
60
|
+
localSha: string;
|
|
61
|
+
entry: ManifestEntry;
|
|
62
|
+
} | {
|
|
63
|
+
kind: 'removed-upstream';
|
|
64
|
+
entry: ManifestEntry;
|
|
65
|
+
};
|
|
66
|
+
export declare function runUpgrade(options?: UpgradeOptions): Promise<void>;
|
|
67
|
+
export type { Classification };
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G12 — `rea upgrade`.
|
|
3
|
+
*
|
|
4
|
+
* Classify every canonical shipped file against the consumer's installed copy
|
|
5
|
+
* and the last manifest entry, then act:
|
|
6
|
+
*
|
|
7
|
+
* - NEW (not in manifest, not on disk) → install the canonical version.
|
|
8
|
+
* - UNMODIFIED (on-disk SHA matches manifest SHA) → silently overwrite with
|
|
9
|
+
* canonical version. The consumer never changed it; they get updates for
|
|
10
|
+
* free.
|
|
11
|
+
* - DRIFTED (on-disk SHA ≠ manifest SHA) → interactive prompt:
|
|
12
|
+
* keep | overwrite | diff (show unified diff, then re-prompt)
|
|
13
|
+
* Non-interactive (`--yes`) defaults to KEEP (safe). `--force` defaults
|
|
14
|
+
* to OVERWRITE and skips the prompt.
|
|
15
|
+
* - REMOVED-UPSTREAM (in manifest, no longer canonical) → prompt to delete.
|
|
16
|
+
* Non-interactive defaults to SKIP; `--force` deletes.
|
|
17
|
+
*
|
|
18
|
+
* After processing, the manifest is rewritten with fresh SHAs + `upgraded_at`.
|
|
19
|
+
*
|
|
20
|
+
* Bootstrap path: if no manifest is found, we record current on-disk SHAs
|
|
21
|
+
* as the baseline and mark `bootstrap: true`. This gives pre-G12 installs a
|
|
22
|
+
* manifest without pretending we know what was originally shipped. The NEXT
|
|
23
|
+
* `rea upgrade` compares against canonical normally.
|
|
24
|
+
*
|
|
25
|
+
* Dogfood caveat: running `rea upgrade` on this repo via a Claude Code
|
|
26
|
+
* session will be blocked by `settings-protection.sh` (`.claude/hooks/*`,
|
|
27
|
+
* `.claude/settings.json`, `.husky/*` all protected from Write/Edit). Invoke
|
|
28
|
+
* `rea upgrade` directly from a terminal outside the Claude Code session.
|
|
29
|
+
* The `rea upgrade` code itself performs writes via node `fs` calls which
|
|
30
|
+
* are not hook-gated — but a Claude Code-hosted Bash invocation is. This is
|
|
31
|
+
* intentional: upgrade is an authorized-human action by design.
|
|
32
|
+
*
|
|
33
|
+
* Security note: every on-disk mutation flows through `safeInstallFile` or
|
|
34
|
+
* `safeDeleteFile` in `install/fs-safe.ts`. Path values that originate from
|
|
35
|
+
* `.rea/install-manifest.json` (attacker-controllable) are validated at
|
|
36
|
+
* schema-load time AND re-validated at each filesystem call. See
|
|
37
|
+
* `install/fs-safe.ts` header for the full TOCTOU argument.
|
|
38
|
+
*/
|
|
39
|
+
import fs from 'node:fs';
|
|
40
|
+
import fsPromises from 'node:fs/promises';
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
import * as p from '@clack/prompts';
|
|
43
|
+
import { loadPolicy } from '../policy/loader.js';
|
|
44
|
+
import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
|
|
45
|
+
import { buildFragment, extractFragment, } from './install/claude-md.js';
|
|
46
|
+
import { atomicReplaceFile, safeDeleteFile, safeInstallFile, safeReadFile, } from './install/fs-safe.js';
|
|
47
|
+
import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
|
|
48
|
+
import { manifestExists, readManifest, writeManifestAtomic, } from './install/manifest-io.js';
|
|
49
|
+
import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
|
|
50
|
+
import { err, getPkgVersion, log, warn } from './utils.js';
|
|
51
|
+
/**
|
|
52
|
+
* Hard cap for `showDiff` reads. Canonical files are all tiny (<64KB) but a
|
|
53
|
+
* consumer could have replaced a hook with a 500MB log; refuse to slurp the
|
|
54
|
+
* whole thing into memory. Above this threshold we emit a truncation notice
|
|
55
|
+
* and decline to produce a diff.
|
|
56
|
+
*/
|
|
57
|
+
const DIFF_SIZE_CAP_BYTES = 256 * 1024;
|
|
58
|
+
/**
|
|
59
|
+
* Read a consumer-side file's SHA-256 *through* the fs-safe containment
|
|
60
|
+
* check. Returns `null` when the file is absent. The path here comes from
|
|
61
|
+
* canonical.destRelPath (trusted, enumerated from PKG_ROOT), but we still
|
|
62
|
+
* run it through `safeReadFile` so every filesystem read in upgrade is
|
|
63
|
+
* uniformly symlink- and containment-guarded.
|
|
64
|
+
*/
|
|
65
|
+
async function readLocalSha(resolvedRoot, relPath) {
|
|
66
|
+
const buf = await safeReadFile(resolvedRoot, relPath);
|
|
67
|
+
if (buf === null)
|
|
68
|
+
return null;
|
|
69
|
+
return sha256OfBuffer(buf);
|
|
70
|
+
}
|
|
71
|
+
function showDiff(resolvedRoot, canonical) {
|
|
72
|
+
const dst = path.join(resolvedRoot, canonical.destRelPath);
|
|
73
|
+
let localStat;
|
|
74
|
+
try {
|
|
75
|
+
localStat = fs.statSync(dst);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
console.log('');
|
|
79
|
+
console.log(` (diff unavailable — ${canonical.destRelPath} disappeared)`);
|
|
80
|
+
console.log('');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const canonicalStat = fs.statSync(canonical.sourceAbsPath);
|
|
84
|
+
if (localStat.size > DIFF_SIZE_CAP_BYTES || canonicalStat.size > DIFF_SIZE_CAP_BYTES) {
|
|
85
|
+
console.log('');
|
|
86
|
+
console.log(` (diff suppressed — ${canonical.destRelPath} exceeds ${DIFF_SIZE_CAP_BYTES} bytes; compare manually)`);
|
|
87
|
+
console.log('');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const localBytes = fs.readFileSync(dst, 'utf8');
|
|
91
|
+
const canonicalBytes = fs.readFileSync(canonical.sourceAbsPath, 'utf8');
|
|
92
|
+
const localLines = localBytes.split('\n');
|
|
93
|
+
const canonicalLines = canonicalBytes.split('\n');
|
|
94
|
+
console.log('');
|
|
95
|
+
console.log(`--- local: ${canonical.destRelPath}`);
|
|
96
|
+
console.log(`+++ canonical (rea v${getPkgVersion()})`);
|
|
97
|
+
console.log('');
|
|
98
|
+
// Minimal unified-diff-ish output: line-by-line replace. Full diff requires
|
|
99
|
+
// an LCS implementation; for our purposes, showing both halves and a simple
|
|
100
|
+
// line-counter is enough to let a human decide.
|
|
101
|
+
const max = Math.max(localLines.length, canonicalLines.length);
|
|
102
|
+
let changes = 0;
|
|
103
|
+
for (let i = 0; i < max && changes < 80; i++) {
|
|
104
|
+
const a = localLines[i];
|
|
105
|
+
const b = canonicalLines[i];
|
|
106
|
+
if (a !== b) {
|
|
107
|
+
if (a !== undefined)
|
|
108
|
+
console.log(`- ${a}`);
|
|
109
|
+
if (b !== undefined)
|
|
110
|
+
console.log(`+ ${b}`);
|
|
111
|
+
changes += 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (changes >= 80)
|
|
115
|
+
console.log('... (diff truncated at 80 changed lines)');
|
|
116
|
+
console.log('');
|
|
117
|
+
}
|
|
118
|
+
async function promptDriftDecision(resolvedRoot, canonical, opts) {
|
|
119
|
+
if (opts.force === true)
|
|
120
|
+
return 'overwrite';
|
|
121
|
+
if (opts.yes === true)
|
|
122
|
+
return 'keep';
|
|
123
|
+
while (true) {
|
|
124
|
+
const choice = await p.select({
|
|
125
|
+
message: `${canonical.destRelPath} — locally modified`,
|
|
126
|
+
initialValue: 'keep',
|
|
127
|
+
options: [
|
|
128
|
+
{ value: 'keep', label: 'keep', hint: 'leave your version untouched (default)' },
|
|
129
|
+
{ value: 'overwrite', label: 'overwrite', hint: `replace with canonical (rea v${getPkgVersion()})` },
|
|
130
|
+
{ value: 'diff', label: 'diff', hint: 'show diff, then re-prompt' },
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
if (p.isCancel(choice))
|
|
134
|
+
return 'keep';
|
|
135
|
+
if (choice === 'diff') {
|
|
136
|
+
showDiff(resolvedRoot, canonical);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
return choice;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function promptRemovedDecision(relPath, opts) {
|
|
143
|
+
if (opts.force === true)
|
|
144
|
+
return 'delete';
|
|
145
|
+
if (opts.yes === true)
|
|
146
|
+
return 'skip';
|
|
147
|
+
const answer = await p.select({
|
|
148
|
+
message: `${relPath} — no longer shipped by rea`,
|
|
149
|
+
initialValue: 'skip',
|
|
150
|
+
options: [
|
|
151
|
+
{ value: 'skip', label: 'skip', hint: 'keep the file (default)' },
|
|
152
|
+
{ value: 'delete', label: 'delete', hint: 'remove it' },
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
if (p.isCancel(answer))
|
|
156
|
+
return 'skip';
|
|
157
|
+
return answer;
|
|
158
|
+
}
|
|
159
|
+
async function classifyFiles(resolvedRoot, canonicalFiles, manifest) {
|
|
160
|
+
const manifestByPath = new Map();
|
|
161
|
+
if (manifest !== null) {
|
|
162
|
+
for (const e of manifest.files)
|
|
163
|
+
manifestByPath.set(e.path, e);
|
|
164
|
+
}
|
|
165
|
+
const canonicalByPath = new Map();
|
|
166
|
+
for (const c of canonicalFiles)
|
|
167
|
+
canonicalByPath.set(c.destRelPath, c);
|
|
168
|
+
const classifications = [];
|
|
169
|
+
const shaByPath = new Map();
|
|
170
|
+
for (const canonical of canonicalFiles) {
|
|
171
|
+
const canonicalSha = await sha256OfFile(canonical.sourceAbsPath);
|
|
172
|
+
shaByPath.set(canonical.destRelPath, canonicalSha);
|
|
173
|
+
const localSha = await readLocalSha(resolvedRoot, canonical.destRelPath);
|
|
174
|
+
const entry = manifestByPath.get(canonical.destRelPath);
|
|
175
|
+
if (localSha === null) {
|
|
176
|
+
classifications.push({ kind: 'new', canonical, canonicalSha });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (entry === undefined) {
|
|
180
|
+
// File exists locally but not in manifest — treat as drift against
|
|
181
|
+
// canonical (bootstrap-equivalent for this single file).
|
|
182
|
+
if (localSha === canonicalSha) {
|
|
183
|
+
classifications.push({
|
|
184
|
+
kind: 'unmodified',
|
|
185
|
+
canonical,
|
|
186
|
+
canonicalSha,
|
|
187
|
+
localSha,
|
|
188
|
+
entry: { path: canonical.destRelPath, sha256: canonicalSha, source: canonical.source },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
classifications.push({
|
|
193
|
+
kind: 'drifted',
|
|
194
|
+
canonical,
|
|
195
|
+
canonicalSha,
|
|
196
|
+
localSha,
|
|
197
|
+
entry: { path: canonical.destRelPath, sha256: localSha, source: canonical.source },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (localSha === entry.sha256) {
|
|
203
|
+
classifications.push({ kind: 'unmodified', canonical, canonicalSha, localSha, entry });
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
classifications.push({ kind: 'drifted', canonical, canonicalSha, localSha, entry });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Removed-upstream: in manifest but not in canonical set.
|
|
210
|
+
if (manifest !== null) {
|
|
211
|
+
for (const entry of manifest.files) {
|
|
212
|
+
if (entry.path === CLAUDE_MD_MANIFEST_PATH ||
|
|
213
|
+
entry.path === SETTINGS_MANIFEST_PATH) {
|
|
214
|
+
continue; // synthetic entries handled separately
|
|
215
|
+
}
|
|
216
|
+
if (!canonicalByPath.has(entry.path)) {
|
|
217
|
+
classifications.push({ kind: 'removed-upstream', entry });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { classifications, shaByPath };
|
|
222
|
+
}
|
|
223
|
+
function summarize(classifications) {
|
|
224
|
+
const counts = { new_: 0, unmodified: 0, drifted: 0, removedUpstream: 0 };
|
|
225
|
+
for (const c of classifications) {
|
|
226
|
+
if (c.kind === 'new')
|
|
227
|
+
counts.new_ += 1;
|
|
228
|
+
else if (c.kind === 'unmodified')
|
|
229
|
+
counts.unmodified += 1;
|
|
230
|
+
else if (c.kind === 'drifted')
|
|
231
|
+
counts.drifted += 1;
|
|
232
|
+
else if (c.kind === 'removed-upstream')
|
|
233
|
+
counts.removedUpstream += 1;
|
|
234
|
+
}
|
|
235
|
+
return counts;
|
|
236
|
+
}
|
|
237
|
+
function readPolicyForFragment(baseDir) {
|
|
238
|
+
try {
|
|
239
|
+
const policy = loadPolicy(baseDir);
|
|
240
|
+
return {
|
|
241
|
+
policyPath: '.rea/policy.yaml',
|
|
242
|
+
profile: policy.profile,
|
|
243
|
+
autonomyLevel: policy.autonomy_level,
|
|
244
|
+
maxAutonomyLevel: policy.max_autonomy_level,
|
|
245
|
+
blockedPathsCount: policy.blocked_paths.length,
|
|
246
|
+
blockAiAttribution: policy.block_ai_attribution,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function upgradeClaudeMdFragment(resolvedRoot, opts) {
|
|
254
|
+
const fragmentInput = readPolicyForFragment(resolvedRoot);
|
|
255
|
+
if (fragmentInput === null)
|
|
256
|
+
return { sha: null, action: 'skipped' };
|
|
257
|
+
const newFragment = buildFragment(fragmentInput);
|
|
258
|
+
const newSha = sha256OfBuffer(newFragment);
|
|
259
|
+
const claudeMdPath = path.join(resolvedRoot, 'CLAUDE.md');
|
|
260
|
+
if (!fs.existsSync(claudeMdPath)) {
|
|
261
|
+
if (opts.dryRun === true)
|
|
262
|
+
return { sha: newSha, action: 'written' };
|
|
263
|
+
await atomicReplaceFile(claudeMdPath, `# CLAUDE.md\n\n${newFragment}\n`);
|
|
264
|
+
return { sha: newSha, action: 'written' };
|
|
265
|
+
}
|
|
266
|
+
const existing = await fsPromises.readFile(claudeMdPath, 'utf8');
|
|
267
|
+
const currentFragment = extractFragment(existing);
|
|
268
|
+
if (currentFragment === newFragment)
|
|
269
|
+
return { sha: newSha, action: 'unchanged' };
|
|
270
|
+
if (opts.dryRun === true)
|
|
271
|
+
return { sha: newSha, action: 'written' };
|
|
272
|
+
let next;
|
|
273
|
+
if (currentFragment !== null) {
|
|
274
|
+
next = existing.replace(currentFragment, newFragment);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
// No markers present — append fragment, preserving existing file content.
|
|
278
|
+
const trailer = existing.endsWith('\n') ? '' : '\n';
|
|
279
|
+
next = `${existing}${trailer}\n${newFragment}\n`;
|
|
280
|
+
}
|
|
281
|
+
await atomicReplaceFile(claudeMdPath, next);
|
|
282
|
+
return { sha: newSha, action: 'written' };
|
|
283
|
+
}
|
|
284
|
+
async function upgradeSettings(baseDir, opts) {
|
|
285
|
+
const desired = defaultDesiredHooks();
|
|
286
|
+
const sha = canonicalSettingsSubsetHash(desired);
|
|
287
|
+
const { settings, settingsPath } = readSettings(baseDir);
|
|
288
|
+
const mergeResult = mergeSettings(settings, desired);
|
|
289
|
+
if (opts.dryRun !== true) {
|
|
290
|
+
await writeSettingsAtomic(settingsPath, mergeResult.merged);
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
sha,
|
|
294
|
+
addedCount: mergeResult.addedCount,
|
|
295
|
+
skippedCount: mergeResult.skippedCount,
|
|
296
|
+
warnings: mergeResult.warnings,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/** Re-hash a file we just wrote. Source and on-disk bytes should match, but
|
|
300
|
+
* we record the *installed* SHA so a disk-level corruption would be visible
|
|
301
|
+
* on the next run rather than papered over by the source SHA. */
|
|
302
|
+
async function hashInstalled(resolvedRoot, relPath) {
|
|
303
|
+
const buf = await safeReadFile(resolvedRoot, relPath);
|
|
304
|
+
if (buf === null) {
|
|
305
|
+
throw new Error(`post-install verification failed: ${relPath} not readable after write`);
|
|
306
|
+
}
|
|
307
|
+
return sha256OfBuffer(buf);
|
|
308
|
+
}
|
|
309
|
+
export async function runUpgrade(options = {}) {
|
|
310
|
+
const baseDir = process.cwd();
|
|
311
|
+
const dryRun = options.dryRun === true;
|
|
312
|
+
if (!fs.existsSync(path.join(baseDir, '.rea'))) {
|
|
313
|
+
err('no .rea/ directory — run `rea init` first.');
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
316
|
+
// Resolve the install root once so every filesystem op below uses a single
|
|
317
|
+
// trusted anchor. `safeInstallFile` / `safeDeleteFile` / `safeReadFile` all
|
|
318
|
+
// require this to enforce containment.
|
|
319
|
+
const resolvedRoot = await fsPromises.realpath(baseDir);
|
|
320
|
+
if (options.force === true && !dryRun) {
|
|
321
|
+
warn('--force: overwriting locally-modified files and deleting removed-upstream entries without prompt.');
|
|
322
|
+
}
|
|
323
|
+
const canonicalFiles = await enumerateCanonicalFiles();
|
|
324
|
+
if (canonicalFiles.length === 0) {
|
|
325
|
+
err('no canonical files found in package — is the build complete?');
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
const existingManifest = manifestExists(resolvedRoot) ? await readManifest(resolvedRoot) : null;
|
|
329
|
+
const isBootstrap = existingManifest === null;
|
|
330
|
+
log(`Upgrade — target ${resolvedRoot}${dryRun ? ' (dry run)' : ''}${isBootstrap ? ' — bootstrap mode' : ''}`);
|
|
331
|
+
console.log('');
|
|
332
|
+
const { classifications } = await classifyFiles(resolvedRoot, canonicalFiles, existingManifest);
|
|
333
|
+
const counts = summarize(classifications);
|
|
334
|
+
console.log(` ${counts.new_} new, ${counts.unmodified} auto-update, ${counts.drifted} drifted, ${counts.removedUpstream} removed-upstream`);
|
|
335
|
+
console.log('');
|
|
336
|
+
const applied = [];
|
|
337
|
+
const skipped = [];
|
|
338
|
+
const errors = [];
|
|
339
|
+
const finalFileEntries = [];
|
|
340
|
+
for (const c of classifications) {
|
|
341
|
+
if (c.kind === 'new') {
|
|
342
|
+
console.log(` + ${c.canonical.destRelPath}`);
|
|
343
|
+
if (!dryRun) {
|
|
344
|
+
await safeInstallFile({
|
|
345
|
+
srcAbsPath: c.canonical.sourceAbsPath,
|
|
346
|
+
resolvedRoot,
|
|
347
|
+
destRelPath: c.canonical.destRelPath,
|
|
348
|
+
mode: c.canonical.mode,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
const installedSha = dryRun
|
|
352
|
+
? c.canonicalSha
|
|
353
|
+
: await hashInstalled(resolvedRoot, c.canonical.destRelPath);
|
|
354
|
+
applied.push(c);
|
|
355
|
+
finalFileEntries.push({
|
|
356
|
+
path: c.canonical.destRelPath,
|
|
357
|
+
sha256: installedSha,
|
|
358
|
+
source: c.canonical.source,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
else if (c.kind === 'unmodified') {
|
|
362
|
+
if (c.canonicalSha === c.localSha) {
|
|
363
|
+
// Already identical to canonical. Record the verified local SHA —
|
|
364
|
+
// no write performed.
|
|
365
|
+
finalFileEntries.push({
|
|
366
|
+
path: c.canonical.destRelPath,
|
|
367
|
+
sha256: c.localSha,
|
|
368
|
+
source: c.canonical.source,
|
|
369
|
+
});
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
// Consumer file matches the OLD manifest (untouched since last install) —
|
|
373
|
+
// safe to auto-update to the new canonical version.
|
|
374
|
+
console.log(` ~ ${c.canonical.destRelPath} (auto-update)`);
|
|
375
|
+
if (!dryRun) {
|
|
376
|
+
await safeInstallFile({
|
|
377
|
+
srcAbsPath: c.canonical.sourceAbsPath,
|
|
378
|
+
resolvedRoot,
|
|
379
|
+
destRelPath: c.canonical.destRelPath,
|
|
380
|
+
mode: c.canonical.mode,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
const installedSha = dryRun
|
|
384
|
+
? c.canonicalSha
|
|
385
|
+
: await hashInstalled(resolvedRoot, c.canonical.destRelPath);
|
|
386
|
+
applied.push(c);
|
|
387
|
+
finalFileEntries.push({
|
|
388
|
+
path: c.canonical.destRelPath,
|
|
389
|
+
sha256: installedSha,
|
|
390
|
+
source: c.canonical.source,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
else if (c.kind === 'drifted') {
|
|
394
|
+
const decision = dryRun
|
|
395
|
+
? 'keep'
|
|
396
|
+
: await promptDriftDecision(resolvedRoot, c.canonical, options);
|
|
397
|
+
if (decision === 'overwrite') {
|
|
398
|
+
console.log(` ~ ${c.canonical.destRelPath} (overwrite)`);
|
|
399
|
+
if (!dryRun) {
|
|
400
|
+
await safeInstallFile({
|
|
401
|
+
srcAbsPath: c.canonical.sourceAbsPath,
|
|
402
|
+
resolvedRoot,
|
|
403
|
+
destRelPath: c.canonical.destRelPath,
|
|
404
|
+
mode: c.canonical.mode,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
const installedSha = dryRun
|
|
408
|
+
? c.canonicalSha
|
|
409
|
+
: await hashInstalled(resolvedRoot, c.canonical.destRelPath);
|
|
410
|
+
applied.push(c);
|
|
411
|
+
finalFileEntries.push({
|
|
412
|
+
path: c.canonical.destRelPath,
|
|
413
|
+
sha256: installedSha,
|
|
414
|
+
source: c.canonical.source,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
console.log(` · ${c.canonical.destRelPath} (kept; local modifications preserved)`);
|
|
419
|
+
warn(`DRIFT: ${c.canonical.destRelPath} differs from canonical — local version kept`);
|
|
420
|
+
skipped.push(c);
|
|
421
|
+
finalFileEntries.push({
|
|
422
|
+
path: c.canonical.destRelPath,
|
|
423
|
+
sha256: c.localSha,
|
|
424
|
+
source: c.canonical.source,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
else if (c.kind === 'removed-upstream') {
|
|
429
|
+
const decision = dryRun
|
|
430
|
+
? 'skip'
|
|
431
|
+
: await promptRemovedDecision(c.entry.path, options);
|
|
432
|
+
if (decision === 'delete') {
|
|
433
|
+
console.log(` - ${c.entry.path} (deleted)`);
|
|
434
|
+
if (!dryRun) {
|
|
435
|
+
// Path originates from the manifest (attacker-controllable). The
|
|
436
|
+
// ManifestPath zod refinement already rejected `..`, absolute
|
|
437
|
+
// paths, and control chars at parse time; `safeDeleteFile` adds
|
|
438
|
+
// symlink refusal + containment re-check for defence in depth.
|
|
439
|
+
await safeDeleteFile(resolvedRoot, c.entry.path);
|
|
440
|
+
}
|
|
441
|
+
applied.push(c);
|
|
442
|
+
// Drop from manifest.
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
console.log(` · ${c.entry.path} (kept; no longer shipped)`);
|
|
446
|
+
skipped.push(c);
|
|
447
|
+
finalFileEntries.push(c.entry);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Synthetic entries: settings + claude-md fragment.
|
|
452
|
+
const settingsResult = await upgradeSettings(resolvedRoot, options);
|
|
453
|
+
if (settingsResult.addedCount > 0) {
|
|
454
|
+
console.log(` ~ .claude/settings.json (${settingsResult.addedCount} hook entries added, ${settingsResult.skippedCount} already present)`);
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
console.log(` · .claude/settings.json (${settingsResult.skippedCount} rea entries already present)`);
|
|
458
|
+
}
|
|
459
|
+
for (const w of settingsResult.warnings)
|
|
460
|
+
warn(w);
|
|
461
|
+
finalFileEntries.push({
|
|
462
|
+
path: SETTINGS_MANIFEST_PATH,
|
|
463
|
+
sha256: settingsResult.sha,
|
|
464
|
+
source: 'settings',
|
|
465
|
+
});
|
|
466
|
+
const mdResult = await upgradeClaudeMdFragment(resolvedRoot, options);
|
|
467
|
+
if (mdResult.sha !== null) {
|
|
468
|
+
if (mdResult.action === 'written')
|
|
469
|
+
console.log(` ~ CLAUDE.md (managed fragment updated)`);
|
|
470
|
+
else if (mdResult.action === 'unchanged')
|
|
471
|
+
console.log(` · CLAUDE.md (fragment unchanged)`);
|
|
472
|
+
finalFileEntries.push({
|
|
473
|
+
path: CLAUDE_MD_MANIFEST_PATH,
|
|
474
|
+
sha256: mdResult.sha,
|
|
475
|
+
source: 'claude-md',
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
if (dryRun) {
|
|
479
|
+
console.log('');
|
|
480
|
+
log('dry run — no changes written.');
|
|
481
|
+
const planned = counts.new_ + counts.drifted + counts.removedUpstream +
|
|
482
|
+
(classifications.some((c) => c.kind === 'unmodified' && c.canonicalSha !== c.localSha)
|
|
483
|
+
? classifications.filter((c) => c.kind === 'unmodified' && c.canonicalSha !== c.localSha).length
|
|
484
|
+
: 0);
|
|
485
|
+
console.log(` ${planned} file action(s) planned.`);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const now = new Date().toISOString();
|
|
489
|
+
const installedAt = existingManifest?.installed_at ?? now;
|
|
490
|
+
const profile = existingManifest?.profile ?? 'unknown';
|
|
491
|
+
const freshManifest = {
|
|
492
|
+
version: getPkgVersion(),
|
|
493
|
+
profile,
|
|
494
|
+
installed_at: installedAt,
|
|
495
|
+
upgraded_at: now,
|
|
496
|
+
...(isBootstrap ? { bootstrap: true } : {}),
|
|
497
|
+
files: finalFileEntries,
|
|
498
|
+
};
|
|
499
|
+
const manifestPath = await writeManifestAtomic(resolvedRoot, freshManifest);
|
|
500
|
+
console.log('');
|
|
501
|
+
log(`upgrade complete — ${applied.length} applied, ${skipped.length} skipped, ${errors.length} errors`);
|
|
502
|
+
console.log(` manifest: ${path.relative(resolvedRoot, manifestPath)} (v${freshManifest.version})`);
|
|
503
|
+
if (isBootstrap) {
|
|
504
|
+
console.log('');
|
|
505
|
+
console.log('Bootstrap mode: existing files were recorded as-is. The next `rea upgrade`');
|
|
506
|
+
console.log('will compare against the canonical set and surface any legitimate drift.');
|
|
507
|
+
}
|
|
508
|
+
console.log('');
|
|
509
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pool of downstream MCP connections. Owns lookup + tool-name prefixing.
|
|
3
|
+
*
|
|
4
|
+
* Tool names exposed to the upstream MCP client are `<serverName>__<toolName>`.
|
|
5
|
+
* The gateway splits on the FIRST `__` — downstream tools that themselves
|
|
6
|
+
* contain `__` in their name continue to work because the split is one-shot.
|
|
7
|
+
*/
|
|
8
|
+
import { DownstreamConnection, type DownstreamToolInfo } from './downstream.js';
|
|
9
|
+
import type { Registry } from '../registry/types.js';
|
|
10
|
+
export interface PrefixedTool extends DownstreamToolInfo {
|
|
11
|
+
/** Server name, not prefixed. */
|
|
12
|
+
server: string;
|
|
13
|
+
/** Full prefixed name, as exposed to the upstream client. */
|
|
14
|
+
name: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class DownstreamPool {
|
|
17
|
+
private readonly connections;
|
|
18
|
+
constructor(registry: Registry);
|
|
19
|
+
get size(): number;
|
|
20
|
+
connectAll(): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Aggregate tools from every healthy downstream with prefixed names.
|
|
23
|
+
* Unhealthy or unconnected connections are skipped — the upstream client
|
|
24
|
+
* will see a smaller catalog rather than a crash.
|
|
25
|
+
*/
|
|
26
|
+
listAllTools(): Promise<PrefixedTool[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Split a prefixed tool name and dispatch. Returns the raw result from the
|
|
29
|
+
* downstream (the gateway response handler shapes it for the upstream reply).
|
|
30
|
+
*/
|
|
31
|
+
callTool(prefixedName: string, args: Record<string, unknown>): Promise<unknown>;
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
/** Visible for tests: get a connection by server name. */
|
|
34
|
+
getConnection(serverName: string): DownstreamConnection | undefined;
|
|
35
|
+
}
|
|
36
|
+
export declare function splitPrefixed(prefixedName: string): {
|
|
37
|
+
server: string;
|
|
38
|
+
tool: string;
|
|
39
|
+
};
|