diff --git a/tools/brain-config.mjs b/tools/brain-config.mjs index 24ce2d1..dc06e8d 100644 --- a/tools/brain-config.mjs +++ b/tools/brain-config.mjs @@ -46,6 +46,7 @@ const DEFAULTS = Object.freeze({ project_url_whitelist: [], classifier_context: 'generic project (no profile configured)', economy_default: '100', + protected_paths: [], }); /** Наложить дефолты + fail-safe направления (§D3). Чистая. */ diff --git a/tools/brain-config.test.mjs b/tools/brain-config.test.mjs index 1dc3839..52e6a61 100644 --- a/tools/brain-config.test.mjs +++ b/tools/brain-config.test.mjs @@ -67,3 +67,13 @@ describe('resolveConfig fail-safe (§D3)', () => { expect(r.registry_path).toBe('docs/registry/nodes.yaml'); }); }); + +describe('resolveConfig protected_paths (Task 4 security, §D1/§D2)', () => { + it('отсутствует → [] (fail-CLOSED augment пуст = база защищает полностью)', () => { + expect(resolveConfig({}).protected_paths).toEqual([]); + }); + it('список пробрасывается как есть', () => { + expect(resolveConfig({ protected_paths: ['secrets/keys'] }).protected_paths) + .toEqual(['secrets/keys']); + }); +}); diff --git a/tools/enforce-normative-content-rules.mjs b/tools/enforce-normative-content-rules.mjs index 492326d..cac55c1 100644 --- a/tools/enforce-normative-content-rules.mjs +++ b/tools/enforce-normative-content-rules.mjs @@ -17,11 +17,19 @@ const NORMATIVE_PATTERNS = [ /(^|\/)docs\/Tooling_[^/]*\.md$/, ]; -/** True if the file path is a protected normative document (§3.6.1). */ -export function isNormativePath(filePath) { +/** True if the file path is a protected normative document (§3.6.1). + * @param {string} filePath + * @param {string[]} [extraProtectedPaths] — fail-CLOSED augment (§D2): config protected_paths + * ТОЛЬКО добавляет пути под гейт; пусто / не-массив → защищает только база. */ +export function isNormativePath(filePath, extraProtectedPaths = []) { if (typeof filePath !== 'string') return false; const n = filePath.replace(/\\/g, '/'); - return NORMATIVE_PATTERNS.some((re) => re.test(n)); + if (NORMATIVE_PATTERNS.some((re) => re.test(n))) return true; + if (!Array.isArray(extraProtectedPaths)) return false; + return extraProtectedPaths.some((p) => { + const e = String(p || '').replace(/\\/g, '/').trim(); + return e.length > 0 && n.includes(e); + }); } // 7.2 (H3, Блок 4.2) — наиболее защищаемое подмножество нормативки: CLAUDE.md / память / diff --git a/tools/enforce-normative-content-rules.test.mjs b/tools/enforce-normative-content-rules.test.mjs index c924520..7216be4 100644 --- a/tools/enforce-normative-content-rules.test.mjs +++ b/tools/enforce-normative-content-rules.test.mjs @@ -362,3 +362,23 @@ describe('Ф8 cherry-pick d1ad4e85 — слияние сохранило ОБЕ expect(src).toContain("logGuardBlock(event, 'М1/М5 Нормативный'"); }); }); + +describe('isNormativePath augment (Task 4 security, §D2 fail-CLOSED)', () => { + it('backward-compat: один аргумент → только база', () => { + expect(isNormativePath('CLAUDE.md')).toBe(true); + expect(isNormativePath('app/secret/keys.md')).toBe(false); + }); + it('extraProtectedPaths добавляет путь под гейт', () => { + expect(isNormativePath('app/secret/keys.md', ['secret/keys'])).toBe(true); + }); + it('база сохраняется при непустом augment', () => { + expect(isNormativePath('CLAUDE.md', ['secret/keys'])).toBe(true); + }); + it('пусто / не-массив → только база (fail-CLOSED)', () => { + expect(isNormativePath('app/secret/keys.md', [])).toBe(false); + expect(isNormativePath('app/secret/keys.md', null)).toBe(false); + }); + it('пустые строки в списке отбрасываются', () => { + expect(isNormativePath('app/x.md', [' ', ''])).toBe(false); + }); +}); diff --git a/tools/shell-content-rules.mjs b/tools/shell-content-rules.mjs index 4a8f2c5..f126fbe 100644 --- a/tools/shell-content-rules.mjs +++ b/tools/shell-content-rules.mjs @@ -40,6 +40,19 @@ export const DEFAULT_PROTECTED_PATTERNS = [ /(^|\/)\.npmrc$/i, ]; +/** fail-CLOSED augment (§D2): UNION базовых DEFAULT_PROTECTED_PATTERNS с config-путями. + * База всегда первая и не удаляется; пусто / не-массив → только база (защита байт-в-байт). + * Каждый config-путь экранируется и матчится по сегменту пути (^|/)… (case-insensitive). */ +export function buildProtectedPatterns(configPaths = []) { + 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')) + : []; + return [...DEFAULT_PROTECTED_PATTERNS, ...extra]; +} + // Read-tool deny list — narrower than DEFAULT_PROTECTED_PATTERNS (over-block fix 2026-05-31). // Smoke 5 reused the full protected-list for the Read tool, which blocked Read of // CLAUDE.md, the normative docs and the memory/ index — breaking the legit diff --git a/tools/shell-content-rules.test.mjs b/tools/shell-content-rules.test.mjs index d9592ae..79e84f3 100644 --- a/tools/shell-content-rules.test.mjs +++ b/tools/shell-content-rules.test.mjs @@ -372,3 +372,24 @@ describe('matchPsHardBlacklist hosted in shell-content-rules (M7 PS single-sourc expect(matchPsHardBlacklist('rm x')).toBeTruthy(); }); }); + +import { buildProtectedPatterns } from './shell-content-rules.mjs'; + +describe('buildProtectedPatterns augment (Task 4 security, §D2 fail-CLOSED)', () => { + it('пусто / без аргумента → база байт-в-байт', () => { + expect(buildProtectedPatterns()).toEqual(DEFAULT_PROTECTED_PATTERNS); + expect(buildProtectedPatterns([])).toEqual(DEFAULT_PROTECTED_PATTERNS); + }); + it('не-массив → только база (fail-CLOSED)', () => { + expect(buildProtectedPatterns(null)).toEqual(DEFAULT_PROTECTED_PATTERNS); + }); + it('пустые строки отбрасываются', () => { + expect(buildProtectedPatterns(['', ' '])).toEqual(DEFAULT_PROTECTED_PATTERNS); + }); + it('добавляет config-путь, база сохранена', () => { + const pats = buildProtectedPatterns(['secrets/keys']); + expect(isProtectedPath('CLAUDE.md', defaultPathNormalize, pats)).toBe(true); + expect(isProtectedPath('app/secrets/keys.txt', defaultPathNormalize, pats)).toBe(true); + expect(isProtectedPath('app/Models/Deal.php', defaultPathNormalize, pats)).toBe(false); + }); +});