fix(router-gate): quote-aware redirect detector + drop dead override-phrase ads

Квирк 2: новый stripQuotedSpans делает детектор stdout/stderr-редиректа
кавычко-осознанным — `>` / `2>` ВНУТРИ кавыченного аргумента (текст коммита
с <email>, "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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-31 14:05:52 +03:00
parent 30b79c7228
commit b0cd18d797
12 changed files with 101 additions and 20 deletions
-2
View File
@@ -54,8 +54,6 @@ export function decide({
`Add as first line of next response:`,
` coverage: skill:<name> (e.g., skill:superpowers:test-driven-development)`,
` coverage: direct:<role> (e.g., direct:memory-sync, direct:git-recovery)`,
``,
`Override: include "без скилов" or "direct ok" in your prompt.`,
].join('\n'),
};
}
+3
View File
@@ -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', () => {
-2
View File
@@ -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'),
};
}
+3
View File
@@ -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', () => {
-2
View File
@@ -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');
}
+6 -4
View File
@@ -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(/ремонт инфраструктуры/);
});
});
+37 -2
View File
@@ -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): `>` внутри кавыченного аргумента (текст коммита, <email>)
* — не 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>` ВНУТРИ кавычек (текст
// коммита с <email> или "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);
}
+43
View File
@@ -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 <email>)', () => {
expect(matchBashHardBlacklist('git commit -m "x <noreply@anthropic.com>"')).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');
});
});
-6
View File
@@ -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 <test-file> / composer test) to confirm RED before prod-code edit.`,
``,
`Override: "срочно" / "быстрый коммит" / "ремонт инфраструктуры".`,
].join('\n'),
};
}
+6
View File
@@ -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', () => {
-2
View File
@@ -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'),
};
}
@@ -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', () => {