feat(brain-config): shell-content защита config-driven (greenfield #3 shell)

buildProtectedPatterns 2-й параметр normativeFiles даёт anchored .md stem-паттерны; оба гейта в main строят protectedPaths из loadConfig (try/catch fallback DEFAULT). DEFAULT 32-34 сохранён (backward-compat); augment только добавляет защиту. shell-content-rules импортирует docStem из cross-ref-checker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-16 09:41:58 +03:00
parent deb504988a
commit 3d7690650e
5 changed files with 189 additions and 5 deletions
@@ -0,0 +1,150 @@
# greenfield shell-content config-driven защита — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (инлайн под стеной; production-правки под TDD-gate override + floor-escape). Steps — checkbox (`- [ ]`).
**Goal:** Подключить config-augment защиты нормативки (`buildProtectedPatterns`) в живые shell-гейты, чтобы greenfield-проект защищал свои нормативные доки от shell-чтения/перезаписи; claude-brain — без регресса.
**Architecture:** Refines design `2026-06-16-greenfield-regex-names-config-design.md` §4 (вместо «убрать 32-34» — **оставить DEFAULT + augment**, т.к. тест 281 пинит DEFAULT-защиту, а шов был не подключён). `pathDenyOverlay` зовут `enforce-powershell-gate` (живой) и `enforce-router-gate` (увольняется, но wire future-proof); оба строят `ctx.protectedPaths = DEFAULT_PROTECTED_PATTERNS` (хардкод). Меняем: `buildProtectedPatterns(configPaths, normativeFiles)` добавляет filename-anchored стем-паттерны `(^|/)<docStem>[^/]*\.md` (`docStem` экспортирован из cross-ref-checker.mjs, deb5049); оба гейта в `main()` строят `protectedPaths` из `loadConfig()` (try/catch → DEFAULT fallback). DEFAULT 32-34 не трогаем → backward-compat.
**Tech Stack:** Node ESM (.mjs), vitest.
---
## File Structure
- Modify: `tools/shell-content-rules.mjs` (+ `.test.mjs`) — import `docStem`; `buildProtectedPatterns` 2-й параметр `normativeFiles` + stem-паттерны.
- Modify: `tools/enforce-powershell-gate.mjs` — import `buildProtectedPatterns`; wire `main()` (стр. 102).
- Modify: `tools/enforce-router-gate.mjs` — import `buildProtectedPatterns`; wire `main()` (стр. 192).
---
## Task 1: `buildProtectedPatterns` 2-й параметр `normativeFiles` (TDD)
**Files:** `tools/shell-content-rules.mjs`, `tools/shell-content-rules.test.mjs`
- [ ] **Step 1 (RED test):** в `shell-content-rules.test.mjs`, в `describe('buildProtectedPatterns augment ...')` добавить:
```javascript
it('normativeFiles → anchored .md stem-паттерны (greenfield); DEFAULT сохранён', () => {
const pats = buildProtectedPatterns([], ['docs/MyRules_v2.md']);
expect(isProtectedPath('docs/MyRules_v2.md', defaultPathNormalize, pats)).toBe(true);
expect(isProtectedPath('proj/MyRules-notes.md', defaultPathNormalize, pats)).toBe(true);
expect(isProtectedPath('app/Models/Deal.php', defaultPathNormalize, pats)).toBe(false);
expect(isProtectedPath('CLAUDE.md', defaultPathNormalize, pats)).toBe(true);
});
it('пустой normativeFiles → DEFAULT + configPaths (backward-compat)', () => {
expect(buildProtectedPatterns([], [])).toEqual(DEFAULT_PROTECTED_PATTERNS);
});
```
- [ ] **Step 2:** Run RED — `npx vitest run --config vitest.config.tools.mjs tools/shell-content-rules.test.mjs` → FAIL (2-й аргумент игнорируется; MyRules не защищён).
- [ ] **Step 3 (impl):** в `shell-content-rules.mjs` добавить импорт (после существующих import-ов вверху):
```javascript
import { docStem } from './cross-ref-checker.mjs';
```
и заменить `buildProtectedPatterns` (текущие строки 46-54):
```javascript
export function buildProtectedPatterns(configPaths = [], normativeFiles = []) {
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const extra = Array.isArray(configPaths)
? configPaths.map((p) => String(p || '').replace(/\\/g, '/').trim()).filter((p) => p.length > 0)
.map((p) => new RegExp('(^|/)' + esc(p), 'i'))
: [];
const stems = Array.isArray(normativeFiles)
? normativeFiles.map((f) => docStem(f)).filter((s) => s.length > 0)
.map((s) => new RegExp('(^|/)' + esc(s) + '[^/]*\\.md', 'i'))
: [];
return [...DEFAULT_PROTECTED_PATTERNS, ...extra, ...stems];
}
```
- [ ] **Step 4:** Run GREEN — тот же прогон → PASS (новые + прежние 380-393: `()`/`([])`/`(null)` → DEFAULT, configPaths augment).
---
## Task 2: wire `enforce-powershell-gate` main()
**Files:** `tools/enforce-powershell-gate.mjs`
- [ ] **Step 1 (import):** в import-блоке из `./shell-content-rules.mjs` (стр. 10-11, рядом с `DEFAULT_PROTECTED_PATTERNS`/`pathDenyOverlay`) добавить строку:
```javascript
buildProtectedPatterns,
```
- [ ] **Step 2 (wire main):** заменить блок построения `ctx` (стр. 99-104):
```javascript
let protectedPaths = DEFAULT_PROTECTED_PATTERNS;
try {
const { loadConfig } = await import('./brain-config.mjs');
const cfg = loadConfig();
protectedPaths = buildProtectedPatterns(cfg.protected_paths, cfg.normative_files);
} catch { /* дефолт DEFAULT_PROTECTED_PATTERNS */ }
const ctx = {
approvedGitOps: loadApprovedGitOps(sessionId),
pathNormalize: await resolvePathNormalize(),
protectedPaths,
now: Date.now(),
};
```
- [ ] **Step 3 (verify):** владелец: `npx vitest run --config vitest.config.tools.mjs tools/enforce-powershell-gate.test.mjs` → PASS (gate-тесты используют DEFAULT через свой ctx/инъекцию; live main читает config с fallback).
---
## Task 3: wire `enforce-router-gate` main()
**Files:** `tools/enforce-router-gate.mjs`
- [ ] **Step 1 (import):** в import-блоке из `./shell-content-rules.mjs` (стр. 15-16) добавить:
```javascript
buildProtectedPatterns,
```
- [ ] **Step 2 (wire main):** заменить блок построения `ctx` (стр. 188-194):
```javascript
let protectedPaths = DEFAULT_PROTECTED_PATTERNS;
try {
const { loadConfig } = await import('./brain-config.mjs');
const cfg = loadConfig();
protectedPaths = buildProtectedPatterns(cfg.protected_paths, cfg.normative_files);
} catch { /* дефолт DEFAULT_PROTECTED_PATTERNS */ }
const ctx = {
approvedGitOps: loadApprovedGitOps(sessionId),
editedFiles: readEditedFiles(sessionId),
pathNormalize,
protectedPaths,
now: Date.now(),
};
```
- [ ] **Step 3 (verify):** владелец: `npx vitest run --config vitest.config.tools.mjs tools/enforce-router-gate.test.mjs` → PASS.
---
## Task 4: Полный свод + коммит (терминал владельца)
- [ ] **Step 1:** `npx vitest run --config vitest.config.tools.mjs` → зелёный, новых падений нет.
- [ ] **Step 2:** commit:
```bash
git add tools/shell-content-rules.mjs tools/shell-content-rules.test.mjs tools/enforce-powershell-gate.mjs tools/enforce-router-gate.mjs docs/superpowers/plans/2026-06-16-greenfield-shell-config-plan.md
git commit -m "feat(brain-config): shell-content защита config-driven (greenfield #3 shell)"
```
---
## Self-Review
- **Spec coverage:** §4 (refined) — Task 1 (buildProtectedPatterns normativeFiles + keep DEFAULT), Task 2/3 (wire оба гейта). §5 fail-safe: пустой normative_files → stems=[] → DEFAULT защищает universal (Task 1 тест). Граница §4: 32-34 НЕ убраны (backward-compat, тест 281).
- **Placeholders:** нет; весь код реальный (точные строки 10/99-104, 15/188-194).
- **Type consistency:** `buildProtectedPatterns(configPaths, normativeFiles)` — единая сигнатура; `docStem` импортируется из cross-ref-checker.mjs (deb5049). Оба гейта — одинаковый wiring-блок.
- **Стена:** production-правки (3 файла: shell-content-rules/powershell-gate/router-gate) под TDD-gate override + floor-escape per Edit; `.test.mjs` — gate молчит. ~7 правок. Полный свод + коммит — терминал владельца.
- **Security-note:** оба гейта — fail-CLOSE при ошибке (catch → block); config-чтение в try/catch → при сбое падаем на DEFAULT (защита не слабеет). Augment только ДОБАВЛЯет паттерны (никогда не убирает) → защита монотонно растёт.
+8 -1
View File
@@ -9,6 +9,7 @@ import {
defaultPathNormalize,
DEFAULT_PROTECTED_PATTERNS,
pathDenyOverlay,
buildProtectedPatterns,
classifyGitCommand,
loadApprovedGitOps,
matchPsHardBlacklist,
@@ -96,10 +97,16 @@ async function main() {
if (event.tool_name !== 'PowerShell') { exitDecision({ block: false }); return; }
const command = (event.tool_input && event.tool_input.command) || '';
const sessionId = event.session_id || 'unknown';
let protectedPaths = DEFAULT_PROTECTED_PATTERNS;
try {
const { loadConfig } = await import('./brain-config.mjs');
const cfg = loadConfig();
protectedPaths = buildProtectedPatterns(cfg.protected_paths, cfg.normative_files);
} catch { /* дефолт DEFAULT_PROTECTED_PATTERNS */ }
const ctx = {
approvedGitOps: loadApprovedGitOps(sessionId),
pathNormalize: await resolvePathNormalize(),
protectedPaths: DEFAULT_PROTECTED_PATTERNS,
protectedPaths,
now: Date.now(),
};
const verdict = classifyPowerShellCommand(command, ctx);
+8 -1
View File
@@ -14,6 +14,7 @@ import {
defaultPathNormalize,
DEFAULT_PROTECTED_PATTERNS,
pathDenyOverlay,
buildProtectedPatterns,
extractPathArgs,
matchBashHardBlacklist,
BASH_HARD_BLACKLIST,
@@ -185,11 +186,17 @@ async function main() {
const command = (event.tool_input && event.tool_input.command) || '';
const sessionId = event.session_id || 'unknown';
const pathNormalize = await resolvePathNormalize();
let protectedPaths = DEFAULT_PROTECTED_PATTERNS;
try {
const { loadConfig } = await import('./brain-config.mjs');
const cfg = loadConfig();
protectedPaths = buildProtectedPatterns(cfg.protected_paths, cfg.normative_files);
} catch { /* дефолт DEFAULT_PROTECTED_PATTERNS */ }
const ctx = {
approvedGitOps: loadApprovedGitOps(sessionId),
editedFiles: readEditedFiles(sessionId),
pathNormalize,
protectedPaths: DEFAULT_PROTECTED_PATTERNS,
protectedPaths,
now: Date.now(),
};
const verdict = classifyBashCommand(command, ctx);
+13 -3
View File
@@ -8,6 +8,7 @@
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { docStem } from './cross-ref-checker.mjs';
// ── Path normalization (Stream A заглушка; реальная — path-normalization.mjs) ──
export function defaultPathNormalize(target) {
@@ -43,14 +44,23 @@ export const DEFAULT_PROTECTED_PATTERNS = [
/** fail-CLOSED augment (§D2): UNION базовых DEFAULT_PROTECTED_PATTERNS с config-путями.
* База всегда первая и не удаляется; пусто / не-массив → только база (защита байт-в-байт).
* Каждый config-путь экранируется и матчится по сегменту пути (^|/)… (case-insensitive). */
export function buildProtectedPatterns(configPaths = []) {
export function buildProtectedPatterns(configPaths = [], normativeFiles = []) {
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const extra = Array.isArray(configPaths)
? configPaths
.map((p) => String(p || '').replace(/\\/g, '/').trim())
.filter((p) => p.length > 0)
.map((p) => new RegExp('(^|/)' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'))
.map((p) => new RegExp('(^|/)' + esc(p), 'i'))
: [];
return [...DEFAULT_PROTECTED_PATTERNS, ...extra];
// #3-shell: нормативные доки из config → filename-anchored stem-паттерны (greenfield).
// DEFAULT (32-34) сохраняется; augment только ДОБАВЛЯет (защита монотонно растёт).
const stems = Array.isArray(normativeFiles)
? normativeFiles
.map((f) => docStem(f))
.filter((s) => s.length > 0)
.map((s) => new RegExp('(^|/)' + esc(s) + '[^/]*\\.md', 'i'))
: [];
return [...DEFAULT_PROTECTED_PATTERNS, ...extra, ...stems];
}
// Read-tool deny list — narrower than DEFAULT_PROTECTED_PATTERNS (over-block fix 2026-05-31).
+10
View File
@@ -392,4 +392,14 @@ describe('buildProtectedPatterns augment (Task 4 security, §D2 fail-CLOSED)', (
expect(isProtectedPath('app/secrets/keys.txt', defaultPathNormalize, pats)).toBe(true);
expect(isProtectedPath('app/Models/Deal.php', defaultPathNormalize, pats)).toBe(false);
});
it('normativeFiles → anchored .md stem-паттерны (greenfield); DEFAULT сохранён', () => {
const pats = buildProtectedPatterns([], ['docs/MyRules_v2.md']);
expect(isProtectedPath('docs/MyRules_v2.md', defaultPathNormalize, pats)).toBe(true);
expect(isProtectedPath('proj/MyRules-notes.md', defaultPathNormalize, pats)).toBe(true);
expect(isProtectedPath('app/Models/Deal.php', defaultPathNormalize, pats)).toBe(false);
expect(isProtectedPath('CLAUDE.md', defaultPathNormalize, pats)).toBe(true);
});
it('пустой normativeFiles → DEFAULT + configPaths (backward-compat)', () => {
expect(buildProtectedPatterns([], [])).toEqual(DEFAULT_PROTECTED_PATTERNS);
});
});