fix(bridge): system audit + 5-file bug fix — PATS Deny trigger removal, auto_resolved chat dedup, UUID filenames, IP rate limit leak, bot.py deque

This commit is contained in:
2026-03-15 22:59:47 +09:00
parent 429cae47b7
commit c9f44afcf1
9 changed files with 193 additions and 107 deletions

View File

@@ -502,3 +502,21 @@
- **해결**: (1) `FALSE_POSITIVE_RE``Deny|Allow Once|Allow This Conversation|Dismiss|Decline` 추가, (2) bot.py auto-approve에 reject-word command 차단 가드, (3) smart button_index 선택 (방어)
- **주의**: `FALSE_POSITIVE_RE`는 렌더러 인라인 스크립트 안에 있으므로 **VSIX 빌드 → AG 풀 재시작** 필요. 새 UI 버튼 패턴 추가 시 반드시 이 필터 점검. **수정 시 해당 파일만 보지 말고 전체 데이터 플로우(producer→consumer→side effects) 분석 필수 (AGENT.md 규칙 #10)**
### [2026-03-15] PATS 배열 Deny 트리거 — 근본 수정
- **증상**: DOM Observer가 "Deny"를 주 트리거로 사용하여 command="Deny" pending 생성. step_probe MERGE와 결합하여 디스코드에 Deny 더미 전달
- **원인**: `PATS` 배열에 `{re:/^Deny$/i, type:'permission'}`이 포함되어 있어 DOM 순서상 Deny가 먼저 스캔됨. `FALSE_POSITIVE_RE`가 HTTP POST 경로에서는 차단하지만 `writePendingApproval` 직접 파일 작성 경로를 우회
- **해결**: PATS에서 거절/보조 버튼(Deny, Reject all, Dismiss) 제거. 긍정 버튼만 그룹 트리거 → 보조 버튼은 `ALL_ACTION_RE` + `collectSiblingButtons`로 형제 수집
- **주의**: PATS = "그룹 생성 트리거", ALL_ACTION_RE = "형제 수집 패턴". 새 버튼 추가 시 이 2단계 구조를 반드시 이해하고 추가
### [2026-03-15] Auto-Resolved 채팅 폭주 — 루프 내 writeChatSnapshot
- **증상**: "✅ AG에서 직접 승인됨" 메시지가 pending 파일 개수(4~5개)만큼 Discord에 반복 전송
- **원인**: auto_resolved 루프 내부에서 매 파일마다 `writeChatSnapshot()` 호출. 또한 `conversation_id` 미검증으로 타 세션 pending도 오염
- **해결**: (1) writeChatSnapshot을 루프 바깥으로 이동(resolvedCount > 0일 때 1회), (2) `pd.conversation_id === activeSessionId` 조건 추가, (3) primaryCommand에서 'Deny'/'Allow' 텍스트 제외
- **주의**: Bridge 파일 루프에서 외부 시스템(Discord)에 메시지를 보낼 때는 반드시 루프 바깥에서 집계 후 1회 발송
### [2026-03-15] 이전 분석 오판(False Positive) — 교훈
- **증상**: 시스템 감사 시 P0/P1으로 보고한 문제들이 실제로는 코드 방어 로직(멱등성, try-catch, 의도된 exact-match)으로 이미 방어되고 있었음
- **원인**: 로컬 코드 스니펫만 보고 판단. 전체 데이터 생명주기를 끝까지 추적하지 않아 방어 로직을 놓침
- **해결**: 나노단위 전체 Flow 추적으로 교차 검증 후 진짜 결함만 추림 (P2 3건, P3 2건)
- **주의**: **코드 감사 시 반드시 producer→transport→consumer→side effects 전체 경로를 추적. 단편적 로컬 분석으로 위험도를 과장하지 말 것**

10
bot.py
View File

@@ -11,6 +11,7 @@ import asyncio
import json
import logging
import time
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
@@ -180,7 +181,7 @@ class GravityBot(commands.Bot):
self.session_category: discord.CategoryChannel | None = None
self.guild: discord.Guild | None = None
self.auto_approve_projects: set[str] = set() # projects with auto-approve enabled
self._processed_message_ids: set[int] = set() # dedup for Gateway event replay
self._processed_message_ids: deque[int] = deque(maxlen=200) # dedup for Gateway event replay
self._approval_messages: dict[str, int] = {} # FIX #4: request_id → discord message_id (for auto_resolved lookup)
self.gateway = None # Set by main.py in gateway mode
@@ -800,12 +801,7 @@ class GravityBot(commands.Bot):
# Dedup: Discord Gateway can deliver MESSAGE_CREATE twice on reconnection
if message.id in self._processed_message_ids:
return
self._processed_message_ids.add(message.id)
# Keep set bounded (last 200 messages)
if len(self._processed_message_ids) > 200:
excess = len(self._processed_message_ids) - 100
for _ in range(excess):
self._processed_message_ids.pop()
self._processed_message_ids.append(message.id)
# Determine project from channel
project = self.channel_to_project.get(message.channel.id)

View File

@@ -21,6 +21,7 @@ Transport layer:
import json
import time
import logging
import uuid
from abc import ABC, abstractmethod
from pathlib import Path
from dataclasses import dataclass, asdict
@@ -410,15 +411,21 @@ class BridgeProtocol:
fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()}
now = time.time()
MAX_AGE = 1800 # 30 minutes (matches Discord button timeout)
CLEANUP_AGE = 86400 # 1 day
for fname in self.transport.list_json_files("pending"):
data = self.transport.read_json("pending", fname)
if data is None:
continue
ts = data.get("timestamp", 0)
if now - ts > CLEANUP_AGE:
# Too old even to keep as expired — delete to prevent accumulation
self.transport.delete_file("pending", fname)
continue
if now - ts > MAX_AGE:
# Too old — mark expired and skip
data["status"] = "expired"
self.transport.write_json("pending", fname, data)
if data.get("status") != "expired":
data["status"] = "expired"
self.transport.write_json("pending", fname, data)
continue
if data.get("status") == "pending":
# Filter to known fields only
@@ -455,7 +462,7 @@ class BridgeProtocol:
def write_command(self, conversation_id: str, text: str, *, project_name: str = ""):
"""Write a user text command for Antigravity to consume."""
cmd_id = f"{int(time.time() * 1000)}"
cmd_id = f"{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}"
fname = f"{cmd_id}.json"
data = {

View File

@@ -16,6 +16,7 @@ import json
import os
import time
import logging
import uuid
from pathlib import Path
from bridge import LocalTransport, RemoteTransport
@@ -338,7 +339,7 @@ class CollectorBridge:
project_intervals[project] = _BASE_INTERVAL
project_empty_streak[project] = 0
for cmd in commands:
cmd_id = cmd.get("id", str(int(time.time() * 1000)))
cmd_id = cmd.get("id", f"{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}")
fname = f"{cmd_id}.json"
self.local.write_json("commands", fname, cmd)
logger.info(f"[COLLECTOR] ← Gateway: command [{project}] {cmd.get('text', '?')[:30]}")

View File

@@ -8,4 +8,5 @@
| 004 | 10:41~10:53 | 성능 최적화 3건 (pollResponseGroup 1500ms, renderer adaptive idle, Bot single-pass scanner) + VSIX 빌드 | `ae0509f` | ✅ |
| 005 | 15:17~17:09 | 크로스 프로젝트 신호 오염 진단 & 승인 플로우 아키텍처 수정 — DEDUP project_name 가드, double-fire auto-approve 제거, 실패 RPC 전략 30+개 삭제 (v0.3.11) | `6739f8f` | ✅ |
| 006 | 18:32~18:51 | Auto-approve 크래시 수정 — DOM Observer Deny false positive 필터 + Bot reject-word 가드 + AGENT.md 규칙 #10 추가 | `5e5f515` | ✅ |
| 007 | 22:00~22:52 | 시스템 전체 감사 + 5개 파일 버그 수정 (PATS Deny 트리거 제거, auto_resolved 채팅 병합, UUID 파일명 충돌방지, IP rate limit 누수, bot.py deque) + VSIX 빌드/배포 | `dcb6bb0` | ✅ |

View File

@@ -1222,16 +1222,15 @@ function generateApprovalObserverScript(_port) {
});
// ── Button patterns to detect (order matters: first match wins per scan) ──
// ONLY positive triggers should initiate a pending request group.
// Negative/secondary buttons (Deny, Reject, Dismiss) will be collected as siblings.
var PATS=[
{re:/^Run/i, type:'terminal_command'},
{re:/^Accept all$/i, type:'diff_review'},
{re:/^Reject all$/i, type:'diff_review'},
{re:/^Accept$/i, type:'agent_step'},
{re:/^Allow/i, type:'permission'},
{re:/^Approve/i, type:'agent_step'},
{re:/^Deny$/i, type:'permission'},
{re:/^Retry$/i, type:'error_recovery'},
{re:/^Dismiss$/i, type:'error_recovery'},
];
// ALL actionable button patterns (for grouping siblings in same container)
@@ -1932,33 +1931,42 @@ function setupMonitor() {
// Steps progressed — if we had a pending approval, it was handled in AG directly
if (!sawRunningAfterPending && lastPendingStepIndex >= 0) {
// Mark pending as auto_resolved so bot can update Discord message
try {
const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending'))
.filter((f) => f.endsWith('.json'));
const nowMs = Date.now();
for (const pf of pendingFiles) {
const pfPath = path.join(bridgePath, 'pending', pf);
let resolvedCount = 0;
let primaryCommand = '';
const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending')).filter((f) => f.endsWith('.json'));
const nowMs = Date.now();
for (const pf of pendingFiles) {
const pfPath = path.join(bridgePath, 'pending', pf);
try {
const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8'));
if (pd.status !== 'pending')
continue;
// Skip other projects' pendings
if (pd.project_name && pd.project_name !== projectName)
continue;
// Match by step_index OR by recency (< 60s, any source)
// Limit to same session AND (same step or recent)
const ageMs = nowMs - (pd.timestamp * 1000);
const isMatch = pd.step_index === lastPendingStepIndex
|| (ageMs < 60_000 && ageMs >= 0);
const isMatch = (pd.conversation_id === activeSessionId) &&
(pd.step_index === lastPendingStepIndex || (ageMs < 60_000 && ageMs >= 0));
if (isMatch) {
pd.status = 'auto_resolved';
fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8');
logToFile(`[AUTO-RESOLVE] step=${lastPendingStepIndex} progressed → marked ${pf} (age=${Math.round(ageMs / 1000)}s)`);
// FIX #3: Notify Discord that user approved locally
writeChatSnapshot(`✅ **AG에서 직접 승인됨** (step ${lastPendingStepIndex})\n\n\`${(pd.command || '').substring(0, 200)}\``);
resolvedCount++;
const cmd = pd.command || '';
if (cmd.length > primaryCommand.length && cmd !== 'Deny' && !cmd.includes('Allow')) {
primaryCommand = cmd;
}
else if (!primaryCommand) {
primaryCommand = cmd;
}
}
}
catch (e) {
logToFile(`[AUTO-RESOLVE] parse error for ${pf}: ${e.message}`);
}
}
catch (e) {
logToFile(`[AUTO-RESOLVE] error: ${e.message}`);
if (resolvedCount > 0) {
logToFile(`[AUTO-RESOLVE] step=${lastPendingStepIndex} progressed → marked ${resolvedCount} pending(s)`);
writeChatSnapshot(`✅ **AG에서 직접 진행됨** (step ${lastPendingStepIndex})\n\n\`${primaryCommand.substring(0, 200)}\``);
}
lastPendingStepIndex = -1;
}
@@ -2437,37 +2445,53 @@ function setupResponseWatcher() {
if (!fs.existsSync(responseDir)) {
fs.mkdirSync(responseDir, { recursive: true });
}
const processAnyResponse = (filename) => {
const fp = path.join(responseDir, filename);
if (fs.existsSync(fp)) {
// Check if this response belongs to our project
const rid = filename.replace('.json', '');
const pendingFile = path.join(bridgePath, 'pending', `${rid}.json`);
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
if (pending.project_name && pending.project_name !== projectName) {
// logToFile(`[RESPONSE] skip ${rid} (project=${pending.project_name}, we=${projectName})`);
return; // Not our project
}
}
catch { }
}
else {
// Pending file missing (deleted or auto_resolved) — check response data itself
try {
const respData = JSON.parse(fs.readFileSync(fp, 'utf-8'));
if (respData.project_name && respData.project_name !== projectName) {
// logToFile(`[RESPONSE] skip (from resp data) ${rid} (project=${respData.project_name}, we=${projectName})`);
return;
}
}
catch { }
}
setTimeout(() => processResponseFile(fp), 300);
}
};
const pollAllResponses = () => {
try {
if (!fs.existsSync(responseDir))
return;
for (const f of fs.readdirSync(responseDir)) {
if (f.endsWith('.json')) {
processAnyResponse(f);
}
}
}
catch { }
};
pollAllResponses(); // Process any existing responses on startup
try {
responseWatcher = fs.watch(responseDir, (event, filename) => {
if (filename && filename.endsWith('.json') && event === 'rename') {
const fp = path.join(responseDir, filename);
if (fs.existsSync(fp)) {
// Check if this response belongs to our project
const rid = filename.replace('.json', '');
const pendingFile = path.join(bridgePath, 'pending', `${rid}.json`);
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
if (pending.project_name && pending.project_name !== projectName) {
logToFile(`[RESPONSE] skip ${rid} (project=${pending.project_name}, we=${projectName})`);
return; // Not our project
}
}
catch { }
}
else {
// Pending file missing (deleted or auto_resolved) — check response data itself
try {
const respData = JSON.parse(fs.readFileSync(fp, 'utf-8'));
if (respData.project_name && respData.project_name !== projectName) {
logToFile(`[RESPONSE] skip (from resp data) ${rid} (project=${respData.project_name}, we=${projectName})`);
return;
}
}
catch { }
}
setTimeout(() => processResponseFile(fp), 300);
}
processAnyResponse(filename);
}
});
console.log('Gravity Bridge: response watcher started');
@@ -2475,6 +2499,8 @@ function setupResponseWatcher() {
catch (e) {
console.log(`Gravity Bridge: response watcher failed: ${e.message}`);
}
// Polling fallback: fs.watch on Windows can silently fail
setInterval(pollAllResponses, 3000);
}
async function processResponseFile(filePath) {
try {

File diff suppressed because one or more lines are too long

View File

@@ -1216,16 +1216,15 @@ function generateApprovalObserverScript(_port: number): string {
});
// ── Button patterns to detect (order matters: first match wins per scan) ──
// ONLY positive triggers should initiate a pending request group.
// Negative/secondary buttons (Deny, Reject, Dismiss) will be collected as siblings.
var PATS=[
{re:/^Run/i, type:'terminal_command'},
{re:/^Accept all$/i, type:'diff_review'},
{re:/^Reject all$/i, type:'diff_review'},
{re:/^Accept$/i, type:'agent_step'},
{re:/^Allow/i, type:'permission'},
{re:/^Approve/i, type:'agent_step'},
{re:/^Deny$/i, type:'permission'},
{re:/^Retry$/i, type:'error_recovery'},
{re:/^Dismiss$/i, type:'error_recovery'},
];
// ALL actionable button patterns (for grouping siblings in same container)
@@ -1934,29 +1933,37 @@ function setupMonitor() {
// Steps progressed — if we had a pending approval, it was handled in AG directly
if (!sawRunningAfterPending && lastPendingStepIndex >= 0) {
// Mark pending as auto_resolved so bot can update Discord message
try {
const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending'))
.filter((f: string) => f.endsWith('.json'));
let resolvedCount = 0;
let primaryCommand = '';
const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending')).filter((f: string) => f.endsWith('.json'));
const nowMs = Date.now();
for (const pf of pendingFiles) {
const pfPath = path.join(bridgePath, 'pending', pf);
const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8'));
if (pd.status !== 'pending') continue;
// Skip other projects' pendings
if (pd.project_name && pd.project_name !== projectName) continue;
// Match by step_index OR by recency (< 60s, any source)
const ageMs = nowMs - (pd.timestamp * 1000);
const isMatch = pd.step_index === lastPendingStepIndex
|| (ageMs < 60_000 && ageMs >= 0);
if (isMatch) {
pd.status = 'auto_resolved';
fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8');
logToFile(`[AUTO-RESOLVE] step=${lastPendingStepIndex} progressed → marked ${pf} (age=${Math.round(ageMs/1000)}s)`);
// FIX #3: Notify Discord that user approved locally
writeChatSnapshot(`✅ **AG에서 직접 승인됨** (step ${lastPendingStepIndex})\n\n\`${(pd.command || '').substring(0, 200)}\``);
}
try {
const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8'));
if (pd.status !== 'pending') continue;
if (pd.project_name && pd.project_name !== projectName) continue;
// Limit to same session AND (same step or recent)
const ageMs = nowMs - (pd.timestamp * 1000);
const isMatch = (pd.conversation_id === activeSessionId) &&
(pd.step_index === lastPendingStepIndex || (ageMs < 60_000 && ageMs >= 0));
if (isMatch) {
pd.status = 'auto_resolved';
fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8');
resolvedCount++;
const cmd = pd.command || '';
if (cmd.length > primaryCommand.length && cmd !== 'Deny' && !cmd.includes('Allow')) {
primaryCommand = cmd;
} else if (!primaryCommand) {
primaryCommand = cmd;
}
}
} catch (e: any) { logToFile(`[AUTO-RESOLVE] parse error for ${pf}: ${e.message}`); }
}
if (resolvedCount > 0) {
logToFile(`[AUTO-RESOLVE] step=${lastPendingStepIndex} progressed → marked ${resolvedCount} pending(s)`);
writeChatSnapshot(`✅ **AG에서 직접 진행됨** (step ${lastPendingStepIndex})\n\n\`${primaryCommand.substring(0, 200)}\``);
}
} catch (e: any) { logToFile(`[AUTO-RESOLVE] error: ${e.message}`); }
lastPendingStepIndex = -1;
}
consecutiveIdleCount = 0;
@@ -2411,40 +2418,60 @@ function setupResponseWatcher() {
fs.mkdirSync(responseDir, { recursive: true });
}
const processAnyResponse = (filename: string) => {
const fp = path.join(responseDir, filename);
if (fs.existsSync(fp)) {
// Check if this response belongs to our project
const rid = filename.replace('.json', '');
const pendingFile = path.join(bridgePath, 'pending', `${rid}.json`);
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
if (pending.project_name && pending.project_name !== projectName) {
// logToFile(`[RESPONSE] skip ${rid} (project=${pending.project_name}, we=${projectName})`);
return; // Not our project
}
} catch { }
} else {
// Pending file missing (deleted or auto_resolved) — check response data itself
try {
const respData = JSON.parse(fs.readFileSync(fp, 'utf-8'));
if (respData.project_name && respData.project_name !== projectName) {
// logToFile(`[RESPONSE] skip (from resp data) ${rid} (project=${respData.project_name}, we=${projectName})`);
return;
}
} catch { }
}
setTimeout(() => processResponseFile(fp), 300);
}
};
const pollAllResponses = () => {
try {
if (!fs.existsSync(responseDir)) return;
for (const f of fs.readdirSync(responseDir)) {
if (f.endsWith('.json')) {
processAnyResponse(f);
}
}
} catch { }
};
pollAllResponses(); // Process any existing responses on startup
try {
responseWatcher = fs.watch(responseDir, (event, filename) => {
if (filename && filename.endsWith('.json') && event === 'rename') {
const fp = path.join(responseDir, filename);
if (fs.existsSync(fp)) {
// Check if this response belongs to our project
const rid = filename.replace('.json', '');
const pendingFile = path.join(bridgePath, 'pending', `${rid}.json`);
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
if (pending.project_name && pending.project_name !== projectName) {
logToFile(`[RESPONSE] skip ${rid} (project=${pending.project_name}, we=${projectName})`);
return; // Not our project
}
} catch { }
} else {
// Pending file missing (deleted or auto_resolved) — check response data itself
try {
const respData = JSON.parse(fs.readFileSync(fp, 'utf-8'));
if (respData.project_name && respData.project_name !== projectName) {
logToFile(`[RESPONSE] skip (from resp data) ${rid} (project=${respData.project_name}, we=${projectName})`);
return;
}
} catch { }
}
setTimeout(() => processResponseFile(fp), 300);
}
processAnyResponse(filename);
}
});
console.log('Gravity Bridge: response watcher started');
} catch (e: any) {
console.log(`Gravity Bridge: response watcher failed: ${e.message}`);
}
// Polling fallback: fs.watch on Windows can silently fail
setInterval(pollAllResponses, 3000);
}
async function processResponseFile(filePath: string) {

View File

@@ -20,6 +20,7 @@ import asyncio
import json
import time
import logging
import uuid
from collections import defaultdict
from pathlib import Path
from aiohttp import web
@@ -94,6 +95,15 @@ class GatewayAPI:
window.append(now)
self._rate_limits[ip] = window
# Memory leak prevention: Cleanup stale IPs when mapping grows too large
if len(self._rate_limits) > 1000:
for k in list(self._rate_limits.keys()):
active = [t for t in self._rate_limits[k] if now - t < RATE_LIMIT_WINDOW]
if active:
self._rate_limits[k] = active
else:
del self._rate_limits[k]
return await handler(request)
# ─── Health ───
@@ -111,7 +121,7 @@ class GatewayAPI:
"""Collector pushes a pending approval request."""
try:
data = await request.json()
rid = data.get("request_id", str(int(time.time() * 1000)))
rid = data.get("request_id", f"{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}")
data["request_id"] = rid
data.setdefault("timestamp", time.time())
data.setdefault("status", "pending")
@@ -166,7 +176,7 @@ class GatewayAPI:
snap_dir = self.bot.bridge.transport.bridge_dir / "chat_snapshots" if hasattr(self.bot.bridge.transport, 'bridge_dir') else None
if snap_dir:
snap_dir.mkdir(parents=True, exist_ok=True)
snap_id = f"{int(time.time() * 1000)}"
snap_id = f"{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}"
snap_data = {
"id": snap_id,
"project_name": project,