From b0cd18d797cd18b377df3f6096bb5dac9e54cb27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 31 May 2026 14:05:52 +0300 Subject: [PATCH] fix(router-gate): quote-aware redirect detector + drop dead override-phrase ads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Квирк 2: новый stripQuotedSpans делает детектор stdout/stderr-редиректа кавычко-осознанным — `>` / `2>` ВНУТРИ кавыченного аргумента (текст коммита с , "2>1") больше не ложно-блокируется; настоящие редиректы (оператор вне кавычек) блокируются как прежде. RED→GREEN, существующие redirect/cd-app кейсы целы. 1A: убрана реклама мёртвых override-фраз (findOverride — заглушка v4, фразы не работают): баннер enforce-prompt-injection (каждый UserPromptSubmit) + block-сообщения enforce-verify-before-push / coverage-verify / memory-coverage / tdd-gate (×3). Каждый фикс залочен негативным тестом. Сознательно НЕ делали: калибровку 6 судьи (читать чат-контекст) и ослабление exact-match approve (квирк 3) — это рубежи защиты, их трогать нельзя. Регрессия vitest tools-only: 1989 passed | 2 skipped (verify через npx vitest run --root app --config vitest.config.tools.mjs). Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/enforce-coverage-verify.mjs | 2 -- tools/enforce-coverage-verify.test.mjs | 3 ++ tools/enforce-memory-coverage.mjs | 2 -- tools/enforce-memory-coverage.test.mjs | 3 ++ tools/enforce-prompt-injection.mjs | 2 -- tools/enforce-prompt-injection.test.mjs | 10 +++--- tools/enforce-router-gate.mjs | 39 ++++++++++++++++++-- tools/enforce-router-gate.test.mjs | 43 +++++++++++++++++++++++ tools/enforce-tdd-gate.mjs | 6 ---- tools/enforce-tdd-gate.test.mjs | 6 ++++ tools/enforce-verify-before-push.mjs | 2 -- tools/enforce-verify-before-push.test.mjs | 3 ++ 12 files changed, 101 insertions(+), 20 deletions(-) diff --git a/tools/enforce-coverage-verify.mjs b/tools/enforce-coverage-verify.mjs index 6b7577fc..9c3dcc96 100644 --- a/tools/enforce-coverage-verify.mjs +++ b/tools/enforce-coverage-verify.mjs @@ -54,8 +54,6 @@ export function decide({ `Add as first line of next response:`, ` coverage: skill: (e.g., skill:superpowers:test-driven-development)`, ` coverage: direct: (e.g., direct:memory-sync, direct:git-recovery)`, - ``, - `Override: include "без скилов" or "direct ok" in your prompt.`, ].join('\n'), }; } diff --git a/tools/enforce-coverage-verify.test.mjs b/tools/enforce-coverage-verify.test.mjs index aeecede1..d4fe3cfb 100644 --- a/tools/enforce-coverage-verify.test.mjs +++ b/tools/enforce-coverage-verify.test.mjs @@ -14,6 +14,9 @@ describe('enforce-coverage-verify / decide', () => { }); expect(r.block).toBe(true); expect(r.message).toMatch(/no.*coverage/); + // 1A (2026-05-31): не рекламировать мёртвые override-фразы (findOverride — заглушка v4). + expect(r.message).not.toMatch(/Override:/); + expect(r.message).not.toMatch(/без скилов|direct ok/); }); it('blocks when coverage says skill but Skill tool not invoked', () => { diff --git a/tools/enforce-memory-coverage.mjs b/tools/enforce-memory-coverage.mjs index bc41e89d..7ae4b242 100644 --- a/tools/enforce-memory-coverage.mjs +++ b/tools/enforce-memory-coverage.mjs @@ -54,8 +54,6 @@ export function decide({ toolName, filePath, transcriptEntries, override }) { `Re-announce on a fresh assistant turn first:`, ` coverage: direct:memory-sync`, `Then retry the Edit/Write.`, - ``, - `Override: include the phrase "memory dump" in your prompt.`, ].join('\n'), }; } diff --git a/tools/enforce-memory-coverage.test.mjs b/tools/enforce-memory-coverage.test.mjs index 999691df..be2a9e9c 100644 --- a/tools/enforce-memory-coverage.test.mjs +++ b/tools/enforce-memory-coverage.test.mjs @@ -26,6 +26,9 @@ describe('enforce-memory-coverage / decide', () => { }); expect(r.block).toBe(true); expect(r.message).toMatch(/memory-sync/); + // 1A (2026-05-31): не рекламировать мёртвую override-фразу (findOverride — заглушка v4). + expect(r.message).not.toMatch(/Override:/); + expect(r.message).not.toMatch(/memory dump/); }); it('blocks memory path with no coverage at all', () => { diff --git a/tools/enforce-prompt-injection.mjs b/tools/enforce-prompt-injection.mjs index 058c8881..da64d3d2 100644 --- a/tools/enforce-prompt-injection.mjs +++ b/tools/enforce-prompt-injection.mjs @@ -58,8 +58,6 @@ export function buildReminder({ classification, recentFlags, override }) { lines.push('Adjust behaviour accordingly.'); lines.push(''); } - lines.push('Override vocabulary (substring-match in user prompt):'); - lines.push(' без скилов / direct ok / срочно / быстрый коммит / recovery / memory dump / ремонт инфраструктуры'); return lines.join('\n'); } diff --git a/tools/enforce-prompt-injection.test.mjs b/tools/enforce-prompt-injection.test.mjs index 8aa42e5b..fb0d4485 100644 --- a/tools/enforce-prompt-injection.test.mjs +++ b/tools/enforce-prompt-injection.test.mjs @@ -66,10 +66,12 @@ describe('enforce-prompt-injection / buildReminder', () => { expect(txt).toMatch(/verify-before-push/); }); - it('lists override-vocabulary phrases for user reference', () => { + it('does NOT advertise dead override-vocabulary phrases (v4 stub — 1A 2026-05-31)', () => { const txt = buildReminder({ classification: null, recentFlags: [] }); - expect(txt).toMatch(/без скилов/); - expect(txt).toMatch(/direct ok/); - expect(txt).toMatch(/срочно/); + // findOverride/loadOverrideVocab — заглушки (vocab removed in v4); реклама фраз + // вводила в заблуждение (фразы не работают). Баннер убран. + expect(txt).not.toMatch(/Override vocabulary/); + expect(txt).not.toMatch(/без скилов/); + expect(txt).not.toMatch(/ремонт инфраструктуры/); }); }); diff --git a/tools/enforce-router-gate.mjs b/tools/enforce-router-gate.mjs index 4fcfdb45..29ec7abc 100644 --- a/tools/enforce-router-gate.mjs +++ b/tools/enforce-router-gate.mjs @@ -50,7 +50,7 @@ export const BASH_HARD_BLACKLIST = [ { re: /(^|\s|;|&&|\|\|)chmod\b/, reason: 'chmod запрещён' }, { re: /(^|\s|;|&&|\|\|)chown\b/, reason: 'chown запрещён' }, { re: /(^|\s|;|&&|\|\|)chgrp\b/, reason: 'chgrp запрещён' }, - { re: /(?:^|[^0-9>&])>{1,2}(?![>&])/, reason: 'stdout redirect (>/>>) запрещён' }, + // stdout redirect (>/>>) — quote-aware проверка в matchBashHardBlacklist (STDOUT_REDIRECT_RE), не здесь (quirk 2, 2026-05-31) { re: /\b(?:node|nodejs)\s+(?:[^|;]*\s)?(?:-e|--eval|-p|--print)\b/, reason: 'node -e/--eval/-p запрещён' }, { re: /\bnode\s+(?:[^|;]*\s)?(?:-r|--require|--import|--experimental-loader)\b/, reason: 'node -r/--import запрещён' }, { re: /\bpython3?\s+-c\b/, reason: 'python -c запрещён' }, @@ -72,11 +72,46 @@ export const BASH_HARD_BLACKLIST = [ { re: /(^|\s|;|&&|\|\|)socat\b/, reason: 'G8: socat запрещён' }, ]; +// stdout redirect operator: `>`/`>>` не после цифры/>/& (исключает fd-dup 1>&2) +// и не перед >/& (так `>>` — один матч, `1>&2`/`2>&1` не ловятся). +const STDOUT_REDIRECT_RE = /(?:^|[^0-9>&])>{1,2}(?![>&])/; + +/** + * Бланкует нутро одинарно/двойно-кавыченных участков (сохраняя сами кавычки, + * длину и всё вне кавычек). Обратный слэш экранирует следующий символ (значит + * экранированная кавычка НЕ открывает участок). Нужно для quote-aware детекции + * редиректа (quirk 2): `>` внутри кавыченного аргумента (текст коммита, ) + * — не shell-редирект; настоящий оператор редиректа стоит ВНЕ кавычек и + * переживает бланковку. + */ +export function stripQuotedSpans(command) { + const s = String(command || ''); + let out = ''; + let quote = null; + let escaped = false; + for (const ch of s) { + if (escaped) { out += ch; escaped = false; continue; } + if (ch === '\\') { out += ch; escaped = true; continue; } + if (quote) { + if (ch === quote) { out += ch; quote = null; } else out += ' '; + continue; + } + if (ch === "'" || ch === '"') { out += ch; quote = ch; continue; } + out += ch; + } + return out; +} + export function matchBashHardBlacklist(command) { const s = String(command || ''); if (hasInjection(s)) return '#34: echo/printf prompt-injection запрещён'; - const stderr = stderrRedirectBlock(s); + // Quote-aware redirect detection (quirk 2): `>` / `2>` ВНУТРИ кавычек (текст + // коммита с или "2>1") — не редирект. Сначала бланкуем кавыченное; + // настоящие операторы редиректа вне кавычек — переживают. + const stripped = stripQuotedSpans(s); + const stderr = stderrRedirectBlock(stripped); if (stderr) return stderr; + if (STDOUT_REDIRECT_RE.test(stripped)) return 'stdout redirect (>/>>) запрещён'; return matchAny(BASH_HARD_BLACKLIST, s); } diff --git a/tools/enforce-router-gate.test.mjs b/tools/enforce-router-gate.test.mjs index 0207a70d..bbaa6c27 100644 --- a/tools/enforce-router-gate.test.mjs +++ b/tools/enforce-router-gate.test.mjs @@ -270,3 +270,46 @@ describe('SAFE_EXACT — narrow `cd app` whitelist (2026-05-31, owner-authorized expect(classifyBashCommand('cd app && frobnicate', {}).result).toBe('block'); }); }); + +import { stripQuotedSpans } from './enforce-router-gate.mjs'; + +describe('quote-aware redirect (quirk 2)', () => { + // False positives that must now be ALLOWED — `>` / `2>` живут внутри кавычек. + it('allows > inside double-quoted commit message (co-author )', () => { + expect(matchBashHardBlacklist('git commit -m "x "')).toBe(null); + }); + it('allows 2> inside double-quoted message', () => { + expect(matchBashHardBlacklist('git commit -m "fix 2>1 logging"')).toBe(null); + }); + it('allows lone quoted >', () => { + expect(matchBashHardBlacklist('git commit -m ">"')).toBe(null); + }); + // Real redirects (operator OUTSIDE quotes) must STILL BLOCK. + it('blocks spaced stdout redirect', () => { + expect(matchBashHardBlacklist('echo x > /tmp/f')).toBeTruthy(); + }); + it('blocks no-space stdout redirect', () => { + expect(matchBashHardBlacklist('echo x>/tmp/f')).toBeTruthy(); + }); + it('blocks append redirect', () => { + expect(matchBashHardBlacklist('echo x >> /tmp/f')).toBeTruthy(); + }); + it('blocks stderr redirect to file', () => { + expect(matchBashHardBlacklist('cmd 2> /tmp/err')).toBeTruthy(); + }); + it('blocks redirect after a closing quote', () => { + expect(matchBashHardBlacklist('echo "x" > /tmp/f')).toBeTruthy(); + }); +}); + +describe('stripQuotedSpans (quirk 2 helper)', () => { + it('blanks double-quoted interior, keeps outside', () => { + expect(stripQuotedSpans('a "b>c" > d')).toBe('a " " > d'); + }); + it('blanks single-quoted interior', () => { + expect(stripQuotedSpans("a 'x>y' z")).toBe("a ' ' z"); + }); + it('keeps backslash-escaped quote literal (no span opened)', () => { + expect(stripQuotedSpans('a \\" > b')).toBe('a \\" > b'); + }); +}); diff --git a/tools/enforce-tdd-gate.mjs b/tools/enforce-tdd-gate.mjs index 65480a67..ef9f8538 100644 --- a/tools/enforce-tdd-gate.mjs +++ b/tools/enforce-tdd-gate.mjs @@ -150,8 +150,6 @@ export function decide({ `[enforce-tdd-gate] task_type="${taskType}" requires a plan before production-code edit.`, `Either invoke superpowers:writing-plans via Skill tool,`, `or reference an existing plan file (docs/superpowers/plans/...) in this turn first.`, - ``, - `Override: "быстрый коммит" / "ремонт инфраструктуры" in your prompt.`, ].join('\n'), }; } @@ -167,8 +165,6 @@ export function decide({ `[enforce-tdd-gate] Production code edit on "${filePath}" without preceding test edit.`, `Write the failing test FIRST in the corresponding *.test.mjs / *.spec.ts / *Test.php.`, `Then run vitest/pest to confirm RED, then return to this prod-code Edit.`, - ``, - `Override: "срочно" / "быстрый коммит" / "ремонт инфраструктуры".`, ].join('\n'), }; } @@ -178,8 +174,6 @@ export function decide({ message: [ `[enforce-tdd-gate] Test was edited but no vitest/pest run with RED output observed in this turn.`, `Run the test suite (vitest run / composer test) to confirm RED before prod-code edit.`, - ``, - `Override: "срочно" / "быстрый коммит" / "ремонт инфраструктуры".`, ].join('\n'), }; } diff --git a/tools/enforce-tdd-gate.test.mjs b/tools/enforce-tdd-gate.test.mjs index becc7cf5..aae8049f 100644 --- a/tools/enforce-tdd-gate.test.mjs +++ b/tools/enforce-tdd-gate.test.mjs @@ -38,6 +38,8 @@ describe('enforce-tdd-gate / decide', () => { }); expect(r.block).toBe(true); expect(r.message).toMatch(/without preceding test edit/); + // 1A (2026-05-31): не рекламировать мёртвые override-фразы (findOverride — заглушка v4). + expect(r.message).not.toMatch(/Override:/); }); it('blocks when test edited but no vitest RED observed', () => { @@ -51,6 +53,8 @@ describe('enforce-tdd-gate / decide', () => { }); expect(r.block).toBe(true); expect(r.message).toMatch(/no vitest.*RED/); + // 1A (2026-05-31): не рекламировать мёртвые override-фразы (findOverride — заглушка v4). + expect(r.message).not.toMatch(/Override:/); }); it('allows after test edit + vitest RED', () => { @@ -107,6 +111,8 @@ describe('enforce-tdd-gate / decide', () => { }); expect(r.block).toBe(true); expect(r.message).toMatch(/requires a plan/); + // 1A (2026-05-31): не рекламировать мёртвые override-фразы (findOverride — заглушка v4). + expect(r.message).not.toMatch(/Override:/); }); it('allows feature edit when Skill(superpowers:writing-plans) invoked', () => { diff --git a/tools/enforce-verify-before-push.mjs b/tools/enforce-verify-before-push.mjs index ff8cd1ab..d52ecffd 100644 --- a/tools/enforce-verify-before-push.mjs +++ b/tools/enforce-verify-before-push.mjs @@ -70,8 +70,6 @@ export function decide({ toolName, command, sentinel, sentinelAge, override, ove message: [ `[enforce-verify-before-push] No verification artifact found.`, `Run a full test suite first (vitest run / composer test) before \`git ${kind}\`.`, - ``, - `Override: "срочно" / "быстрый коммит" / "ремонт инфраструктуры" in your prompt.`, ].join('\n'), }; } diff --git a/tools/enforce-verify-before-push.test.mjs b/tools/enforce-verify-before-push.test.mjs index fdcd8012..9bd23f59 100644 --- a/tools/enforce-verify-before-push.test.mjs +++ b/tools/enforce-verify-before-push.test.mjs @@ -153,6 +153,9 @@ describe('enforce-verify-before-push / decide', () => { }); expect(r.block).toBe(true); expect(r.message).toMatch(/No verification/); + // 1A (2026-05-31): не рекламировать мёртвые override-фразы (findOverride — заглушка v4). + expect(r.message).not.toMatch(/Override:/); + expect(r.message).not.toMatch(/срочно|ремонт инфраструктуры/); }); it('does NOT emit override-missing-justification diagnostic for overrides without requires_justification', () => {