@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,160 @@
|
|
|
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 fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { parse as parseYaml } from 'yaml';
|
|
30
|
+
import { AutonomyLevel } from '../../policy/types.js';
|
|
31
|
+
import { ProfileSchema } from '../../policy/profiles.js';
|
|
32
|
+
const LEVEL_RANK = {
|
|
33
|
+
[AutonomyLevel.L0]: 0,
|
|
34
|
+
[AutonomyLevel.L1]: 1,
|
|
35
|
+
[AutonomyLevel.L2]: 2,
|
|
36
|
+
[AutonomyLevel.L3]: 3,
|
|
37
|
+
};
|
|
38
|
+
/** Fields transferred directly from reagent. Both schemas agree on semantics. */
|
|
39
|
+
const COPY_FIELDS = [
|
|
40
|
+
'autonomy_level',
|
|
41
|
+
'max_autonomy_level',
|
|
42
|
+
'promotion_requires_human_approval',
|
|
43
|
+
'blocked_paths',
|
|
44
|
+
'context_protection',
|
|
45
|
+
'block_ai_attribution',
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
48
|
+
* SECURITY-RELEVANT fields that reagent supported but rea does not model the
|
|
49
|
+
* same way (yet). Presence of any of these triggers a refusal unless the
|
|
50
|
+
* caller explicitly opts in via `--accept-dropped-fields`. This list is
|
|
51
|
+
* intentionally broader than the strict minimum — err on the side of asking.
|
|
52
|
+
*/
|
|
53
|
+
const DROP_FIELDS = [
|
|
54
|
+
'push_review',
|
|
55
|
+
'coverage',
|
|
56
|
+
'security',
|
|
57
|
+
'commit_review',
|
|
58
|
+
'quality_gates',
|
|
59
|
+
];
|
|
60
|
+
export class ReagentDroppedFieldsError extends Error {
|
|
61
|
+
dropped;
|
|
62
|
+
constructor(dropped) {
|
|
63
|
+
super(`Reagent policy contains fields that rea does not model identically:\n` +
|
|
64
|
+
dropped.map((f) => ` - ${f}`).join('\n') +
|
|
65
|
+
`\n\nPass --accept-dropped-fields to continue. The dropped fields are\n` +
|
|
66
|
+
`security-adjacent and will be silently removed — review the rea policy\n` +
|
|
67
|
+
`after migration and restore equivalent guarantees by other means.`);
|
|
68
|
+
this.name = 'ReagentDroppedFieldsError';
|
|
69
|
+
this.dropped = dropped;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function readReagentPolicy(reagentPath) {
|
|
73
|
+
const raw = fs.readFileSync(reagentPath, 'utf8');
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = parseYaml(raw);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
throw new Error(`Failed to parse reagent policy at ${reagentPath}: ${err instanceof Error ? err.message : err}`);
|
|
80
|
+
}
|
|
81
|
+
if (parsed === null || parsed === undefined)
|
|
82
|
+
return {};
|
|
83
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
84
|
+
throw new Error(`Reagent policy at ${reagentPath} is not a YAML mapping`);
|
|
85
|
+
}
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
88
|
+
function detectDropped(policy) {
|
|
89
|
+
const found = [];
|
|
90
|
+
for (const f of DROP_FIELDS) {
|
|
91
|
+
if (f in policy)
|
|
92
|
+
found.push(f);
|
|
93
|
+
}
|
|
94
|
+
return found;
|
|
95
|
+
}
|
|
96
|
+
function extractCopyFields(policy) {
|
|
97
|
+
const out = {};
|
|
98
|
+
for (const f of COPY_FIELDS) {
|
|
99
|
+
if (f in policy)
|
|
100
|
+
out[f] = policy[f];
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Translate the reagent policy at `reagentPath`, enforcing drop-list rules.
|
|
106
|
+
*
|
|
107
|
+
* @throws {ReagentDroppedFieldsError} if drop-list fields are present and
|
|
108
|
+
* `acceptDropped` is false.
|
|
109
|
+
*/
|
|
110
|
+
export function translateReagentPolicy(reagentPath, options) {
|
|
111
|
+
if (!fs.existsSync(reagentPath)) {
|
|
112
|
+
throw new Error(`Reagent policy not found at ${reagentPath}`);
|
|
113
|
+
}
|
|
114
|
+
const raw = readReagentPolicy(reagentPath);
|
|
115
|
+
const dropped = detectDropped(raw);
|
|
116
|
+
if (dropped.length > 0 && !options.acceptDropped) {
|
|
117
|
+
throw new ReagentDroppedFieldsError(dropped);
|
|
118
|
+
}
|
|
119
|
+
const notices = [];
|
|
120
|
+
if (dropped.length > 0) {
|
|
121
|
+
for (const f of dropped) {
|
|
122
|
+
notices.push(`dropped reagent field "${f}" — governance surface no longer modeled in rea policy`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const copied = extractCopyFields(raw);
|
|
126
|
+
// Validate the copied subset against the profile schema. This catches shape
|
|
127
|
+
// drift (e.g. a reagent file with a malformed context_protection block).
|
|
128
|
+
let validated;
|
|
129
|
+
try {
|
|
130
|
+
validated = ProfileSchema.parse(copied);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
throw new Error(`Reagent-translated fields failed schema validation: ${err instanceof Error ? err.message : err}`);
|
|
134
|
+
}
|
|
135
|
+
let clamped = false;
|
|
136
|
+
if (validated.max_autonomy_level !== undefined) {
|
|
137
|
+
const reagentCeilingRank = LEVEL_RANK[validated.max_autonomy_level];
|
|
138
|
+
const profileCeilingRank = LEVEL_RANK[options.profileCeiling];
|
|
139
|
+
if (reagentCeilingRank > profileCeilingRank) {
|
|
140
|
+
notices.push(`clamping max_autonomy_level ${validated.max_autonomy_level} → ${options.profileCeiling} (profile ceiling)`);
|
|
141
|
+
validated.max_autonomy_level = options.profileCeiling;
|
|
142
|
+
clamped = true;
|
|
143
|
+
}
|
|
144
|
+
// Also clamp autonomy_level if it now exceeds the ceiling.
|
|
145
|
+
if (validated.autonomy_level !== undefined &&
|
|
146
|
+
LEVEL_RANK[validated.autonomy_level] > LEVEL_RANK[validated.max_autonomy_level]) {
|
|
147
|
+
notices.push(`clamping autonomy_level ${validated.autonomy_level} → ${validated.max_autonomy_level} after ceiling clamp`);
|
|
148
|
+
validated.autonomy_level = validated.max_autonomy_level;
|
|
149
|
+
clamped = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { translated: validated, notices, droppedFields: dropped, clampedAutonomy: clamped };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Resolve the default reagent policy path inside a target directory.
|
|
156
|
+
* Convenience for the CLI's `--from-reagent` flag.
|
|
157
|
+
*/
|
|
158
|
+
export function defaultReagentPath(targetDir) {
|
|
159
|
+
return path.join(targetDir, '.reagent', 'policy.yaml');
|
|
160
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge rea's required hook registrations into a consumer's
|
|
3
|
+
* `.claude/settings.json` without ever silently overwriting consumer-authored
|
|
4
|
+
* entries.
|
|
5
|
+
*
|
|
6
|
+
* Rules per hook-group `(event, matcher, command)`:
|
|
7
|
+
*
|
|
8
|
+
* 1. `${matcher}::${command}` already present on the same event → no-op.
|
|
9
|
+
* 2. Same matcher, different command → append and warn (the consumer may
|
|
10
|
+
* need to chain the hooks manually if order matters).
|
|
11
|
+
* 3. Novel matcher → append as a new matcher group.
|
|
12
|
+
*
|
|
13
|
+
* Writes are atomic: serialize to `settings.json.tmp`, then rename. This keeps
|
|
14
|
+
* the file intact under crash or signal-interrupt.
|
|
15
|
+
*
|
|
16
|
+
* We deliberately do NOT validate the shape of existing entries beyond the
|
|
17
|
+
* minimum needed to merge. The harness is the source of truth for the schema;
|
|
18
|
+
* if a consumer has hand-authored unusual entries, we trust them and merge
|
|
19
|
+
* around their structure.
|
|
20
|
+
*/
|
|
21
|
+
export type HookEvent = 'PreToolUse' | 'PostToolUse' | 'Notification' | 'Stop';
|
|
22
|
+
export interface DesiredHook {
|
|
23
|
+
type: 'command';
|
|
24
|
+
command: string;
|
|
25
|
+
timeout?: number;
|
|
26
|
+
statusMessage?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface DesiredHookGroup {
|
|
29
|
+
event: HookEvent;
|
|
30
|
+
matcher: string;
|
|
31
|
+
hooks: DesiredHook[];
|
|
32
|
+
}
|
|
33
|
+
export interface MergeResult {
|
|
34
|
+
merged: Record<string, unknown>;
|
|
35
|
+
warnings: string[];
|
|
36
|
+
addedCount: number;
|
|
37
|
+
skippedCount: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Pure merge function — takes the existing settings object and the desired
|
|
41
|
+
* hooks and returns the merged settings plus a list of warnings. Does NOT
|
|
42
|
+
* touch disk.
|
|
43
|
+
*/
|
|
44
|
+
export declare function mergeSettings(existing: Record<string, unknown>, desired: DesiredHookGroup[]): MergeResult;
|
|
45
|
+
/**
|
|
46
|
+
* Atomic write via tmp-file + rename.
|
|
47
|
+
*
|
|
48
|
+
* Portability note (finding #8): POSIX `rename(2)` atomically replaces the
|
|
49
|
+
* destination if it exists, but Windows `MoveFileEx` without
|
|
50
|
+
* `MOVEFILE_REPLACE_EXISTING` fails with `EEXIST`/`EPERM` on a non-empty
|
|
51
|
+
* destination — and Node's `fs.rename` does not pass that flag on Win32. So on
|
|
52
|
+
* Windows, a straight rename over an existing `settings.json` fails and
|
|
53
|
+
* `rea init` cannot update consumer settings.
|
|
54
|
+
*
|
|
55
|
+
* We handle this inline rather than taking a dependency: try `rename`; if it
|
|
56
|
+
* fails with `EEXIST` or `EPERM`, `unlink` the destination and retry. This
|
|
57
|
+
* opens a tiny window where the file is missing between unlink and rename, but
|
|
58
|
+
* a crash in that window leaves the `.tmp` file on disk as a recoverable
|
|
59
|
+
* artifact — strictly better than a corrupted merge. We prefer no dependency
|
|
60
|
+
* over `write-file-atomic`: every dep on a governance tool is a supply-chain
|
|
61
|
+
* surface, and this shim is small enough to audit here.
|
|
62
|
+
*/
|
|
63
|
+
export declare function writeSettingsAtomic(settingsPath: string, settings: Record<string, unknown>): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Read `${targetDir}/.claude/settings.json` if present; return an empty object
|
|
66
|
+
* otherwise. The wizard/`--yes` path will then populate env defaults
|
|
67
|
+
* elsewhere; this function only concerns itself with structural parsing.
|
|
68
|
+
*/
|
|
69
|
+
export declare function readSettings(targetDir: string): {
|
|
70
|
+
settings: Record<string, unknown>;
|
|
71
|
+
settingsPath: string;
|
|
72
|
+
existed: boolean;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Stable SHA-256 of the rea-owned "desired hooks" subset. G12 uses this as
|
|
76
|
+
* the manifest entry for `.claude/settings.json` so drift detection only
|
|
77
|
+
* flags cases where a consumer deleted one of OUR entries — consumer-added
|
|
78
|
+
* entries remain invisible.
|
|
79
|
+
*
|
|
80
|
+
* Deterministic serialization: property order is fixed in-code (event,
|
|
81
|
+
* matcher, hooks → type, command, timeout, statusMessage), and the array
|
|
82
|
+
* order matches `defaultDesiredHooks()`. Any change to the desired set
|
|
83
|
+
* produces a new hash, which is the correct upgrade signal.
|
|
84
|
+
*/
|
|
85
|
+
export declare function canonicalSettingsSubsetHash(groups: DesiredHookGroup[]): string;
|
|
86
|
+
/**
|
|
87
|
+
* Desired hook registrations that `rea init` installs on every run. Mirrors
|
|
88
|
+
* the shape of the `.claude/settings.json` that this repo dogfoods. Keep in
|
|
89
|
+
* lockstep with `hooks/` filenames.
|
|
90
|
+
*/
|
|
91
|
+
export declare function defaultDesiredHooks(): DesiredHookGroup[];
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge rea's required hook registrations into a consumer's
|
|
3
|
+
* `.claude/settings.json` without ever silently overwriting consumer-authored
|
|
4
|
+
* entries.
|
|
5
|
+
*
|
|
6
|
+
* Rules per hook-group `(event, matcher, command)`:
|
|
7
|
+
*
|
|
8
|
+
* 1. `${matcher}::${command}` already present on the same event → no-op.
|
|
9
|
+
* 2. Same matcher, different command → append and warn (the consumer may
|
|
10
|
+
* need to chain the hooks manually if order matters).
|
|
11
|
+
* 3. Novel matcher → append as a new matcher group.
|
|
12
|
+
*
|
|
13
|
+
* Writes are atomic: serialize to `settings.json.tmp`, then rename. This keeps
|
|
14
|
+
* the file intact under crash or signal-interrupt.
|
|
15
|
+
*
|
|
16
|
+
* We deliberately do NOT validate the shape of existing entries beyond the
|
|
17
|
+
* minimum needed to merge. The harness is the source of truth for the schema;
|
|
18
|
+
* if a consumer has hand-authored unusual entries, we trust them and merge
|
|
19
|
+
* around their structure.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import fsPromises from 'node:fs/promises';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
function deepClone(value) {
|
|
26
|
+
// structuredClone is available on Node 22+ (engines.node enforces this).
|
|
27
|
+
return structuredClone(value);
|
|
28
|
+
}
|
|
29
|
+
function ensureHooksShape(settings) {
|
|
30
|
+
const hooks = settings.hooks ?? {};
|
|
31
|
+
settings.hooks = hooks;
|
|
32
|
+
return hooks;
|
|
33
|
+
}
|
|
34
|
+
function keyFor(matcher, command) {
|
|
35
|
+
return `${matcher}::${command}`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Pure merge function — takes the existing settings object and the desired
|
|
39
|
+
* hooks and returns the merged settings plus a list of warnings. Does NOT
|
|
40
|
+
* touch disk.
|
|
41
|
+
*/
|
|
42
|
+
export function mergeSettings(existing, desired) {
|
|
43
|
+
const merged = deepClone(existing);
|
|
44
|
+
const hooks = ensureHooksShape(merged);
|
|
45
|
+
const warnings = [];
|
|
46
|
+
let addedCount = 0;
|
|
47
|
+
let skippedCount = 0;
|
|
48
|
+
for (const want of desired) {
|
|
49
|
+
const existingGroups = hooks[want.event] ?? [];
|
|
50
|
+
// Build a set of already-registered (matcher, command) pairs across all
|
|
51
|
+
// groups for this event. Most consumer files have one group per matcher,
|
|
52
|
+
// but we handle the multi-group case defensively.
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const g of existingGroups) {
|
|
55
|
+
if (typeof g.matcher !== 'string')
|
|
56
|
+
continue;
|
|
57
|
+
for (const h of g.hooks ?? []) {
|
|
58
|
+
if (typeof h.command === 'string')
|
|
59
|
+
seen.add(keyFor(g.matcher, h.command));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Find or create a group with exactly this matcher. Track whether the
|
|
63
|
+
// group pre-existed so we only warn when rea chains onto consumer-authored
|
|
64
|
+
// hooks — not when multiple rea defaults share the same matcher group we
|
|
65
|
+
// just created this run.
|
|
66
|
+
const preExisting = existingGroups.find((g) => g.matcher === want.matcher);
|
|
67
|
+
let targetGroup = preExisting;
|
|
68
|
+
const wasPreExisting = preExisting !== undefined;
|
|
69
|
+
for (const wantHook of want.hooks) {
|
|
70
|
+
const k = keyFor(want.matcher, wantHook.command);
|
|
71
|
+
if (seen.has(k)) {
|
|
72
|
+
skippedCount += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (targetGroup === undefined) {
|
|
76
|
+
targetGroup = { matcher: want.matcher, hooks: [] };
|
|
77
|
+
existingGroups.push(targetGroup);
|
|
78
|
+
hooks[want.event] = existingGroups;
|
|
79
|
+
warnings.push(`added novel matcher "${want.matcher}" to event ${want.event}`);
|
|
80
|
+
}
|
|
81
|
+
else if (wasPreExisting) {
|
|
82
|
+
// Same matcher existed already in the consumer file; we're chaining
|
|
83
|
+
// new commands onto something the consumer owns. Warn so they review
|
|
84
|
+
// ordering semantics.
|
|
85
|
+
warnings.push(`chained new command onto existing matcher "${want.matcher}" for event ${want.event}: ${wantHook.command} — verify hook ordering is still correct`);
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(targetGroup.hooks))
|
|
88
|
+
targetGroup.hooks = [];
|
|
89
|
+
targetGroup.hooks.push({
|
|
90
|
+
type: wantHook.type,
|
|
91
|
+
command: wantHook.command,
|
|
92
|
+
...(wantHook.timeout !== undefined ? { timeout: wantHook.timeout } : {}),
|
|
93
|
+
...(wantHook.statusMessage !== undefined
|
|
94
|
+
? { statusMessage: wantHook.statusMessage }
|
|
95
|
+
: {}),
|
|
96
|
+
});
|
|
97
|
+
seen.add(k);
|
|
98
|
+
addedCount += 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { merged, warnings, addedCount, skippedCount };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Atomic write via tmp-file + rename.
|
|
105
|
+
*
|
|
106
|
+
* Portability note (finding #8): POSIX `rename(2)` atomically replaces the
|
|
107
|
+
* destination if it exists, but Windows `MoveFileEx` without
|
|
108
|
+
* `MOVEFILE_REPLACE_EXISTING` fails with `EEXIST`/`EPERM` on a non-empty
|
|
109
|
+
* destination — and Node's `fs.rename` does not pass that flag on Win32. So on
|
|
110
|
+
* Windows, a straight rename over an existing `settings.json` fails and
|
|
111
|
+
* `rea init` cannot update consumer settings.
|
|
112
|
+
*
|
|
113
|
+
* We handle this inline rather than taking a dependency: try `rename`; if it
|
|
114
|
+
* fails with `EEXIST` or `EPERM`, `unlink` the destination and retry. This
|
|
115
|
+
* opens a tiny window where the file is missing between unlink and rename, but
|
|
116
|
+
* a crash in that window leaves the `.tmp` file on disk as a recoverable
|
|
117
|
+
* artifact — strictly better than a corrupted merge. We prefer no dependency
|
|
118
|
+
* over `write-file-atomic`: every dep on a governance tool is a supply-chain
|
|
119
|
+
* surface, and this shim is small enough to audit here.
|
|
120
|
+
*/
|
|
121
|
+
export async function writeSettingsAtomic(settingsPath, settings) {
|
|
122
|
+
const dir = path.dirname(settingsPath);
|
|
123
|
+
await fsPromises.mkdir(dir, { recursive: true });
|
|
124
|
+
const tmp = `${settingsPath}.tmp`;
|
|
125
|
+
const serialized = JSON.stringify(settings, null, 2) + '\n';
|
|
126
|
+
await fsPromises.writeFile(tmp, serialized, 'utf8');
|
|
127
|
+
try {
|
|
128
|
+
await fsPromises.rename(tmp, settingsPath);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
const code = err.code;
|
|
132
|
+
if (code !== 'EEXIST' && code !== 'EPERM') {
|
|
133
|
+
// Clean up the tmp file on unexpected failure so we don't leave litter.
|
|
134
|
+
await fsPromises.unlink(tmp).catch(() => {
|
|
135
|
+
/* best-effort; original error is the one that matters */
|
|
136
|
+
});
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
// Windows: destination exists and rename refuses to replace it. Remove
|
|
140
|
+
// and retry. If the second rename also fails, propagate — something
|
|
141
|
+
// stranger is going on (permissions, read-only volume) that the operator
|
|
142
|
+
// needs to see.
|
|
143
|
+
await fsPromises.unlink(settingsPath);
|
|
144
|
+
try {
|
|
145
|
+
await fsPromises.rename(tmp, settingsPath);
|
|
146
|
+
}
|
|
147
|
+
catch (retryErr) {
|
|
148
|
+
await fsPromises.unlink(tmp).catch(() => {
|
|
149
|
+
/* best-effort cleanup */
|
|
150
|
+
});
|
|
151
|
+
throw retryErr;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Read `${targetDir}/.claude/settings.json` if present; return an empty object
|
|
157
|
+
* otherwise. The wizard/`--yes` path will then populate env defaults
|
|
158
|
+
* elsewhere; this function only concerns itself with structural parsing.
|
|
159
|
+
*/
|
|
160
|
+
export function readSettings(targetDir) {
|
|
161
|
+
const settingsPath = path.join(targetDir, '.claude', 'settings.json');
|
|
162
|
+
if (!fs.existsSync(settingsPath)) {
|
|
163
|
+
return { settings: {}, settingsPath, existed: false };
|
|
164
|
+
}
|
|
165
|
+
const raw = fs.readFileSync(settingsPath, 'utf8');
|
|
166
|
+
try {
|
|
167
|
+
const parsed = JSON.parse(raw);
|
|
168
|
+
return { settings: parsed, settingsPath, existed: true };
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
throw new Error(`Failed to parse ${settingsPath}: ${err instanceof Error ? err.message : err}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Stable SHA-256 of the rea-owned "desired hooks" subset. G12 uses this as
|
|
176
|
+
* the manifest entry for `.claude/settings.json` so drift detection only
|
|
177
|
+
* flags cases where a consumer deleted one of OUR entries — consumer-added
|
|
178
|
+
* entries remain invisible.
|
|
179
|
+
*
|
|
180
|
+
* Deterministic serialization: property order is fixed in-code (event,
|
|
181
|
+
* matcher, hooks → type, command, timeout, statusMessage), and the array
|
|
182
|
+
* order matches `defaultDesiredHooks()`. Any change to the desired set
|
|
183
|
+
* produces a new hash, which is the correct upgrade signal.
|
|
184
|
+
*/
|
|
185
|
+
export function canonicalSettingsSubsetHash(groups) {
|
|
186
|
+
const canonical = groups.map((g) => ({
|
|
187
|
+
event: g.event,
|
|
188
|
+
matcher: g.matcher,
|
|
189
|
+
hooks: g.hooks.map((h) => {
|
|
190
|
+
const entry = { type: h.type, command: h.command };
|
|
191
|
+
if (h.timeout !== undefined)
|
|
192
|
+
entry.timeout = h.timeout;
|
|
193
|
+
if (h.statusMessage !== undefined)
|
|
194
|
+
entry.statusMessage = h.statusMessage;
|
|
195
|
+
return entry;
|
|
196
|
+
}),
|
|
197
|
+
}));
|
|
198
|
+
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Desired hook registrations that `rea init` installs on every run. Mirrors
|
|
202
|
+
* the shape of the `.claude/settings.json` that this repo dogfoods. Keep in
|
|
203
|
+
* lockstep with `hooks/` filenames.
|
|
204
|
+
*/
|
|
205
|
+
export function defaultDesiredHooks() {
|
|
206
|
+
const base = '"$CLAUDE_PROJECT_DIR"/.claude/hooks';
|
|
207
|
+
return [
|
|
208
|
+
{
|
|
209
|
+
event: 'PreToolUse',
|
|
210
|
+
matcher: 'Bash',
|
|
211
|
+
hooks: [
|
|
212
|
+
{ type: 'command', command: `${base}/dangerous-bash-interceptor.sh`, timeout: 10000, statusMessage: 'Checking command safety...' },
|
|
213
|
+
{ type: 'command', command: `${base}/env-file-protection.sh`, timeout: 5000, statusMessage: 'Checking for .env file reads...' },
|
|
214
|
+
{ type: 'command', command: `${base}/dependency-audit-gate.sh`, timeout: 15000, statusMessage: 'Verifying package exists...' },
|
|
215
|
+
{ type: 'command', command: `${base}/security-disclosure-gate.sh`, timeout: 5000, statusMessage: 'Checking disclosure policy...' },
|
|
216
|
+
{ type: 'command', command: `${base}/pr-issue-link-gate.sh`, timeout: 5000, statusMessage: 'Checking PR for issue reference...' },
|
|
217
|
+
{ type: 'command', command: `${base}/attribution-advisory.sh`, timeout: 5000, statusMessage: 'Checking for AI attribution...' },
|
|
218
|
+
{ type: 'command', command: `${base}/push-review-gate.sh`, timeout: 30000, statusMessage: 'Running push review gate...' },
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
event: 'PreToolUse',
|
|
223
|
+
matcher: 'Write|Edit',
|
|
224
|
+
hooks: [
|
|
225
|
+
{ type: 'command', command: `${base}/secret-scanner.sh`, timeout: 15000, statusMessage: 'Scanning for credentials...' },
|
|
226
|
+
{ type: 'command', command: `${base}/settings-protection.sh`, timeout: 5000, statusMessage: 'Checking settings protection...' },
|
|
227
|
+
{ type: 'command', command: `${base}/blocked-paths-enforcer.sh`, timeout: 5000, statusMessage: 'Checking blocked paths...' },
|
|
228
|
+
{ type: 'command', command: `${base}/changeset-security-gate.sh`, timeout: 5000, statusMessage: 'Checking changeset for security leaks...' },
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
event: 'PostToolUse',
|
|
233
|
+
matcher: 'Write|Edit',
|
|
234
|
+
hooks: [
|
|
235
|
+
{ type: 'command', command: `${base}/architecture-review-gate.sh`, timeout: 10000, statusMessage: 'Checking architecture impact...' },
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHA-256 helpers for the install manifest (G12). Lowercase hex, 64 chars.
|
|
3
|
+
*
|
|
4
|
+
* Reads source files via streaming so large files don't balloon memory —
|
|
5
|
+
* even though our canonical files are all small, the same helper is used
|
|
6
|
+
* for consumer-side drift detection and we don't want a surprise.
|
|
7
|
+
*/
|
|
8
|
+
export declare function sha256OfBuffer(buf: Buffer | string): string;
|
|
9
|
+
export declare function sha256OfFile(filePath: string): Promise<string>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHA-256 helpers for the install manifest (G12). Lowercase hex, 64 chars.
|
|
3
|
+
*
|
|
4
|
+
* Reads source files via streaming so large files don't balloon memory —
|
|
5
|
+
* even though our canonical files are all small, the same helper is used
|
|
6
|
+
* for consumer-side drift detection and we don't want a surprise.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { createReadStream } from 'node:fs';
|
|
10
|
+
export function sha256OfBuffer(buf) {
|
|
11
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
12
|
+
}
|
|
13
|
+
export function sha256OfFile(filePath) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const hash = createHash('sha256');
|
|
16
|
+
const stream = createReadStream(filePath);
|
|
17
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
18
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
19
|
+
stream.on('error', reject);
|
|
20
|
+
});
|
|
21
|
+
}
|
package/dist/cli/serve.d.ts
CHANGED
|
@@ -1 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea serve` — start the MCP gateway.
|
|
3
|
+
*
|
|
4
|
+
* Loads `.rea/policy.yaml` and `.rea/registry.yaml`, builds the middleware
|
|
5
|
+
* chain, spawns downstream children from the registry, and connects an upstream
|
|
6
|
+
* stdio MCP server that clients (Claude Code, Helix, etc.) can talk to.
|
|
7
|
+
*
|
|
8
|
+
* Signals: SIGTERM and SIGINT both trigger a graceful shutdown. We do NOT exit
|
|
9
|
+
* on uncaughtException — that path is owned by `src/cli/index.ts`. If the
|
|
10
|
+
* gateway itself throws during startup we log and exit 1.
|
|
11
|
+
*/
|
|
1
12
|
export declare function runServe(): Promise<void>;
|
package/dist/cli/serve.js
CHANGED
|
@@ -1,19 +1,85 @@
|
|
|
1
1
|
import { loadPolicy } from '../policy/loader.js';
|
|
2
|
-
import {
|
|
2
|
+
import { loadRegistry } from '../registry/loader.js';
|
|
3
|
+
import { createGateway } from '../gateway/server.js';
|
|
4
|
+
import { CodexProbe } from '../gateway/observability/codex-probe.js';
|
|
5
|
+
import { POLICY_FILE, REGISTRY_FILE, err, exitWithMissingPolicy, log, reaPath, warn, } from './utils.js';
|
|
6
|
+
/**
|
|
7
|
+
* `rea serve` — start the MCP gateway.
|
|
8
|
+
*
|
|
9
|
+
* Loads `.rea/policy.yaml` and `.rea/registry.yaml`, builds the middleware
|
|
10
|
+
* chain, spawns downstream children from the registry, and connects an upstream
|
|
11
|
+
* stdio MCP server that clients (Claude Code, Helix, etc.) can talk to.
|
|
12
|
+
*
|
|
13
|
+
* Signals: SIGTERM and SIGINT both trigger a graceful shutdown. We do NOT exit
|
|
14
|
+
* on uncaughtException — that path is owned by `src/cli/index.ts`. If the
|
|
15
|
+
* gateway itself throws during startup we log and exit 1.
|
|
16
|
+
*/
|
|
3
17
|
export async function runServe() {
|
|
4
18
|
const baseDir = process.cwd();
|
|
5
19
|
const policyPath = reaPath(baseDir, POLICY_FILE);
|
|
20
|
+
const registryPath = reaPath(baseDir, REGISTRY_FILE);
|
|
21
|
+
let policy;
|
|
6
22
|
try {
|
|
7
|
-
|
|
8
|
-
log(`MCP gateway not yet implemented — install complete, policy loaded (profile=${policy.profile}, autonomy=${policy.autonomy_level}).`);
|
|
9
|
-
process.exit(0);
|
|
23
|
+
policy = loadPolicy(baseDir);
|
|
10
24
|
}
|
|
11
25
|
catch (e) {
|
|
12
26
|
const message = e instanceof Error ? e.message : String(e);
|
|
13
|
-
if (message.includes('not found'))
|
|
27
|
+
if (message.includes('not found'))
|
|
14
28
|
exitWithMissingPolicy(policyPath);
|
|
15
|
-
}
|
|
16
29
|
err(`Failed to load policy: ${message}`);
|
|
17
30
|
process.exit(1);
|
|
18
31
|
}
|
|
32
|
+
let registry;
|
|
33
|
+
try {
|
|
34
|
+
registry = loadRegistry(baseDir);
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
38
|
+
if (message.includes('not found')) {
|
|
39
|
+
err(`Registry file not found: ${registryPath}`);
|
|
40
|
+
console.error('');
|
|
41
|
+
console.error(' Run `rea init` to create an empty registry, then edit it to declare downstream servers.');
|
|
42
|
+
console.error('');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
err(`Failed to load registry: ${message}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const handle = createGateway({ baseDir, policy, registry });
|
|
49
|
+
// G11.3 — Codex availability probe. Observational only: a failed probe
|
|
50
|
+
// NEVER fail-closes the gateway at startup. When the policy explicitly
|
|
51
|
+
// opts out of Codex (`review.codex_required: false`), skip the probe
|
|
52
|
+
// entirely — there are no Codex calls to observe, so the probe would be
|
|
53
|
+
// noise on stderr.
|
|
54
|
+
const codexRequired = policy.review?.codex_required !== false;
|
|
55
|
+
let codexProbe;
|
|
56
|
+
if (codexRequired) {
|
|
57
|
+
codexProbe = new CodexProbe();
|
|
58
|
+
const initialState = await codexProbe.probe();
|
|
59
|
+
if (!initialState.cli_responsive) {
|
|
60
|
+
warn(`Codex probe failed — push-gate will use fallback reviewer path if triggered (${initialState.last_error ?? 'no error detail'})`);
|
|
61
|
+
}
|
|
62
|
+
codexProbe.start();
|
|
63
|
+
}
|
|
64
|
+
const shutdown = async (signal) => {
|
|
65
|
+
log(`rea serve: received ${signal} — draining and shutting down`);
|
|
66
|
+
codexProbe?.stop();
|
|
67
|
+
try {
|
|
68
|
+
await handle.stop();
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
err(`shutdown error: ${e instanceof Error ? e.message : e}`);
|
|
72
|
+
}
|
|
73
|
+
process.exit(0);
|
|
74
|
+
};
|
|
75
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
76
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
77
|
+
log(`rea serve: policy profile=${policy.profile}, autonomy=${policy.autonomy_level}, downstream servers=${registry.servers.filter((s) => s.enabled).length}`);
|
|
78
|
+
try {
|
|
79
|
+
await handle.start();
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
err(`gateway start failed: ${e instanceof Error ? e.message : e}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
19
85
|
}
|