@bookedsolid/rea 0.20.0 → 0.21.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/dist/cli/init.js CHANGED
@@ -297,6 +297,16 @@ function writePolicyYaml(targetDir, config, layered) {
297
297
  lines.push(` max_bash_output_lines: ${cp.max_bash_output_lines}`);
298
298
  }
299
299
  }
300
+ // 0.20.1+ helix-round-N P2: emit architecture_review.patterns when
301
+ // the layered profile declared them. Consumers without patterns see
302
+ // a silent no-op from architecture-review-gate.sh.
303
+ if (layered.architecture_review?.patterns !== undefined) {
304
+ lines.push(`architecture_review:`);
305
+ lines.push(` patterns:`);
306
+ for (const p of layered.architecture_review.patterns) {
307
+ lines.push(` - ${JSON.stringify(p)}`);
308
+ }
309
+ }
300
310
  // 0.18.1+ helixir #9: emit audit.rotation when the layered profile
301
311
  // declared it. Empty `rotation: {}` opts in to documented defaults
302
312
  // (50 MiB / 30 days); explicit values override.
@@ -363,6 +363,11 @@ export async function runPushGate(deps) {
363
363
  const cached = cacheLookup.entry;
364
364
  const cachedBlocked = cached.verdict === 'blocking'
365
365
  || (cached.verdict === 'concerns' && policy.concerns_blocks && !isConcernsOverrideSet(env));
366
+ // 0.19.1 P3-3 (code-reviewer): emit EVT_CACHE_HIT (forensic detail
367
+ // for the cache layer specifically) AND EVT_REVIEWED (the canonical
368
+ // verdict event with `cache_hit: true` metadata). Operators
369
+ // grepping `rea.push_gate.reviewed` for verdict-stability dashboards
370
+ // see every push, including cached ones.
366
371
  await safeAppend(appendAuditFn, deps.baseDir, EVT_CACHE_HIT, fullPolicy, {
367
372
  verdict: cached.verdict,
368
373
  finding_count: cached.finding_count,
@@ -374,16 +379,23 @@ export async function runPushGate(deps) {
374
379
  cached_reasoning_effort: cached.reasoning_effort,
375
380
  blocked: cachedBlocked,
376
381
  });
382
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, fullPolicy, {
383
+ verdict: cached.verdict,
384
+ finding_count: cached.finding_count,
385
+ base_ref: base.ref,
386
+ base_source: base.source,
387
+ head_sha: headSha,
388
+ blocked: cachedBlocked,
389
+ cache_hit: true,
390
+ cached_reviewed_at: cached.reviewed_at,
391
+ cached_model: cached.model,
392
+ cached_reasoning_effort: cached.reasoning_effort,
393
+ });
394
+ // 0.19.1 P3-1 (backend): simplified return shape. Verdict maps
395
+ // 1:1 to status; cachedBlocked maps 1:1 to exitCode. The prior
396
+ // nested ternary recomputed the same mapping in both arms.
377
397
  return {
378
- status: cachedBlocked
379
- ? cached.verdict === 'blocking'
380
- ? 'blocking'
381
- : 'concerns'
382
- : cached.verdict === 'blocking'
383
- ? 'blocking'
384
- : cached.verdict === 'concerns'
385
- ? 'concerns'
386
- : 'pass',
398
+ status: cached.verdict,
387
399
  exitCode: cachedBlocked ? 2 : 0,
388
400
  summary: `${cached.verdict}: ${cached.finding_count} finding(s) (cached)`,
389
401
  verdict: cached.verdict,
@@ -59,6 +59,16 @@ import type { Verdict as ReviewVerdict } from './findings.js';
59
59
  export declare const VERDICT_CACHE_FILE = "last-review.cache.json";
60
60
  export declare const VERDICT_CACHE_SCHEMA_VERSION: 2;
61
61
  export declare const DEFAULT_CACHE_TTL_MS: number;
62
+ /**
63
+ * Soft cap on cached entry count before `writeVerdict` opportunistically
64
+ * prunes expired entries (0.19.1 backend-engineer P2-3). Keeps the
65
+ * cache file from growing unbounded over months of pushes against many
66
+ * SHAs — at 500 entries × ~200 bytes/entry ≈ 100 KB, we proactively
67
+ * drop expired entries on the next write. The prune is best-effort:
68
+ * if every entry is unexpired we accept the larger file rather than
69
+ * dropping fresh durable verdicts.
70
+ */
71
+ export declare const VERDICT_CACHE_PRUNE_THRESHOLD = 500;
62
72
  export interface VerdictCacheEntry {
63
73
  verdict: ReviewVerdict;
64
74
  finding_count: number;
@@ -62,6 +62,16 @@ import { withAuditLock } from '../../audit/fs.js';
62
62
  export const VERDICT_CACHE_FILE = 'last-review.cache.json';
63
63
  export const VERDICT_CACHE_SCHEMA_VERSION = 2;
64
64
  export const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1_000; // 24h
65
+ /**
66
+ * Soft cap on cached entry count before `writeVerdict` opportunistically
67
+ * prunes expired entries (0.19.1 backend-engineer P2-3). Keeps the
68
+ * cache file from growing unbounded over months of pushes against many
69
+ * SHAs — at 500 entries × ~200 bytes/entry ≈ 100 KB, we proactively
70
+ * drop expired entries on the next write. The prune is best-effort:
71
+ * if every entry is unexpired we accept the larger file rather than
72
+ * dropping fresh durable verdicts.
73
+ */
74
+ export const VERDICT_CACHE_PRUNE_THRESHOLD = 500;
65
75
  /**
66
76
  * Read the cache file and look up `head_sha`. Missing file, malformed
67
77
  * JSON, missing entry, and unsupported schema_version all resolve to a
@@ -110,13 +120,40 @@ export async function writeVerdict(baseDir, headSha, entry) {
110
120
  throw new VerdictCacheForeignSchemaError(cachePath);
111
121
  }
112
122
  const existing = readCacheFile(baseDir);
123
+ const merged = {
124
+ ...(existing?.entries ?? {}),
125
+ [headSha]: entry,
126
+ };
127
+ // 0.19.1 P2-3 (backend-engineer): opportunistic prune.
128
+ // When the cache crosses the soft threshold, drop entries whose
129
+ // own ttl_ms has expired before writing. Cheap walk; bounded to
130
+ // O(n) at write time only when n > threshold. Prevents the cache
131
+ // file from growing unbounded over months of pushes.
132
+ const pruned = Object.keys(merged).length > VERDICT_CACHE_PRUNE_THRESHOLD
133
+ ? _pruneExpired(merged, new Date())
134
+ : merged;
113
135
  const next = {
114
136
  schema_version: VERDICT_CACHE_SCHEMA_VERSION,
115
- entries: { ...(existing?.entries ?? {}), [headSha]: entry },
137
+ entries: pruned,
116
138
  };
117
139
  _atomicWriteJson(cachePath, next);
118
140
  });
119
141
  }
142
+ function _pruneExpired(entries, now) {
143
+ const surviving = {};
144
+ const nowMs = now.getTime();
145
+ for (const [sha, entry] of Object.entries(entries)) {
146
+ const reviewedAtMs = Date.parse(entry.reviewed_at);
147
+ if (Number.isNaN(reviewedAtMs)) {
148
+ surviving[sha] = entry;
149
+ continue;
150
+ }
151
+ if (nowMs - reviewedAtMs < entry.ttl_ms) {
152
+ surviving[sha] = entry;
153
+ }
154
+ }
155
+ return surviving;
156
+ }
120
157
  /**
121
158
  * Remove a single SHA from the cache. Returns true if the entry existed.
122
159
  */
@@ -178,6 +178,13 @@ declare const PolicySchema: z.ZodObject<{
178
178
  expose_diagnostics?: boolean | undefined;
179
179
  } | undefined;
180
180
  }>>;
181
+ architecture_review: z.ZodOptional<z.ZodObject<{
182
+ patterns: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
183
+ }, "strip", z.ZodTypeAny, {
184
+ patterns?: string[] | undefined;
185
+ }, {
186
+ patterns?: string[] | undefined;
187
+ }>>;
181
188
  }, "strict", z.ZodTypeAny, {
182
189
  version: string;
183
190
  profile: string;
@@ -228,6 +235,9 @@ declare const PolicySchema: z.ZodObject<{
228
235
  expose_diagnostics?: boolean | undefined;
229
236
  } | undefined;
230
237
  } | undefined;
238
+ architecture_review?: {
239
+ patterns?: string[] | undefined;
240
+ } | undefined;
231
241
  }, {
232
242
  version: string;
233
243
  profile: string;
@@ -278,6 +288,9 @@ declare const PolicySchema: z.ZodObject<{
278
288
  expose_diagnostics?: boolean | undefined;
279
289
  } | undefined;
280
290
  } | undefined;
291
+ architecture_review?: {
292
+ patterns?: string[] | undefined;
293
+ } | undefined;
281
294
  }>;
282
295
  /**
283
296
  * Async policy loader with TTL cache and mtime-based invalidation.
@@ -200,6 +200,17 @@ const PolicySchema = z
200
200
  redact: RedactPolicySchema.optional(),
201
201
  audit: AuditPolicySchema.optional(),
202
202
  gateway: GatewayPolicySchema.optional(),
203
+ // 0.20.1 helix-round-N P2: architecture-review-gate.sh patterns
204
+ // are now policy-driven. Pre-fix the hook hardcoded rea-internal
205
+ // source-tree patterns (`src/gateway/`, `hooks/_lib/`, etc.) which
206
+ // produced irrelevant advisory output in consumer projects.
207
+ // Empty (or unset) → silent no-op. bst-internal profile pins the
208
+ // rea-source patterns so dogfood behaves as before.
209
+ architecture_review: z
210
+ .object({
211
+ patterns: z.array(z.string()).optional(),
212
+ })
213
+ .optional(),
203
214
  })
204
215
  .strict();
205
216
  const DEFAULT_CACHE_TTL_MS = 30_000;
@@ -69,6 +69,13 @@ export declare const ProfileSchema: z.ZodObject<{
69
69
  max_age_days?: number | undefined;
70
70
  } | undefined;
71
71
  }>>;
72
+ architecture_review: z.ZodOptional<z.ZodObject<{
73
+ patterns: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
74
+ }, "strip", z.ZodTypeAny, {
75
+ patterns?: string[] | undefined;
76
+ }, {
77
+ patterns?: string[] | undefined;
78
+ }>>;
72
79
  }, "strict", z.ZodTypeAny, {
73
80
  autonomy_level?: AutonomyLevel | undefined;
74
81
  max_autonomy_level?: AutonomyLevel | undefined;
@@ -92,6 +99,9 @@ export declare const ProfileSchema: z.ZodObject<{
92
99
  max_age_days?: number | undefined;
93
100
  } | undefined;
94
101
  } | undefined;
102
+ architecture_review?: {
103
+ patterns?: string[] | undefined;
104
+ } | undefined;
95
105
  }, {
96
106
  autonomy_level?: AutonomyLevel | undefined;
97
107
  max_autonomy_level?: AutonomyLevel | undefined;
@@ -115,6 +125,9 @@ export declare const ProfileSchema: z.ZodObject<{
115
125
  max_age_days?: number | undefined;
116
126
  } | undefined;
117
127
  } | undefined;
128
+ architecture_review?: {
129
+ patterns?: string[] | undefined;
130
+ } | undefined;
118
131
  }>;
119
132
  export type Profile = z.infer<typeof ProfileSchema>;
120
133
  /** Hard defaults applied before any profile or wizard answer. */
@@ -69,6 +69,12 @@ export const ProfileSchema = z
69
69
  .optional(),
70
70
  })
71
71
  .optional(),
72
+ // 0.20.1+ profiles can declare architecture-sensitive paths.
73
+ architecture_review: z
74
+ .object({
75
+ patterns: z.array(z.string()).optional(),
76
+ })
77
+ .optional(),
72
78
  })
73
79
  .strict();
74
80
  /** Hard defaults applied before any profile or wizard answer. */
@@ -289,4 +289,16 @@ export interface Policy {
289
289
  redact?: RedactPolicy;
290
290
  audit?: AuditPolicy;
291
291
  gateway?: GatewayPolicy;
292
+ /**
293
+ * Architecture-review patterns (0.20.1+). When set, the
294
+ * `architecture-review-gate.sh` hook fires an advisory when a
295
+ * Write/Edit/MultiEdit/NotebookEdit lands on a path matching one
296
+ * of the patterns. When unset or empty, the hook is a silent no-op
297
+ * — consumers without architecture-sensitive paths see zero noise.
298
+ * bst-internal profile pins rea's own source-tree patterns
299
+ * (`src/gateway/`, `hooks/_lib/`, etc.).
300
+ */
301
+ architecture_review?: {
302
+ patterns?: string[];
303
+ };
292
304
  }
@@ -70,3 +70,78 @@ resolve_parent_realpath() {
70
70
  resolved=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved=""
71
71
  printf '%s' "$resolved"
72
72
  }
73
+
74
+ # 0.20.1 helix-021 fixes: shared helper for the Bash-tier symlink
75
+ # resolution that the Write-tier `blocked-paths-enforcer.sh` has had
76
+ # since 0.10.x. Given a project-relative LOGICAL_PATH (already
77
+ # normalized via normalize_path) and the original raw token (whose
78
+ # parent dir may exist on disk), return the resolved-symlink
79
+ # project-relative form on stdout.
80
+ #
81
+ # Returns:
82
+ # - The empty string if the parent doesn't exist (caller can't
83
+ # resolve, falls back to LOGICAL_PATH only).
84
+ # - A literal `__rea_outside_root__:<resolved>` sentinel when the
85
+ # parent's realpath escapes REA_ROOT. Caller refuses with the
86
+ # same shape as the existing outside-REA_ROOT check.
87
+ # - The project-relative resolved form (lowercased to match
88
+ # case-insensitive comparisons elsewhere) when resolution
89
+ # succeeds.
90
+ #
91
+ # Reference:
92
+ # `blocked-paths-enforcer.sh` lines ~205-238 for the Write-tier
93
+ # reference implementation that this helper backports to Bash-tier.
94
+ rea_resolved_relative_form() {
95
+ local raw_token="$1"
96
+ # Skip absolute paths whose logical form is already outside REA_ROOT
97
+ # — `/tmp/log`, `/var/log/x`, etc. The caller's logical-path check
98
+ # has already decided whether to allow or refuse based on the
99
+ # logical form. Re-running symlink resolution on these would
100
+ # produce a false "symlink resolves outside project root" refusal
101
+ # (because `/tmp` resolves to `/private/tmp` on macOS, which is
102
+ # technically outside REA_ROOT). The threat model for THIS helper
103
+ # is intra-project symlink walks: a path the caller thinks is
104
+ # under REA_ROOT but resolves elsewhere via an intermediate
105
+ # symlink. Pure external paths are out of scope.
106
+ if [[ "$raw_token" == /* ]]; then
107
+ # Canonicalize REA_ROOT for the comparison.
108
+ local rea_root_canon_for_skip
109
+ rea_root_canon_for_skip=$(cd -P -- "$REA_ROOT" 2>/dev/null && pwd -P 2>/dev/null) || rea_root_canon_for_skip="$REA_ROOT"
110
+ if [[ "$raw_token" != "$rea_root_canon_for_skip"/* && "$raw_token" != "$REA_ROOT"/* ]]; then
111
+ printf ''
112
+ return 0
113
+ fi
114
+ fi
115
+ local resolved_parent
116
+ resolved_parent=$(resolve_parent_realpath "$raw_token")
117
+ if [[ -z "$resolved_parent" ]]; then
118
+ printf ''
119
+ return 0
120
+ fi
121
+ # Canonicalize REA_ROOT the same way `pwd -P` canonicalized
122
+ # `resolved_parent`. macOS resolves `/var/folders/...` to
123
+ # `/private/var/folders/...` because `/var` is a symlink to
124
+ # `/private/var`; without this normalization the prefix-equality
125
+ # below produces a false outside-REA_ROOT sentinel for every path
126
+ # under a tmpdir that started life as `/var/...`. Memo-friendly:
127
+ # `cd -P` runs once per hook invocation; the cost is bounded.
128
+ local rea_root_canon
129
+ rea_root_canon=$(cd -P -- "$REA_ROOT" 2>/dev/null && pwd -P 2>/dev/null) || rea_root_canon="$REA_ROOT"
130
+ # Outside-REA_ROOT guard. The resolve may walk a symlink that exits
131
+ # the project tree entirely; emit the sentinel so the caller
132
+ # refuses with the same wording as the logical-path traversal
133
+ # check.
134
+ if [[ "$resolved_parent" != "$rea_root_canon" && "$resolved_parent" != "$rea_root_canon"/* ]]; then
135
+ printf '__rea_outside_root__:%s/%s' "$resolved_parent" "$(basename -- "$raw_token")"
136
+ return 0
137
+ fi
138
+ # Strip canonical REA_ROOT prefix, append basename, lowercase to
139
+ # match rea_path_is_protected's case-insensitive comparison.
140
+ local rel
141
+ if [[ "$resolved_parent" == "$rea_root_canon" ]]; then
142
+ rel="$(basename -- "$raw_token")"
143
+ else
144
+ rel="${resolved_parent#"$rea_root_canon"/}/$(basename -- "$raw_token")"
145
+ fi
146
+ printf '%s' "$rel" | tr '[:upper:]' '[:lower:]'
147
+ }
@@ -49,6 +49,11 @@ REA_PROTECTED_PATTERNS_FULL=(
49
49
  # since 0.18.1. A forged entry would skip codex on next push of that
50
50
  # SHA. Protect it like the kill-switch.
51
51
  '.rea/last-review.cache.json'
52
+ # 0.20.1 round-N P1: last-review.json is the operator's only forensic
53
+ # snapshot of the most recent codex review. A forged entry presents
54
+ # a fake "PASS" verdict to operators reading the file directly, and
55
+ # to any future tooling that consults it. Protect alongside the cache.
56
+ '.rea/last-review.json'
52
57
  )
53
58
 
54
59
  # Kill-switch invariants — never relaxable. Subset of FULL.
@@ -57,6 +62,7 @@ REA_KILL_SWITCH_INVARIANTS=(
57
62
  '.rea/policy.yaml'
58
63
  '.rea/HALT'
59
64
  '.rea/last-review.cache.json'
65
+ '.rea/last-review.json'
60
66
  )
61
67
 
62
68
  # Effective patterns after applying the relax list. Computed lazily on
@@ -50,15 +50,29 @@ source "$(dirname "$0")/_lib/path-normalize.sh"
50
50
  FILE_PATH=$(normalize_path "$FILE_PATH")
51
51
 
52
52
  # ── 6. Check architecture-sensitive paths ─────────────────────────────────────
53
- ARCH_PATTERNS=(
54
- 'src/types/'
55
- 'src/gateway/'
56
- 'src/config/'
57
- 'src/cli/commands/init/'
58
- 'hooks/_lib/'
59
- 'templates/'
60
- 'profiles/'
61
- )
53
+ # 0.20.1 helix-round-N P2: read patterns from policy. Pre-fix the
54
+ # rea-internal source-tree patterns (`src/gateway/`, `hooks/_lib/`,
55
+ # `profiles/`, etc.) shipped as hardcoded defaults — irrelevant noise
56
+ # in consumer projects whose architecture-sensitive paths are
57
+ # different. Consumers with their own architecture surfaces declare
58
+ # them in `.rea/policy.yaml::architecture_review.patterns`. The
59
+ # bst-internal profile pins the rea-source patterns so the dogfood
60
+ # install behaves the same as before; consumers without a pattern
61
+ # set get a silent no-op.
62
+ # shellcheck source=_lib/policy-read.sh
63
+ source "$(dirname "$0")/_lib/policy-read.sh"
64
+
65
+ ARCH_PATTERNS=()
66
+ while IFS= read -r entry; do
67
+ [[ -z "$entry" ]] && continue
68
+ ARCH_PATTERNS+=("$entry")
69
+ done < <(policy_list "architecture_review.patterns" 2>/dev/null || true)
70
+
71
+ if [[ ${#ARCH_PATTERNS[@]} -eq 0 ]]; then
72
+ # Empty/unset policy → silent no-op. Consumers who haven't declared
73
+ # architecture-sensitive paths see zero advisory output.
74
+ exit 0
75
+ fi
62
76
 
63
77
  MATCHED=""
64
78
  for pattern in "${ARCH_PATTERNS[@]}"; do
@@ -161,6 +161,13 @@ _refuse() {
161
161
  }
162
162
 
163
163
  # Check a single resolved-target token. Refuses on hit.
164
+ #
165
+ # 0.20.1 helix-021 #2: in addition to the logical post-_normalize_target
166
+ # form, also check the symlink-resolved form. Pre-fix `ln -s . linkroot;
167
+ # printf x > linkroot/.env` had a logical form of `linkroot/.env`
168
+ # (no match against blocked_paths) but a resolved form of `.env`
169
+ # (which DOES match). Refuse on either match. Write-tier
170
+ # `blocked-paths-enforcer.sh` already has this resolution since 0.10.x.
164
171
  _check_token() {
165
172
  local token="$1" segment="$2"
166
173
  [[ -z "$token" ]] && return 0
@@ -172,9 +179,21 @@ _check_token() {
172
179
  # outside-root rejection on the protected list itself.
173
180
  return 0
174
181
  fi
182
+ # Symlink-resolved form via shared helper. Returns empty when the
183
+ # parent doesn't exist (legitimate "creating the parent" case);
184
+ # outside-REA_ROOT sentinel when the symlink walks out of the
185
+ # project (silently allow — same as the logical-path branch above).
186
+ local resolved_symlink
187
+ resolved_symlink=$(rea_resolved_relative_form "$token")
188
+ if [[ "$resolved_symlink" == __rea_outside_root__:* ]]; then
189
+ resolved_symlink=""
190
+ fi
175
191
  if _match_blocked "$resolved"; then
176
192
  _refuse "$MATCHED" "$resolved" "$segment"
177
193
  fi
194
+ if [[ -n "$resolved_symlink" ]] && _match_blocked "$resolved_symlink"; then
195
+ _refuse "$MATCHED" "$resolved_symlink" "$segment"
196
+ fi
178
197
  return 0
179
198
  }
180
199
 
@@ -27,6 +27,8 @@ set -uo pipefail
27
27
 
28
28
  # shellcheck source=_lib/protected-paths.sh
29
29
  source "$(dirname "$0")/_lib/protected-paths.sh"
30
+ # shellcheck source=_lib/path-normalize.sh
31
+ source "$(dirname "$0")/_lib/path-normalize.sh"
30
32
  # shellcheck source=_lib/cmd-segments.sh
31
33
  source "$(dirname "$0")/_lib/cmd-segments.sh"
32
34
 
@@ -235,7 +237,7 @@ _check_segment() {
235
237
  # walking — there may be more positional args.
236
238
  local _t
237
239
  _t=$(_normalize_target "$target_token")
238
- # 0.16.0 codex P2-3: outside-REA_ROOT sentinel handling.
240
+ # 0.16.0 codex P2-3: outside-REA_ROOT sentinel handling (logical).
239
241
  if [[ "$_t" == __rea_outside_root__:* ]]; then
240
242
  local resolved="${_t#__rea_outside_root__:}"
241
243
  {
@@ -244,15 +246,37 @@ _check_segment() {
244
246
  } >&2
245
247
  exit 2
246
248
  fi
247
- if rea_path_is_protected "$_t"; then
249
+ # 0.20.1 helix-021 #1: resolve intermediate symlinks via
250
+ # `cd -P / pwd -P` parent-canonicalization (Write-tier parity).
251
+ # `ln -s ../ .husky/pre-push.d/linkdir; printf x > .husky/pre-push.d/linkdir/pre-push`
252
+ # had a logical form of `.husky/pre-push.d/linkdir/pre-push`
253
+ # that didn't match any protected pattern; the resolved form
254
+ # is `.husky/pre-push` which DOES match. Refuse on either.
255
+ local _t_resolved
256
+ _t_resolved=$(rea_resolved_relative_form "$target_token")
257
+ if [[ "$_t_resolved" == __rea_outside_root__:* ]]; then
258
+ local resolved="${_t_resolved#__rea_outside_root__:}"
259
+ {
260
+ printf 'PROTECTED PATH (bash): symlink resolves outside project root\n'
261
+ printf ' Logical: %s\n Resolved: %s\n' "$target_token" "$resolved"
262
+ } >&2
263
+ exit 2
264
+ fi
265
+ if rea_path_is_protected "$_t" \
266
+ || ([[ -n "$_t_resolved" ]] && rea_path_is_protected "$_t_resolved"); then
248
267
  local matched=""
249
268
  local pattern_lc
269
+ local hit_form="$_t"
270
+ if [[ -n "$_t_resolved" ]] && rea_path_is_protected "$_t_resolved" \
271
+ && ! rea_path_is_protected "$_t"; then
272
+ hit_form="$_t_resolved"
273
+ fi
250
274
  for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
251
275
  pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
252
- if [[ "$_t" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
253
- if [[ "$pattern_lc" == */ && "$_t" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
276
+ if [[ "$hit_form" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
277
+ if [[ "$pattern_lc" == */ && "$hit_form" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
254
278
  done
255
- _refuse "$matched" "$_t" "$segment"
279
+ _refuse "$matched" "$hit_form" "$segment"
256
280
  fi
257
281
  # Reset target_token so the post-loop check doesn't double-check.
258
282
  target_token=""
@@ -283,17 +307,38 @@ _check_segment() {
283
307
  } >&2
284
308
  exit 2
285
309
  fi
286
- if rea_path_is_protected "$target"; then
310
+ # 0.20.1 helix-021 #1: resolve intermediate symlinks. See parallel
311
+ # block in the multi-target loop above for the rationale.
312
+ local target_resolved
313
+ target_resolved=$(rea_resolved_relative_form "$target_token")
314
+ if [[ "$target_resolved" == __rea_outside_root__:* ]]; then
315
+ local resolved="${target_resolved#__rea_outside_root__:}"
316
+ {
317
+ printf 'PROTECTED PATH (bash): symlink resolves outside project root\n'
318
+ printf '\n'
319
+ printf ' Logical: %s\n' "$target_token"
320
+ printf ' Resolved: %s\n' "$resolved"
321
+ printf ' Segment: %s\n' "$segment"
322
+ } >&2
323
+ exit 2
324
+ fi
325
+ if rea_path_is_protected "$target" \
326
+ || ([[ -n "$target_resolved" ]] && rea_path_is_protected "$target_resolved"); then
287
327
  # Find the matching pattern for the error message. Both `target`
288
328
  # and `pattern` lowercased to match `_normalize_target`'s case-
289
329
  # insensitive output (helix-015 P1 fix).
290
330
  local matched="" pattern_lc
331
+ local hit_form="$target"
332
+ if [[ -n "$target_resolved" ]] && rea_path_is_protected "$target_resolved" \
333
+ && ! rea_path_is_protected "$target"; then
334
+ hit_form="$target_resolved"
335
+ fi
291
336
  for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
292
337
  pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
293
- if [[ "$target" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
294
- if [[ "$pattern_lc" == */ && "$target" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
338
+ if [[ "$hit_form" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
339
+ if [[ "$pattern_lc" == */ && "$hit_form" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
295
340
  done
296
- _refuse "$matched" "$target" "$segment"
341
+ _refuse "$matched" "$hit_form" "$segment"
297
342
  fi
298
343
  return 0
299
344
  }
@@ -199,8 +199,15 @@ case "$LOWER_NORM" in
199
199
  if [ -d "$parent_dir" ]; then
200
200
  resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
201
201
  if [ -n "$resolved_parent" ]; then
202
+ # 0.20.1 helix-021 #3: directory-boundary on the case glob.
203
+ # Pre-fix `*"/.husky/commit-msg.d"*` matched `.husky/commit-msg.d.bak/`
204
+ # too (substring without trailing-slash anchor). A symlink
205
+ # `.husky/pre-push.d/linkdir -> ../pre-push.d.bak` then resolved
206
+ # to `.husky/pre-push.d.bak/...` and slipped through.
207
+ # The trailing `/` on each pattern (and the explicit
208
+ # exact-match arm) requires a real directory boundary.
202
209
  case "$resolved_parent" in
203
- *"/.husky/commit-msg.d"*|*"/.husky/pre-push.d"*) : ;;
210
+ */.husky/commit-msg.d|*/.husky/commit-msg.d/*|*/.husky/pre-push.d|*/.husky/pre-push.d/*) : ;;
204
211
  *)
205
212
  {
206
213
  printf 'SETTINGS PROTECTION: extension path resolves outside surface\n'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
@@ -36,3 +36,18 @@ context_protection:
36
36
  # hash chain across the boundary.
37
37
  audit:
38
38
  rotation: {}
39
+ # 0.20.1 helix-round-N P2: rea's own architecture-sensitive paths.
40
+ # Hardcoded into the hook before this release; now policy-driven so
41
+ # consumer projects don't get rea-source patterns advisory-warning on
42
+ # their own `src/types/` directories. Empty (or unset) on other
43
+ # profiles = silent no-op. Operators add their own
44
+ # architecture-sensitive paths here on a per-project basis.
45
+ architecture_review:
46
+ patterns:
47
+ - src/types/
48
+ - src/gateway/
49
+ - src/config/
50
+ - src/cli/commands/init/
51
+ - hooks/_lib/
52
+ - templates/
53
+ - profiles/