From ee7acf6eaa3752d0fc5cdcd2793ea5617cdce565 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: Fri, 29 May 2026 20:45:23 +0300 Subject: [PATCH] fix(router-gate): allow 2>&1 fd-duplication, keep file-redirect block (review finding) --- tools/enforce-router-gate.mjs | 19 ++++++++++--------- tools/enforce-router-gate.test.mjs | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/tools/enforce-router-gate.mjs b/tools/enforce-router-gate.mjs index adef5368..23c4740e 100644 --- a/tools/enforce-router-gate.mjs +++ b/tools/enforce-router-gate.mjs @@ -23,20 +23,21 @@ import { import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs'; // ── stderr redirect (C16) ── -const STDERR_REDIRECT = /(?:2>>|2>|&>>|&>|\|&|2>&1\s*>)\s*([^\s|;&]+)?/g; const SAFE_SINKS = new Set(['/dev/null', '&1', '$null', 'nul']); function stderrRedirectBlock(cmd) { - STDERR_REDIRECT.lastIndex = 0; + // "2>&1 >file": stderr merged into stdout, then stdout redirected to a file → block. + if (/2>&1\s*>\s*[^\s|;&]/.test(cmd)) return 'C16: stderr→stdout с последующим file-redirect'; + const RE = /(2>>|2>|&>>|&>|\|&)\s*([^\s|;&]+)?/g; let m; - while ((m = STDERR_REDIRECT.exec(cmd)) !== null) { - const target = (m[1] || '').replace(/^['"]|['"]$/g, ''); - if (m[0].includes('2>&1') && !m[0].trim().endsWith('2>&1')) { - // "2>&1 >file" — file redirect detected separately; still block - return 'C16: stderr→stdout с последующим file-redirect'; - } + while ((m = RE.exec(cmd)) !== null) { + const op = m[1]; + const after = cmd.slice(m.index + op.length); + if (/^\s*&\d/.test(after)) continue; // fd-duplication (2>&1, 1>&2) — no file, allow + const target = (m[2] || '').replace(/^['"]|['"]$/g, ''); + if (!target) continue; // no file target captured → benign artifact if (SAFE_SINKS.has(target)) continue; - return `C16: stderr redirect к «${target || 'file'}» запрещён`; + return `C16: stderr redirect к «${target}» запрещён`; } return null; } diff --git a/tools/enforce-router-gate.test.mjs b/tools/enforce-router-gate.test.mjs index 681576ac..63a57e8f 100644 --- a/tools/enforce-router-gate.test.mjs +++ b/tools/enforce-router-gate.test.mjs @@ -137,3 +137,25 @@ describe('resolvePathNormalize', () => { expect(fn('"a\\b"')).toBe('a/b'); // default behaviour }); }); + +describe('stderr redirect — 2>&1 fd-duplication (review fix)', () => { + it('allows cat a 2>&1 (merge to stdout, no file)', () => { + expect(classifyBashCommand('cat a 2>&1', {}).result).toBe('allow'); + }); + it('allows cat a 2>/dev/null', () => { + expect(classifyBashCommand('cat a 2>/dev/null', {}).result).toBe('allow'); + }); + it('still blocks stderr redirect to a file', () => { + expect(classifyBashCommand('cat a 2> err.log', {}).result).toBe('block'); + expect(classifyBashCommand('cat a 2>> err.log', {}).result).toBe('block'); + }); + it('still blocks &> file', () => { + expect(classifyBashCommand('cat a &> out.log', {}).result).toBe('block'); + }); + it('allows 1>&2 fd-duplication', () => { + expect(classifyBashCommand('cat a 1>&2', {}).result).toBe('allow'); + }); + it('blocks 2>&1 followed by file redirect', () => { + expect(classifyBashCommand('cat a 2>&1 > out.txt', {}).result).toBe('block'); + }); +});