@bookedsolid/rea 0.6.2 → 0.8.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/pre-push +59 -4
- package/THREAT_MODEL.md +14 -0
- package/dist/cli/install/pre-push.js +3 -0
- package/dist/gateway/downstream.d.ts +11 -14
- package/dist/gateway/downstream.js +50 -18
- package/hooks/_lib/push-review-core.sh +1057 -0
- package/hooks/push-review-gate-git.sh +94 -0
- package/hooks/push-review-gate.sh +63 -988
- package/package.json +1 -1
- package/scripts/dist-regression-gate.sh +220 -0
package/.husky/pre-push
CHANGED
|
@@ -29,8 +29,22 @@
|
|
|
29
29
|
|
|
30
30
|
set -eu
|
|
31
31
|
|
|
32
|
+
# git passes the remote name as $1 to pre-push. Fall back to `origin` for
|
|
33
|
+
# direct invocation (tests, manual runs). The shared core uses the same
|
|
34
|
+
# argv_remote convention — parity required so a push to `upstream` probes
|
|
35
|
+
# `upstream/main` rather than stale `origin/main`.
|
|
36
|
+
REMOTE="${1:-origin}"
|
|
37
|
+
|
|
32
38
|
REA_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
33
39
|
|
|
40
|
+
# Well-known empty-tree SHA: `git hash-object -t tree /dev/null`. Every git
|
|
41
|
+
# installation carries this object implicitly — using it as a merge-base
|
|
42
|
+
# baseline for initial pushes lets `git diff $EMPTY_TREE $local_sha` emit
|
|
43
|
+
# the complete change set against a truly-empty tree. The protected-path
|
|
44
|
+
# check then sees every file in the initial push, so a first push of
|
|
45
|
+
# protected-path changes to a fresh remote is still gated.
|
|
46
|
+
EMPTY_TREE='4b825dc642cb6eb9a060e54bf8d69288fbee4904'
|
|
47
|
+
|
|
34
48
|
if [ -f "${REA_ROOT}/.rea/HALT" ]; then
|
|
35
49
|
# POSIX `head` does not specify `-c`; use awk for the first line. HALT is
|
|
36
50
|
# a short reason string, so the first line is enough for display.
|
|
@@ -81,14 +95,55 @@ while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
|
|
|
81
95
|
|
|
82
96
|
# Determine merge base. If remote is new (remote_sha is zeros), diff against
|
|
83
97
|
# the default branch; else against remote_sha.
|
|
98
|
+
#
|
|
99
|
+
# Anchor on a REMOTE-TRACKING ref (refs/remotes/<remote>/<name>), NOT a bare
|
|
100
|
+
# branch name. A bare `main` resolves to refs/heads/main, which the pusher
|
|
101
|
+
# controls locally — a local main fast-forwarded to the feature tip would
|
|
102
|
+
# give merge-base main <local_sha> == local_sha and silently collapse the
|
|
103
|
+
# diff to empty. Remote-tracking refs are server-authoritative from the
|
|
104
|
+
# last fetch and cannot be tampered with locally.
|
|
105
|
+
#
|
|
106
|
+
# Fallback order when $REMOTE/HEAD is not set (common on shallow or mirror
|
|
107
|
+
# clones): probe $REMOTE/main then $REMOTE/master via rev-parse. If neither
|
|
108
|
+
# exists — initial push to a fresh remote with no tracking refs yet — use
|
|
109
|
+
# the well-known EMPTY_TREE as the baseline so the diff covers the FULL
|
|
110
|
+
# change set. This keeps the protected-path check honest on first push
|
|
111
|
+
# (prior versions of this patch `continue`d here, which was a fail-open
|
|
112
|
+
# flagged as HIGH by adversarial review).
|
|
84
113
|
if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
|
|
85
|
-
|
|
86
|
-
[ -z "${
|
|
87
|
-
|
|
114
|
+
default_ref=$(git symbolic-ref "refs/remotes/${REMOTE}/HEAD" 2>/dev/null || printf '')
|
|
115
|
+
if [ -z "${default_ref:-}" ]; then
|
|
116
|
+
if git rev-parse --verify --quiet "refs/remotes/${REMOTE}/main" >/dev/null 2>&1; then
|
|
117
|
+
default_ref="refs/remotes/${REMOTE}/main"
|
|
118
|
+
elif git rev-parse --verify --quiet "refs/remotes/${REMOTE}/master" >/dev/null 2>&1; then
|
|
119
|
+
default_ref="refs/remotes/${REMOTE}/master"
|
|
120
|
+
else
|
|
121
|
+
default_ref=""
|
|
122
|
+
fi
|
|
123
|
+
fi
|
|
124
|
+
if [ -n "${default_ref:-}" ]; then
|
|
125
|
+
base=$(git merge-base "$default_ref" "$local_sha" 2>/dev/null || printf '')
|
|
126
|
+
else
|
|
127
|
+
# Bootstrap: no remote-tracking ref exists at all. Use the empty-tree
|
|
128
|
+
# baseline so the diff covers every file in the push. git diff accepts
|
|
129
|
+
# a tree SHA as the left-hand side.
|
|
130
|
+
base="$EMPTY_TREE"
|
|
131
|
+
fi
|
|
88
132
|
else
|
|
89
133
|
base=$(git merge-base "$remote_sha" "$local_sha" 2>/dev/null || printf '')
|
|
90
134
|
fi
|
|
91
|
-
|
|
135
|
+
# Fail CLOSED on empty merge-base when a remote ref DID resolve. The
|
|
136
|
+
# 0.4.0..0.6.2 behavior here was to `continue` — a silent bypass. A push
|
|
137
|
+
# whose history is unrelated to origin (or any transient git failure at
|
|
138
|
+
# merge-base resolution) would pass through without the protected-path
|
|
139
|
+
# check ever running. Refuse instead and force the operator to resolve it.
|
|
140
|
+
if [ -z "${base:-}" ]; then
|
|
141
|
+
printf 'PUSH BLOCKED: could not resolve merge-base between %s and %s (local_ref=%s remote_ref=%s).\n' \
|
|
142
|
+
"${remote_sha:-<new>}" "${local_sha:-<missing>}" "${local_ref:-<unknown>}" "${remote_ref:-<unknown>}" >&2
|
|
143
|
+
printf ' Run `git fetch %s` and retry. If the history is genuinely unrelated\n' "$REMOTE" >&2
|
|
144
|
+
printf ' to %s (e.g. grafted branch), resolve manually before pushing.\n' "$REMOTE" >&2
|
|
145
|
+
exit 1
|
|
146
|
+
fi
|
|
92
147
|
|
|
93
148
|
# Check if the diff touches protected paths.
|
|
94
149
|
if git diff --name-only "$base" "$local_sha" 2>/dev/null | grep -qE "$PROTECTED_RE"; then
|
package/THREAT_MODEL.md
CHANGED
|
@@ -107,6 +107,20 @@ Downstream MCP servers are treated as untrusted by default. Codex plugin *invoca
|
|
|
107
107
|
|
|
108
108
|
---
|
|
109
109
|
|
|
110
|
+
### 5.2a `CLAUDE_PROJECT_DIR` as advisory-only signal (BUG-012, 0.6.2)
|
|
111
|
+
|
|
112
|
+
**Threat:** The `push-review-gate.sh` and `commit-review-gate.sh` hooks need to know the rea repository root so that (a) cross-repo invocations from consumer repositories short-circuit cleanly, and (b) HALT / policy enforcement always evaluates the correct policy file. Prior to 0.6.2, the guard read the root from the `CLAUDE_PROJECT_DIR` environment variable. That variable is caller-controlled — any process invoking the hook (or any shell that has it exported in the environment) can set it to a foreign path, which the guard would then treat as rea. The result: HALT is silently bypassed, the cross-repo short-circuit fires on the wrong comparison, and policy is read from a directory the caller chose.
|
|
113
|
+
|
|
114
|
+
**Mitigations:**
|
|
115
|
+
|
|
116
|
+
- The hooks derive `REA_ROOT` from their own on-disk location using `BASH_SOURCE[0]` + `pwd -P`, then walk up to 4 parent directories looking for `.rea/policy.yaml` as the authoritative install marker. Install topology is fixed: hooks live at `<root>/.claude/hooks/<name>.sh`, so the anchor is forge-resistant — a caller cannot relocate the hook file without filesystem write access to the rea install, which is already protected by `settings-protection.sh` and `blocked-paths` enforcement.
|
|
117
|
+
- `CLAUDE_PROJECT_DIR` is retained only as an advisory signal. When set and the realpath differs from the script-derived `REA_ROOT`, the hook emits a stderr advisory and continues using the script-derived value. It is never compared for short-circuit, never used to select the policy file, and never used to locate HALT.
|
|
118
|
+
- The cross-repo guard compares `git rev-parse --git-common-dir` on both sides (not path prefixes). Mixed state (one side git, one non-git) fails **closed** — the gate runs — rather than falling through to path-prefix. Only the both-non-git case still uses path-prefix, matching the documented 0.5.1 non-git escape hatch.
|
|
119
|
+
|
|
120
|
+
**Residual risk:** If a local attacker has write access to the rea install directory they can move or replace the hook file, which would change `SCRIPT_DIR` and therefore `REA_ROOT`. This is equivalent to tampering with any other hook contents (`settings-protection.sh` already addresses it) and lies outside the `CLAUDE_PROJECT_DIR` threat class. Ref: `__tests__/hooks/push-review-gate-cross-repo.test.ts` "BUG-012: foreign CLAUDE_PROJECT_DIR does NOT bypass HALT".
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
110
124
|
### 5.3 Policy Tampering
|
|
111
125
|
|
|
112
126
|
**Threat:** An attacker or rogue agent modifies `policy.yaml` to elevate `autonomy_level` above `max_autonomy_level`, removes blocked paths, or disables `block_ai_attribution`.
|
|
@@ -268,6 +268,9 @@ const KNOWN_LEGACY_HUSKY_SHA256 = new Set([
|
|
|
268
268
|
'9d4885b64f50dd91887c2c6b4d17e3aa91b0be5da8e842ca8915bec1bf369de5',
|
|
269
269
|
// Initial publication (commit b513760, G6 MVP).
|
|
270
270
|
'1ee21164ccce628a1ef85c313d09afdcdb8560efd761ec64b046cca6cc319cba',
|
|
271
|
+
// 0.7.0 — Codex pass-2 empty-tree baseline + $1 remote honoring +
|
|
272
|
+
// fail-closed on empty merge-base when a remote ref did resolve.
|
|
273
|
+
'84449e17a04986f3a6580eeb6fb9192cc6d8fabb099cd41cab0574a800c82056',
|
|
271
274
|
]);
|
|
272
275
|
/**
|
|
273
276
|
* True when `content` contains a POSIX shell construct that detects
|
|
@@ -75,6 +75,7 @@ export interface BuiltChildEnv {
|
|
|
75
75
|
}
|
|
76
76
|
export declare function buildChildEnv(config: RegistryServer, hostEnv?: NodeJS.ProcessEnv): BuiltChildEnv;
|
|
77
77
|
export declare class DownstreamConnection {
|
|
78
|
+
#private;
|
|
78
79
|
private readonly config;
|
|
79
80
|
/**
|
|
80
81
|
* Optional structured logger (G5). When omitted, connection lifecycle
|
|
@@ -93,13 +94,6 @@ export declare class DownstreamConnection {
|
|
|
93
94
|
/** Epoch ms of the last successful reconnect. Used by the flapping guard. */
|
|
94
95
|
private lastReconnectAt;
|
|
95
96
|
private health;
|
|
96
|
-
/**
|
|
97
|
-
* The most recent error observed on this connection (connect or call
|
|
98
|
-
* failure). Surfaced via `__rea__health` so callers can diagnose an empty
|
|
99
|
-
* tool catalog without digging through stderr logs. Set to `null` after a
|
|
100
|
-
* successful connect/reconnect.
|
|
101
|
-
*/
|
|
102
|
-
private lastErrorMessage;
|
|
103
97
|
constructor(config: RegistryServer,
|
|
104
98
|
/**
|
|
105
99
|
* Optional structured logger (G5). When omitted, connection lifecycle
|
|
@@ -115,13 +109,16 @@ export declare class DownstreamConnection {
|
|
|
115
109
|
* Last error observed, or null if the connection has never failed (or fully
|
|
116
110
|
* recovered).
|
|
117
111
|
*
|
|
118
|
-
* BUG-011 (0.6.2): cap exposure via
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
112
|
+
* BUG-011 (0.6.2) → BUG-014 (0.7.0): cap exposure via
|
|
113
|
+
* `boundedDiagnosticString`. 0.6.2 applied the bound at *read*, which
|
|
114
|
+
* meant every assignment site was trusted to eventually flow through
|
|
115
|
+
* this getter. 0.7.0 moves the bound to the private *setter* above, so
|
|
116
|
+
* the invariant is structural — every `this.#lastErrorMessage = x` write
|
|
117
|
+
* is bounded at assignment time regardless of how many assignment sites
|
|
118
|
+
* exist or where they live. We keep the read-side bound as cheap
|
|
119
|
+
* defense-in-depth (it's a no-op for already-bounded strings and costs
|
|
120
|
+
* O(length) only if a future intra-class edit writes directly to the
|
|
121
|
+
* backing field instead of going through the setter).
|
|
125
122
|
*/
|
|
126
123
|
get lastError(): string | null;
|
|
127
124
|
connect(): Promise<void>;
|
|
@@ -113,8 +113,36 @@ export class DownstreamConnection {
|
|
|
113
113
|
* failure). Surfaced via `__rea__health` so callers can diagnose an empty
|
|
114
114
|
* tool catalog without digging through stderr logs. Set to `null` after a
|
|
115
115
|
* successful connect/reconnect.
|
|
116
|
+
*
|
|
117
|
+
* BUG-014 (0.7.0): true ECMAScript private field + private accessor pair.
|
|
118
|
+
* Every internal write `this.#lastErrorMessage = x` goes through the
|
|
119
|
+
* setter, which applies `boundedDiagnosticString` at assignment time.
|
|
120
|
+
* This converts the prior "bound-at-read" invariant (see `get lastError`
|
|
121
|
+
* below, which was the single chokepoint before 0.7.0) into a structural
|
|
122
|
+
* property: no matter how many assignment sites exist, every one produces
|
|
123
|
+
* a bounded string. A future refactor can add new sites without needing
|
|
124
|
+
* to know the bound exists — the setter enforces it.
|
|
125
|
+
*
|
|
126
|
+
* The backing field `#lastErrorBacking` is the raw storage; only the
|
|
127
|
+
* setter writes to it. External code cannot reach either name because
|
|
128
|
+
* both are ES-private (`#`), not TS-private.
|
|
116
129
|
*/
|
|
117
|
-
|
|
130
|
+
#lastErrorBacking = null;
|
|
131
|
+
get #lastErrorMessage() {
|
|
132
|
+
return this.#lastErrorBacking;
|
|
133
|
+
}
|
|
134
|
+
set #lastErrorMessage(msg) {
|
|
135
|
+
if (msg !== null && typeof msg !== 'string') {
|
|
136
|
+
// BUG-014 defense-in-depth: the TS type gate is strict, but a future
|
|
137
|
+
// refactor (or an `as unknown as string` cast) could slip a non-string
|
|
138
|
+
// through. `boundedDiagnosticString` calls `.length` / `.slice` on the
|
|
139
|
+
// input — a non-string would throw or silently corrupt the field. Fail
|
|
140
|
+
// loud instead.
|
|
141
|
+
throw new TypeError(`DownstreamConnection#lastErrorMessage: expected string | null, got ${typeof msg}`);
|
|
142
|
+
}
|
|
143
|
+
this.#lastErrorBacking =
|
|
144
|
+
msg === null ? null : boundedDiagnosticString(msg);
|
|
145
|
+
}
|
|
118
146
|
constructor(config,
|
|
119
147
|
/**
|
|
120
148
|
* Optional structured logger (G5). When omitted, connection lifecycle
|
|
@@ -139,18 +167,22 @@ export class DownstreamConnection {
|
|
|
139
167
|
* Last error observed, or null if the connection has never failed (or fully
|
|
140
168
|
* recovered).
|
|
141
169
|
*
|
|
142
|
-
* BUG-011 (0.6.2): cap exposure via
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
170
|
+
* BUG-011 (0.6.2) → BUG-014 (0.7.0): cap exposure via
|
|
171
|
+
* `boundedDiagnosticString`. 0.6.2 applied the bound at *read*, which
|
|
172
|
+
* meant every assignment site was trusted to eventually flow through
|
|
173
|
+
* this getter. 0.7.0 moves the bound to the private *setter* above, so
|
|
174
|
+
* the invariant is structural — every `this.#lastErrorMessage = x` write
|
|
175
|
+
* is bounded at assignment time regardless of how many assignment sites
|
|
176
|
+
* exist or where they live. We keep the read-side bound as cheap
|
|
177
|
+
* defense-in-depth (it's a no-op for already-bounded strings and costs
|
|
178
|
+
* O(length) only if a future intra-class edit writes directly to the
|
|
179
|
+
* backing field instead of going through the setter).
|
|
149
180
|
*/
|
|
150
181
|
get lastError() {
|
|
151
|
-
|
|
182
|
+
const raw = this.#lastErrorMessage;
|
|
183
|
+
if (raw === null)
|
|
152
184
|
return null;
|
|
153
|
-
return boundedDiagnosticString(
|
|
185
|
+
return boundedDiagnosticString(raw);
|
|
154
186
|
}
|
|
155
187
|
async connect() {
|
|
156
188
|
if (this.client !== null)
|
|
@@ -173,12 +205,12 @@ export class DownstreamConnection {
|
|
|
173
205
|
catch (err) {
|
|
174
206
|
this.health = 'unhealthy';
|
|
175
207
|
const msg = `failed to resolve env for downstream "${this.config.name}": ${err instanceof Error ? err.message : err}`;
|
|
176
|
-
this
|
|
208
|
+
this.#lastErrorMessage = msg;
|
|
177
209
|
throw new Error(msg);
|
|
178
210
|
}
|
|
179
211
|
if (built.missing.length > 0) {
|
|
180
212
|
this.health = 'unhealthy';
|
|
181
|
-
this
|
|
213
|
+
this.#lastErrorMessage = `missing env: ${built.missing.join(', ')}`;
|
|
182
214
|
// One line per missing var so grep/jq users can find the exact gap.
|
|
183
215
|
// We intentionally do NOT log the env key name's VALUE (there is none —
|
|
184
216
|
// it's unresolved) nor any other env values.
|
|
@@ -198,12 +230,12 @@ export class DownstreamConnection {
|
|
|
198
230
|
await client.connect(transport);
|
|
199
231
|
this.client = client;
|
|
200
232
|
this.health = 'healthy';
|
|
201
|
-
this
|
|
233
|
+
this.#lastErrorMessage = null;
|
|
202
234
|
}
|
|
203
235
|
catch (err) {
|
|
204
236
|
this.health = 'unhealthy';
|
|
205
237
|
const msg = `failed to connect to downstream "${this.config.name}" (${this.config.command}): ${err instanceof Error ? err.message : err}`;
|
|
206
|
-
this
|
|
238
|
+
this.#lastErrorMessage = msg;
|
|
207
239
|
throw new Error(msg);
|
|
208
240
|
}
|
|
209
241
|
}
|
|
@@ -230,7 +262,7 @@ export class DownstreamConnection {
|
|
|
230
262
|
// this, a connection that failed once and then recovered on the very
|
|
231
263
|
// next call (same client, no reconnect) would forever report the old
|
|
232
264
|
// error via `__rea__health`, misleading operators about live state.
|
|
233
|
-
this
|
|
265
|
+
this.#lastErrorMessage = null;
|
|
234
266
|
return result;
|
|
235
267
|
}
|
|
236
268
|
catch (err) {
|
|
@@ -253,7 +285,7 @@ export class DownstreamConnection {
|
|
|
253
285
|
// stamp the reconnect time so flap-guard can refuse rapid repeats.
|
|
254
286
|
this.reconnectAttempted = false;
|
|
255
287
|
this.lastReconnectAt = Date.now();
|
|
256
|
-
this
|
|
288
|
+
this.#lastErrorMessage = null;
|
|
257
289
|
this.logger?.info({
|
|
258
290
|
event: 'downstream.reconnected',
|
|
259
291
|
server_name: this.config.name,
|
|
@@ -264,7 +296,7 @@ export class DownstreamConnection {
|
|
|
264
296
|
catch (reconnectErr) {
|
|
265
297
|
this.health = 'unhealthy';
|
|
266
298
|
const errMsg = reconnectErr instanceof Error ? reconnectErr.message : String(reconnectErr);
|
|
267
|
-
this
|
|
299
|
+
this.#lastErrorMessage = errMsg;
|
|
268
300
|
this.logger?.error({
|
|
269
301
|
event: 'downstream.reconnect_failed',
|
|
270
302
|
server_name: this.config.name,
|
|
@@ -275,7 +307,7 @@ export class DownstreamConnection {
|
|
|
275
307
|
}
|
|
276
308
|
}
|
|
277
309
|
this.health = 'unhealthy';
|
|
278
|
-
this
|
|
310
|
+
this.#lastErrorMessage = message;
|
|
279
311
|
this.logger?.error({
|
|
280
312
|
event: 'downstream.call_failed',
|
|
281
313
|
server_name: this.config.name,
|