feat(router-gate): shared classifyGitCommand (readonly/conditional/hard incl G5/G6)

This commit is contained in:
Дмитрий
2026-05-29 19:59:14 +03:00
parent 2006dbd544
commit e791d70d0e
2 changed files with 111 additions and 0 deletions
+62
View File
@@ -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` };
}
+49
View File
@@ -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');
});
});