@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 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
- default_branch=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
86
- [ -z "${default_branch:-}" ] && default_branch="main"
87
- base=$(git merge-base "$default_branch" "$local_sha" 2>/dev/null || printf '')
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
- [ -z "${base:-}" ] && continue
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 `boundedDiagnosticString`. An
119
- * adversarial downstream MCP can throw `new Error(huge_string)`, and that
120
- * raw message flows from `err.message` into `lastErrorMessage` at the
121
- * assignment sites below. Bounding here means every consumer of the
122
- * getterthe `__rea__health` snapshot, diagnostic logs, future status
123
- * dashboards sees a bounded, UTF-16-safe string. `sanitizeHealthSnapshot`
124
- * applies the same cap for defense-in-depth.
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
- lastErrorMessage = null;
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 `boundedDiagnosticString`. An
143
- * adversarial downstream MCP can throw `new Error(huge_string)`, and that
144
- * raw message flows from `err.message` into `lastErrorMessage` at the
145
- * assignment sites below. Bounding here means every consumer of the
146
- * getterthe `__rea__health` snapshot, diagnostic logs, future status
147
- * dashboards sees a bounded, UTF-16-safe string. `sanitizeHealthSnapshot`
148
- * applies the same cap for defense-in-depth.
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
- if (this.lastErrorMessage === null)
182
+ const raw = this.#lastErrorMessage;
183
+ if (raw === null)
152
184
  return null;
153
- return boundedDiagnosticString(this.lastErrorMessage);
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.lastErrorMessage = msg;
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.lastErrorMessage = `missing env: ${built.missing.join(', ')}`;
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.lastErrorMessage = null;
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.lastErrorMessage = msg;
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.lastErrorMessage = null;
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.lastErrorMessage = null;
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.lastErrorMessage = errMsg;
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.lastErrorMessage = message;
310
+ this.#lastErrorMessage = message;
279
311
  this.logger?.error({
280
312
  event: 'downstream.call_failed',
281
313
  server_name: this.config.name,