From e791d70d0e5bd9b8fcdedee7f055e5a49efb696b 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 19:59:14 +0300 Subject: [PATCH] feat(router-gate): shared classifyGitCommand (readonly/conditional/hard incl G5/G6) --- tools/shell-content-rules.mjs | 62 ++++++++++++++++++++++++++++++ tools/shell-content-rules.test.mjs | 49 +++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/tools/shell-content-rules.mjs b/tools/shell-content-rules.mjs index 9df5bc41..5565d7ce 100644 --- a/tools/shell-content-rules.mjs +++ b/tools/shell-content-rules.mjs @@ -108,4 +108,66 @@ export function loadApprovedGitOps(sessionId, now = Date.now()) { } } catch { return []; } return out.filter((op) => now - op.ts <= APPROVE_WINDOW_MS); +} +// ── git classification (shared Bash + PowerShell) ── +const GIT_READONLY_SUB = new Set([ + 'status', 'log', 'show', 'diff', 'blame', 'format-patch', + 'rev-parse', 'merge-base', 'remote', 'stash', // stash list/show resolved below +]); +const GIT_CONDITIONAL_SUB = new Set([ + 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch', + 'branch', 'stash', 'cherry-pick', 'revert', 'pull', 'push', 'clean', +]); + +// G5/G6 + force-push + add -f → always block (даже если "approved"). +const GIT_HARD_PATTERNS = [ + { re: /\bgit\s+(?:commit|push|tag|merge|rebase|cherry-pick|revert)\b[^\n]*--no-verify\b/, reason: 'G5: git --no-verify (обход хуков) запрещён' }, + { re: /\bgit\s+-c\s+(?:commit|tag)\.gpgsign\s*=\s*false\b/, reason: 'G6: обход gpg-подписи запрещён' }, + { re: /\bgit\s+commit\b[^\n]*--no-gpg-sign\b/, reason: 'G6: --no-gpg-sign запрещён' }, + { re: /\bgit\s+push\b[^\n]*(?:--force\b|--force-with-lease\b|\s-f\b)/, reason: 'git push --force запрещён' }, + { re: /\bgit\s+add\b[^\n]*\s-f\b/, reason: 'git add -f (форс gitignored) запрещён' }, +]; + +function gitSubcommand(command) { + const m = normalizeCommand(command).match(/\bgit\s+(?:-c\s+\S+\s+)*([a-z][\w-]*)/); + return m ? m[1] : null; +} + +export function classifyGitCommand(command, ctx = {}) { + const norm = normalizeCommand(command); + if (!/\bgit\b/.test(norm)) return null; + const sub = gitSubcommand(command); + if (!sub) return null; + + // 1. git-hard — block безусловно + const hard = matchAny(GIT_HARD_PATTERNS, norm); + if (hard) return { result: 'block', reason: hard }; + + // 2. stash/remote: list/show readonly; pop/apply/drop/clear/push/save conditional + if (sub === 'stash') { + if (/\bgit\s+stash\s+(?:list|show)\b/.test(norm)) return { result: 'allow', reason: 'readonly git stash' }; + // fallthrough → conditional + } + if (sub === 'branch') { + if (/\bgit\s+branch\s+(?:--show-current|-a|-r|--list)\b/.test(norm) || /\bgit\s+branch\s*$/.test(norm)) return { result: 'allow', reason: 'readonly git branch' }; + // fallthrough → conditional + } + + if (sub === 'remote') { + if (/\bgit\s+remote\s+(?:-v\b|show\b|$)/.test(norm)) return { result: 'allow', reason: 'readonly git remote' }; + return { result: 'block', reason: 'git remote (мутация) требует AskUser approval' }; + } + + // 3. conditional → approve check + if (GIT_CONDITIONAL_SUB.has(sub)) { + const approved = isApproved(command, ctx.approvedGitOps, ctx.now ?? Date.now()); + if (approved) return { result: 'allow', reason: `git ${sub}: подтверждено approve_git_operation` }; + return { result: 'block', reason: `git ${sub} требует AskUser approval (approve_git_operation). Запросите подтверждение и повторите.` }; + } + + // 4. readonly + if (GIT_READONLY_SUB.has(sub)) return { result: 'allow', reason: `readonly git ${sub}` }; + + // 5. unknown git subcommand → default-deny + return { result: 'block', reason: `git ${sub} не в whitelist — default-deny` }; } \ No newline at end of file diff --git a/tools/shell-content-rules.test.mjs b/tools/shell-content-rules.test.mjs index 004e8db5..f2d699ce 100644 --- a/tools/shell-content-rules.test.mjs +++ b/tools/shell-content-rules.test.mjs @@ -113,3 +113,52 @@ describe('isApproved (one-shot + 5-min window)', () => { expect(isApproved('git commit', undefined, now)).toBe(false); }); }); +import { classifyGitCommand } from './shell-content-rules.mjs'; + +describe('classifyGitCommand — readonly', () => { + it.each(['git status', 'git log --oneline', 'git diff HEAD~1', 'git branch --show-current', 'git remote -v'])( + 'allows %s', + (cmd) => { + expect(classifyGitCommand(cmd, {}).result).toBe('allow'); + }, + ); + it('returns null for non-git', () => { + expect(classifyGitCommand('ls -la', {})).toBe(null); + }); +}); + +describe('classifyGitCommand — conditional after approve', () => { + const now = 2_000_000; + it('blocks unapproved git commit', () => { + const r = classifyGitCommand('git commit -m "x"', { approvedGitOps: [], now }); + expect(r.result).toBe('block'); + expect(r.reason).toMatch(/approve/i); + }); + it('allows approved git commit', () => { + const r = classifyGitCommand('git commit -m "x"', { + approvedGitOps: [{ command: 'git commit -m "x"', ts: now }], + now, + }); + expect(r.result).toBe('allow'); + }); + it.each(['git rebase main', 'git reset --hard', 'git switch main', 'git stash pop', 'git push origin feat'])( + 'blocks unapproved %s', + (cmd) => { + expect(classifyGitCommand(cmd, { approvedGitOps: [], now }).result).toBe('block'); + }, + ); +}); + +describe('classifyGitCommand — git-hard (always block)', () => { + it.each([ + 'git push --force origin main', + 'git push -f origin master', + 'git commit --no-verify -m "x"', + 'git -c commit.gpgsign=false commit -m "x"', + 'git commit --no-gpg-sign -m "x"', + 'git push --no-verify', + ])('blocks %s', (cmd) => { + const r = classifyGitCommand(cmd, { approvedGitOps: [{ command: cmd, ts: Date.now() }], now: Date.now() }); + expect(r.result).toBe('block'); + }); +});