feat(router-gate): shared classifyGitCommand (readonly/conditional/hard incl G5/G6)
This commit is contained in:
@@ -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` };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user