@bookedsolid/rea 0.20.0 → 0.21.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/dist/cli/init.js +136 -15
- package/dist/hooks/push-gate/index.js +21 -9
- package/dist/hooks/push-gate/verdict-cache.d.ts +10 -0
- package/dist/hooks/push-gate/verdict-cache.js +38 -1
- package/dist/policy/loader.d.ts +13 -0
- package/dist/policy/loader.js +11 -0
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +6 -0
- package/dist/policy/types.d.ts +12 -0
- package/hooks/_lib/path-normalize.sh +75 -0
- package/hooks/_lib/protected-paths.sh +6 -0
- package/hooks/architecture-review-gate.sh +23 -9
- package/hooks/blocked-paths-bash-gate.sh +19 -0
- package/hooks/protected-paths-bash-gate.sh +54 -9
- package/hooks/settings-protection.sh +8 -1
- package/package.json +1 -1
- package/profiles/bst-internal.yaml +15 -0
package/dist/cli/init.js
CHANGED
|
@@ -85,7 +85,7 @@ function resolveLayered(profileName, reagentTranslated) {
|
|
|
85
85
|
}
|
|
86
86
|
return layered;
|
|
87
87
|
}
|
|
88
|
-
async function runWizard(options, targetDir, reagentPolicyPath, layeredBase) {
|
|
88
|
+
async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, existingPolicy = undefined) {
|
|
89
89
|
const projectName = detectProjectName(targetDir);
|
|
90
90
|
p.intro(`rea init — ${projectName}`);
|
|
91
91
|
let fromReagent = options.fromReagent === true;
|
|
@@ -124,9 +124,15 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase) {
|
|
|
124
124
|
cancel('Init cancelled.');
|
|
125
125
|
profileName = picked;
|
|
126
126
|
}
|
|
127
|
-
|
|
127
|
+
// 0.21.1: prefer the existing on-disk value over the profile default
|
|
128
|
+
// so re-running `rea init` doesn't reset an operator's manual edit.
|
|
129
|
+
const autonomyDefault = existingPolicy?.autonomyLevel
|
|
130
|
+
?? layeredBase.autonomy_level
|
|
131
|
+
?? AutonomyLevel.L1;
|
|
128
132
|
const autonomyPick = await p.select({
|
|
129
|
-
message:
|
|
133
|
+
message: existingPolicy?.autonomyLevel !== undefined
|
|
134
|
+
? `Starting autonomy_level (current: ${existingPolicy.autonomyLevel})`
|
|
135
|
+
: 'Starting autonomy_level',
|
|
130
136
|
initialValue: autonomyDefault,
|
|
131
137
|
options: [
|
|
132
138
|
{ value: AutonomyLevel.L0, label: 'L0', hint: 'read-only; every write needs approval' },
|
|
@@ -139,9 +145,13 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase) {
|
|
|
139
145
|
cancel('Init cancelled.');
|
|
140
146
|
const autonomyLevel = autonomyPick;
|
|
141
147
|
const maxCandidates = AUTONOMY_LEVELS.filter((lvl) => levelRank(lvl) >= levelRank(autonomyLevel));
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
// 0.21.1: prefer existing on-disk max_autonomy_level over profile default.
|
|
149
|
+
const defaultMax = (existingPolicy?.maxAutonomyLevel !== undefined &&
|
|
150
|
+
maxCandidates.includes(existingPolicy.maxAutonomyLevel) &&
|
|
151
|
+
existingPolicy.maxAutonomyLevel) ||
|
|
152
|
+
(layeredBase.max_autonomy_level !== undefined &&
|
|
153
|
+
maxCandidates.includes(layeredBase.max_autonomy_level) &&
|
|
154
|
+
layeredBase.max_autonomy_level) ||
|
|
145
155
|
maxCandidates.find((l) => l === AutonomyLevel.L2) ||
|
|
146
156
|
autonomyLevel;
|
|
147
157
|
const maxOptions = maxCandidates.map((lvl) => {
|
|
@@ -233,6 +243,79 @@ async function printCodexInstallAssist() {
|
|
|
233
243
|
console.log(' Install via the Claude Code Codex plugin helper: `/codex:setup`,');
|
|
234
244
|
console.log(' or set `review.codex_required: false` in .rea/policy.yaml to opt out.');
|
|
235
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Read user-mutable values from an existing `.rea/policy.yaml`.
|
|
248
|
+
* Returns undefined when the file doesn't exist or fails to parse.
|
|
249
|
+
*
|
|
250
|
+
* The reader is permissive — any field that fails to extract is
|
|
251
|
+
* dropped from the result; the caller falls back to the profile
|
|
252
|
+
* default for that one field. This is the idempotency contract
|
|
253
|
+
* extension introduced in 0.17.0 (`installed_at` preservation),
|
|
254
|
+
* extended in 0.21.1 to cover every field an operator might
|
|
255
|
+
* manually edit between init runs.
|
|
256
|
+
*
|
|
257
|
+
* Profile-switch is allowed but advisory: when the existing
|
|
258
|
+
* `profile:` value disagrees with the requested one, the existing
|
|
259
|
+
* VALUES are still preserved. Operators who want full reset pass
|
|
260
|
+
* `--force` to bypass the file-existence check entirely.
|
|
261
|
+
*/
|
|
262
|
+
function readExistingPolicyForPreservation(targetDir) {
|
|
263
|
+
const policyPath = path.join(targetDir, REA_DIR, POLICY_FILE);
|
|
264
|
+
if (!fs.existsSync(policyPath))
|
|
265
|
+
return undefined;
|
|
266
|
+
try {
|
|
267
|
+
const raw = fs.readFileSync(policyPath, 'utf8');
|
|
268
|
+
const out = {};
|
|
269
|
+
// Profile (informational; used for stderr advisory).
|
|
270
|
+
const pm = raw.match(/^profile:\s*['"]?([a-z0-9-]+)['"]?\s*$/m);
|
|
271
|
+
if (pm)
|
|
272
|
+
out.profile = pm[1];
|
|
273
|
+
// Autonomy + ceiling (enum).
|
|
274
|
+
const am = raw.match(/^autonomy_level:\s*(L[0-3])\s*$/m);
|
|
275
|
+
const amVal = am?.[1];
|
|
276
|
+
if (amVal !== undefined && Object.values(AutonomyLevel).includes(amVal)) {
|
|
277
|
+
out.autonomyLevel = amVal;
|
|
278
|
+
}
|
|
279
|
+
const mm = raw.match(/^max_autonomy_level:\s*(L[0-3])\s*$/m);
|
|
280
|
+
const mmVal = mm?.[1];
|
|
281
|
+
if (mmVal !== undefined && Object.values(AutonomyLevel).includes(mmVal)) {
|
|
282
|
+
out.maxAutonomyLevel = mmVal;
|
|
283
|
+
}
|
|
284
|
+
// block_ai_attribution.
|
|
285
|
+
const bm = raw.match(/^block_ai_attribution:\s*(true|false)\s*$/m);
|
|
286
|
+
if (bm?.[1] !== undefined)
|
|
287
|
+
out.blockAiAttribution = bm[1] === 'true';
|
|
288
|
+
// blocked_paths block-sequence — line-by-line scan.
|
|
289
|
+
const bpStart = raw.match(/^blocked_paths:\s*$/m);
|
|
290
|
+
if (bpStart) {
|
|
291
|
+
const after = raw.slice((bpStart.index ?? 0) + bpStart[0].length + 1);
|
|
292
|
+
const lines = after.split('\n');
|
|
293
|
+
const collected = [];
|
|
294
|
+
for (const line of lines) {
|
|
295
|
+
const m2 = line.match(/^\s*-\s+(?:['"]([^'"]+)['"]|(\S.*?))\s*$/);
|
|
296
|
+
if (!m2)
|
|
297
|
+
break;
|
|
298
|
+
const v = m2[1] ?? m2[2];
|
|
299
|
+
if (v !== undefined)
|
|
300
|
+
collected.push(v);
|
|
301
|
+
}
|
|
302
|
+
if (collected.length > 0)
|
|
303
|
+
out.blockedPaths = collected;
|
|
304
|
+
}
|
|
305
|
+
// notification_channel.
|
|
306
|
+
const nm = raw.match(/^notification_channel:\s*['"]?([^'"\n]*)['"]?\s*$/m);
|
|
307
|
+
if (nm?.[1] !== undefined)
|
|
308
|
+
out.notificationChannel = nm[1];
|
|
309
|
+
// review.codex_required (under nested `review:` block).
|
|
310
|
+
const cm = raw.match(/^\s+codex_required:\s*(true|false)\s*$/m);
|
|
311
|
+
if (cm?.[1] !== undefined)
|
|
312
|
+
out.codexRequired = cm[1] === 'true';
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
236
319
|
function readExistingInstalledAt(policyPath) {
|
|
237
320
|
try {
|
|
238
321
|
if (!fs.existsSync(policyPath))
|
|
@@ -297,6 +380,16 @@ function writePolicyYaml(targetDir, config, layered) {
|
|
|
297
380
|
lines.push(` max_bash_output_lines: ${cp.max_bash_output_lines}`);
|
|
298
381
|
}
|
|
299
382
|
}
|
|
383
|
+
// 0.20.1+ helix-round-N P2: emit architecture_review.patterns when
|
|
384
|
+
// the layered profile declared them. Consumers without patterns see
|
|
385
|
+
// a silent no-op from architecture-review-gate.sh.
|
|
386
|
+
if (layered.architecture_review?.patterns !== undefined) {
|
|
387
|
+
lines.push(`architecture_review:`);
|
|
388
|
+
lines.push(` patterns:`);
|
|
389
|
+
for (const p of layered.architecture_review.patterns) {
|
|
390
|
+
lines.push(` - ${JSON.stringify(p)}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
300
393
|
// 0.18.1+ helixir #9: emit audit.rotation when the layered profile
|
|
301
394
|
// declared it. Empty `rotation: {}` opts in to documented defaults
|
|
302
395
|
// (50 MiB / 30 days); explicit values override.
|
|
@@ -470,30 +563,58 @@ export async function runInit(options) {
|
|
|
470
563
|
}
|
|
471
564
|
}
|
|
472
565
|
const layeredBase = resolveLayered(profileName, reagentTranslated);
|
|
566
|
+
// 0.21.1: preserve user-mutable policy values across re-init (idempotency
|
|
567
|
+
// class — same as the `installed_at` fix from 0.17.0). Pre-fix, every
|
|
568
|
+
// `rea init` re-applied profile defaults, silently resetting an
|
|
569
|
+
// operator's `autonomy_level: L2` back to the profile's L1, etc.
|
|
570
|
+
// Read the existing policy if present and merge: explicit existing
|
|
571
|
+
// value wins over profile default. Operator opts out with --force
|
|
572
|
+
// (existing flag — bypass the file-existence check entirely).
|
|
573
|
+
// Profile-switch case: when the existing profile name disagrees with
|
|
574
|
+
// the requested profile, the existing values are STILL preserved by
|
|
575
|
+
// default but a stderr advisory names what was kept; operator can
|
|
576
|
+
// pass --force to fully reset.
|
|
577
|
+
const existingPolicy = readExistingPolicyForPreservation(targetDir);
|
|
473
578
|
let config;
|
|
474
579
|
if (options.yes === true) {
|
|
475
580
|
// G11.4 non-interactive codex resolution:
|
|
476
581
|
// 1. Explicit --codex / --no-codex flag wins.
|
|
477
|
-
// 2. Otherwise
|
|
582
|
+
// 2. Otherwise existing policy value wins (preserves operator edit).
|
|
583
|
+
// 3. Otherwise derive from the profile name (`*-no-codex` → false).
|
|
478
584
|
const codexRequired = options.codex !== undefined
|
|
479
585
|
? options.codex
|
|
480
|
-
: profileDefaultCodexRequired(profileName);
|
|
586
|
+
: (existingPolicy?.codexRequired ?? profileDefaultCodexRequired(profileName));
|
|
481
587
|
config = {
|
|
482
588
|
profile: profileName,
|
|
483
|
-
autonomyLevel:
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
589
|
+
autonomyLevel: existingPolicy?.autonomyLevel
|
|
590
|
+
?? layeredBase.autonomy_level
|
|
591
|
+
?? AutonomyLevel.L1,
|
|
592
|
+
maxAutonomyLevel: existingPolicy?.maxAutonomyLevel
|
|
593
|
+
?? layeredBase.max_autonomy_level
|
|
594
|
+
?? AutonomyLevel.L2,
|
|
595
|
+
blockAiAttribution: existingPolicy?.blockAiAttribution
|
|
596
|
+
?? layeredBase.block_ai_attribution
|
|
597
|
+
?? true,
|
|
598
|
+
blockedPaths: existingPolicy?.blockedPaths
|
|
599
|
+
?? layeredBase.blocked_paths
|
|
600
|
+
?? ['.env', '.env.*'],
|
|
601
|
+
notificationChannel: existingPolicy?.notificationChannel
|
|
602
|
+
?? layeredBase.notification_channel
|
|
603
|
+
?? '',
|
|
488
604
|
codexRequired,
|
|
489
605
|
fromReagent,
|
|
490
606
|
reagentPolicyPath,
|
|
491
607
|
reagentNotices,
|
|
492
608
|
};
|
|
493
|
-
|
|
609
|
+
if (existingPolicy !== undefined) {
|
|
610
|
+
log(`Non-interactive init (re-run): preserving existing autonomy=${config.autonomyLevel}, max=${config.maxAutonomyLevel}, attribution-block=${config.blockAiAttribution}, codex_required=${config.codexRequired}. Pass --force to reset to profile defaults.`);
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
log(`Non-interactive init: profile=${profileName}, autonomy=${config.autonomyLevel}, max=${config.maxAutonomyLevel}, attribution-block=${config.blockAiAttribution}, codex_required=${config.codexRequired}`);
|
|
614
|
+
}
|
|
494
615
|
}
|
|
495
616
|
else {
|
|
496
|
-
config = await runWizard(options, targetDir, reagentPolicyPath, layeredBase);
|
|
617
|
+
config = await runWizard(options, targetDir, reagentPolicyPath, layeredBase, existingPolicy);
|
|
497
618
|
config.reagentNotices = reagentNotices;
|
|
498
619
|
}
|
|
499
620
|
if (!fs.existsSync(reaDir))
|
|
@@ -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:
|
|
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:
|
|
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
|
*/
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -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.
|
package/dist/policy/loader.js
CHANGED
|
@@ -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. */
|
package/dist/policy/profiles.js
CHANGED
|
@@ -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. */
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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 [[ "$
|
|
253
|
-
if [[ "$pattern_lc" == */ && "$
|
|
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" "$
|
|
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
|
-
|
|
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 [[ "$
|
|
294
|
-
if [[ "$pattern_lc" == */ && "$
|
|
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" "$
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.21.1",
|
|
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/
|